added working scriptable widget for ai api usage info
This commit is contained in:
@@ -1,336 +0,0 @@
|
|||||||
o# Usage API Enhancements Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Enhance the `/usage` API endpoint to provide complete data for a dashboard widget, including models with zero usage, daily breakdowns, provider health, and lifetime token totals.
|
|
||||||
|
|
||||||
## All changes in `proxy.mjs` only — zero new dependencies.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pre-existing Bug to Fix
|
|
||||||
|
|
||||||
**`buildUsageResponse()` line 232 has an unresolved Promise bug:**
|
|
||||||
|
|
||||||
```js
|
|
||||||
const balances = fetchProviderBalances(); // Start fetching but don't await
|
|
||||||
```
|
|
||||||
|
|
||||||
This assigns a Promise to `balances`, then on line 303:
|
|
||||||
```js
|
|
||||||
if (!balances.anthropic) { // always undefined — checking property on a Promise object
|
|
||||||
```
|
|
||||||
|
|
||||||
The notes about balances always fire incorrectly. The actual await happens later in the `/usage` handler (line 557). **Fix**: Remove the balance-related code from `buildUsageResponse()` entirely since it's handled in the request handler already. Move the notes logic to after balances are resolved in the `/usage` handler.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Change 1: Seed All Configured Models (Must Have)
|
|
||||||
|
|
||||||
**What**: Ensure every route in `MODEL_ROUTES` appears in the response even with zero usage.
|
|
||||||
|
|
||||||
**Where**: `buildUsageResponse()` — add a loop after the existing `usageData.models` iteration (after line ~282).
|
|
||||||
|
|
||||||
**Logic**:
|
|
||||||
```
|
|
||||||
For each [requestedModel, route] in MODEL_ROUTES:
|
|
||||||
providerName = route.provider
|
|
||||||
if providers[providerName].models[requestedModel] does NOT exist:
|
|
||||||
add it with:
|
|
||||||
routesTo: route.rewriteModel || requestedModel
|
|
||||||
total/monthly/today: all zeros
|
|
||||||
costUsd: null for flat-rate, 0 for token-based
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result**: The widget sees all available routes including zero-usage models like:
|
|
||||||
```json
|
|
||||||
"deepseek": {
|
|
||||||
"models": {
|
|
||||||
"claude-haiku-4-5": { "routesTo": "deepseek-chat", ... },
|
|
||||||
"claude-haiku-4-5-20251001": { "routesTo": "deepseek-chat", ... },
|
|
||||||
"deepseek-chat": { "routesTo": "deepseek-chat", ... }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The widget can deduplicate by `routesTo` if it wants to show only unique upstream models.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Change 2: Daily Usage Tracking (Nice to Have)
|
|
||||||
|
|
||||||
**What**: Track per-day usage alongside existing total and monthly tracking.
|
|
||||||
|
|
||||||
### 2a. Add `getCurrentDate()` helper (new function, near `getCurrentPeriod()` at line 82)
|
|
||||||
|
|
||||||
```js
|
|
||||||
function getCurrentDate() {
|
|
||||||
const now = new Date();
|
|
||||||
return now.toISOString().slice(0, 10); // "2026-03-27"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2b. Extend `recordUsage()` (line 117)
|
|
||||||
|
|
||||||
After the monthly period rollover check (line 133), add daily rollover:
|
|
||||||
```js
|
|
||||||
const todayStr = getCurrentDate();
|
|
||||||
if (!modelData.daily || modelData.daily.date !== todayStr) {
|
|
||||||
modelData.daily = { date: todayStr, inputTokens: 0, outputTokens: 0, requests: 0 };
|
|
||||||
}
|
|
||||||
modelData.daily.inputTokens += inputTokens || 0;
|
|
||||||
modelData.daily.outputTokens += outputTokens || 0;
|
|
||||||
modelData.daily.requests += 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2c. Backward compatibility
|
|
||||||
|
|
||||||
Existing `usage-data.json` entries won't have `daily`. In `buildUsageResponse()`, check for its existence:
|
|
||||||
```js
|
|
||||||
const daily = modelData.daily?.date === getCurrentDate() ? modelData.daily : null;
|
|
||||||
```
|
|
||||||
If `daily` is null or stale date, use zeros.
|
|
||||||
|
|
||||||
### 2d. Schema addition to `usage-data.json` (auto-updated on next request)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"daily": { "date": "2026-03-27", "inputTokens": 0, "outputTokens": 0, "requests": 0 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Change 3: Provider Status Tracking (Nice to Have)
|
|
||||||
|
|
||||||
**What**: In-memory map tracking last request outcome per provider. Resets to `"idle"` on restart.
|
|
||||||
|
|
||||||
### 3a. Declare `providerStatus` (new module-level const, after line ~78)
|
|
||||||
|
|
||||||
```js
|
|
||||||
const providerStatus = {};
|
|
||||||
for (const name of Object.keys(PROVIDERS)) {
|
|
||||||
providerStatus[name] = { status: "idle", lastSeen: null, lastError: null };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3b. Update status in `handleMessages()` — three insertion points:
|
|
||||||
|
|
||||||
1. **Streaming success** (after line ~475, after usage extraction in streaming path):
|
|
||||||
```js
|
|
||||||
providerStatus[route.provider] = { status: "ok", lastSeen: new Date().toISOString(), lastError: null };
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Non-streaming response** (after line ~487, inside the non-streaming else branch):
|
|
||||||
- If `statusCode >= 200 && statusCode < 300`: status `"ok"`
|
|
||||||
- Else: status `"degraded"` with `lastError: "HTTP " + statusCode`
|
|
||||||
|
|
||||||
3. **Fetch failure** (inside catch at line ~505):
|
|
||||||
```js
|
|
||||||
providerStatus[route.provider] = { status: "error", lastSeen: new Date().toISOString(), lastError: fetchErr.message };
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3c. Include in `buildUsageResponse()` output
|
|
||||||
|
|
||||||
For each provider, add `status`, `lastSeen`, `lastError` from the in-memory map.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Change 4: Lifetime Token Totals in `totals` (Low Priority)
|
|
||||||
|
|
||||||
**What**: Add `totalInputTokens`, `totalOutputTokens`, `monthlyInputTokens`, `monthlyOutputTokens` to the `totals` response object.
|
|
||||||
|
|
||||||
**Where**: `buildUsageResponse()` aggregation loop (line ~248). Add four new accumulators initialized to 0, incremented alongside existing `totalRequests`/`monthlyRequests`.
|
|
||||||
|
|
||||||
**New fields in `totals`**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"totalInputTokens": 105000,
|
|
||||||
"totalOutputTokens": 262000,
|
|
||||||
"monthlyInputTokens": 105000,
|
|
||||||
"monthlyOutputTokens": 262000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Change 5: Per-Model Pricing (Low Priority)
|
|
||||||
|
|
||||||
**What**: Include pricing info on each model entry so the widget doesn't have to cross-reference provider pricing.
|
|
||||||
|
|
||||||
**Where**: `buildUsageResponse()` — when building each model's output object.
|
|
||||||
|
|
||||||
**Logic**:
|
|
||||||
```js
|
|
||||||
const pricing = config.pricing?.[providerName] || DEFAULT_PRICING[providerName];
|
|
||||||
modelEntry.pricing = pricing?.type === "flat" ? "flat_rate" : { inputPerMTok: pricing.inputPerMTok, outputPerMTok: pricing.outputPerMTok };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Change 6: Today's Aggregates in `totals` (Nice to Have)
|
|
||||||
|
|
||||||
**What**: Add `todayRequests`, `todayCostUsd`, `todayInputTokens`, `todayOutputTokens` to the `totals` object.
|
|
||||||
|
|
||||||
**Where**: `buildUsageResponse()` — aggregate daily data across all models. Depends on Change 2 (daily tracking).
|
|
||||||
|
|
||||||
**New accumulators**: `todayRequests`, `todayCost`, `todayInputTokens`, `todayOutputTokens` — sum of all models' `today` values.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed `/usage` Response Shape (complete)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"service": "ein-ai-proxy",
|
|
||||||
"generatedAt": "2026-03-27T15:51:00.000Z",
|
|
||||||
"currentPeriod": "2026-03",
|
|
||||||
"providers": {
|
|
||||||
"anthropic": {
|
|
||||||
"status": "ok",
|
|
||||||
"lastSeen": "2026-03-27T14:30:00.000Z",
|
|
||||||
"lastError": null,
|
|
||||||
"balance": null,
|
|
||||||
"pricingNote": "No balance API available - check console.anthropic.com",
|
|
||||||
"pricing": { "inputPerMTok": 15, "outputPerMTok": 75 },
|
|
||||||
"models": {
|
|
||||||
"claude-opus-4-6": {
|
|
||||||
"routesTo": "claude-opus-4-6",
|
|
||||||
"pricing": { "inputPerMTok": 15, "outputPerMTok": 75 },
|
|
||||||
"total": { "inputTokens": 5000, "outputTokens": 12000, "requests": 3, "costUsd": 0.975 },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 5000, "outputTokens": 12000, "requests": 3, "costUsd": 0.975 },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 2000, "outputTokens": 5000, "requests": 1, "costUsd": 0.405 }
|
|
||||||
},
|
|
||||||
"claude-opus-4-5": {
|
|
||||||
"routesTo": "claude-opus-4-5",
|
|
||||||
"pricing": { "inputPerMTok": 15, "outputPerMTok": 75 },
|
|
||||||
"total": { "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 }
|
|
||||||
},
|
|
||||||
"claude-opus-4-5-20251101": {
|
|
||||||
"routesTo": "claude-opus-4-5-20251101",
|
|
||||||
"pricing": { "inputPerMTok": 15, "outputPerMTok": 75 },
|
|
||||||
"total": { "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deepseek": {
|
|
||||||
"status": "idle",
|
|
||||||
"lastSeen": null,
|
|
||||||
"lastError": null,
|
|
||||||
"balance": { "totalBalance": "4.51", "currency": "USD" },
|
|
||||||
"pricing": { "inputPerMTok": 0.27, "outputPerMTok": 1.10 },
|
|
||||||
"models": {
|
|
||||||
"claude-haiku-4-5-20251001": {
|
|
||||||
"routesTo": "deepseek-chat",
|
|
||||||
"pricing": { "inputPerMTok": 0.27, "outputPerMTok": 1.10 },
|
|
||||||
"total": { "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 }
|
|
||||||
},
|
|
||||||
"claude-haiku-4-5": {
|
|
||||||
"routesTo": "deepseek-chat",
|
|
||||||
"pricing": { "inputPerMTok": 0.27, "outputPerMTok": 1.10 },
|
|
||||||
"total": { "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 }
|
|
||||||
},
|
|
||||||
"deepseek-chat": {
|
|
||||||
"routesTo": "deepseek-chat",
|
|
||||||
"pricing": { "inputPerMTok": 0.27, "outputPerMTok": 1.10 },
|
|
||||||
"total": { "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zai": {
|
|
||||||
"status": "ok",
|
|
||||||
"lastSeen": "2026-03-27T15:00:00.000Z",
|
|
||||||
"lastError": null,
|
|
||||||
"balance": null,
|
|
||||||
"pricingNote": "Flat-rate subscription, no balance API available",
|
|
||||||
"pricing": "flat_rate",
|
|
||||||
"monthlyCostUsd": 10,
|
|
||||||
"models": {
|
|
||||||
"claude-sonnet-4-6": {
|
|
||||||
"routesTo": "glm-4.7",
|
|
||||||
"pricing": "flat_rate",
|
|
||||||
"total": { "inputTokens": 100000, "outputTokens": 250000, "requests": 50, "costUsd": null },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 100000, "outputTokens": 250000, "requests": 50, "costUsd": null },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 10000, "outputTokens": 25000, "requests": 5, "costUsd": null }
|
|
||||||
},
|
|
||||||
"claude-sonnet-4-5": {
|
|
||||||
"routesTo": "glm-4.7",
|
|
||||||
"pricing": "flat_rate",
|
|
||||||
"total": { "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null }
|
|
||||||
},
|
|
||||||
"claude-sonnet-4-5-20250929": {
|
|
||||||
"routesTo": "glm-4.7",
|
|
||||||
"pricing": "flat_rate",
|
|
||||||
"total": { "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null }
|
|
||||||
},
|
|
||||||
"glm-4.7": {
|
|
||||||
"routesTo": "glm-4.7",
|
|
||||||
"pricing": "flat_rate",
|
|
||||||
"total": { "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null }
|
|
||||||
},
|
|
||||||
"glm-4.5-air": {
|
|
||||||
"routesTo": "glm-4.5-air",
|
|
||||||
"pricing": "flat_rate",
|
|
||||||
"total": { "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null },
|
|
||||||
"monthly": { "period": "2026-03", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null },
|
|
||||||
"today": { "date": "2026-03-27", "inputTokens": 0, "outputTokens": 0, "requests": 0, "costUsd": null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"totals": {
|
|
||||||
"totalRequests": 53,
|
|
||||||
"monthlyRequests": 53,
|
|
||||||
"todayRequests": 6,
|
|
||||||
"totalInputTokens": 105000,
|
|
||||||
"totalOutputTokens": 262000,
|
|
||||||
"monthlyInputTokens": 105000,
|
|
||||||
"monthlyOutputTokens": 262000,
|
|
||||||
"todayInputTokens": 12000,
|
|
||||||
"todayOutputTokens": 30000,
|
|
||||||
"totalCostUsd": 0.975,
|
|
||||||
"monthlyCostUsd": 0.975,
|
|
||||||
"todayCostUsd": 0.405,
|
|
||||||
"note": "Cost excludes flat-rate provider(s). Z.ai costs $10/mo regardless of usage. Anthropic has no public balance API - check console.anthropic.com. Z.ai is flat-rate; no balance API available."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
The changes should be applied in this order to minimize conflicts:
|
|
||||||
|
|
||||||
1. **Bug fix**: Remove stale `balances` variable and balance-related notes from `buildUsageResponse()`. Move notes logic to `/usage` handler.
|
|
||||||
2. **`getCurrentDate()` helper**: Add near `getCurrentPeriod()`.
|
|
||||||
3. **`providerStatus` map**: Add module-level constant.
|
|
||||||
4. **`recordUsage()` changes**: Add daily tracking field.
|
|
||||||
5. **`handleMessages()` changes**: Add provider status updates at the 3 insertion points.
|
|
||||||
6. **`buildUsageResponse()` rewrite**: The big change — model seeding, daily/today aggregation, lifetime token totals, per-model pricing, provider status inclusion.
|
|
||||||
7. **`/usage` handler update**: Updated notes logic after balances resolve.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `proxy.mjs` | All code changes (6 changes + 1 bug fix) |
|
|
||||||
|
|
||||||
No config changes. No new files. No new dependencies. Backward-compatible with existing `usage-data.json`.
|
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
// =======================
|
||||||
|
// AI Proxy Usage Widget
|
||||||
|
// =======================
|
||||||
|
// Scriptable widget for https://ai.ein-softworks.com/usage
|
||||||
|
// Supports: Small, Medium, Large sizes
|
||||||
|
|
||||||
|
const API_URL = "https://ai.ein-softworks.com/usage";
|
||||||
|
|
||||||
|
// -- Color Palette --
|
||||||
|
const COLORS = {
|
||||||
|
bg: new Color("#0d1117"),
|
||||||
|
surface: new Color("#161b22"),
|
||||||
|
border: new Color("#30363d"),
|
||||||
|
title: new Color("#e6edf3"),
|
||||||
|
label: new Color("#8b949e"),
|
||||||
|
value: new Color("#e6edf3"),
|
||||||
|
accent: new Color("#58a6ff"),
|
||||||
|
green: new Color("#3fb950"),
|
||||||
|
yellow: new Color("#d29922"),
|
||||||
|
red: new Color("#f85149"),
|
||||||
|
purple: new Color("#bc8cff"),
|
||||||
|
dim: new Color("#484f58"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Fetch --
|
||||||
|
async function fetchUsage() {
|
||||||
|
try {
|
||||||
|
let req = new Request(API_URL);
|
||||||
|
req.timeoutInterval = 10;
|
||||||
|
return await req.loadJSON();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helpers --
|
||||||
|
function fmt$(n) {
|
||||||
|
if (n == null) return "—";
|
||||||
|
if (n === 0) return "$0.00";
|
||||||
|
if (n < 0.01) return "<$0.01";
|
||||||
|
if (n < 1) return "$" + n.toFixed(3);
|
||||||
|
return "$" + n.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTok(n) {
|
||||||
|
if (n == null) return "—";
|
||||||
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||||
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPeriod(p) {
|
||||||
|
if (!p) return "—";
|
||||||
|
let parts = p.split("-");
|
||||||
|
if (parts.length === 2) return parseInt(parts[1], 10) + "/" + parts[0];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso) {
|
||||||
|
let d = iso ? new Date(iso) : new Date();
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function provColor(name) {
|
||||||
|
return { anthropic: COLORS.accent, deepseek: COLORS.green, zai: COLORS.purple }[name] || COLORS.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pl(n, s) { return n + " " + s + (n === 1 ? "" : "s"); }
|
||||||
|
|
||||||
|
// Aggregate monthly stats for a provider by summing all its models
|
||||||
|
function providerMonthly(prov) {
|
||||||
|
let cost = 0, reqs = 0, inTok = 0, outTok = 0;
|
||||||
|
for (let m of Object.values(prov.models || {})) {
|
||||||
|
let mo = m.monthly || {};
|
||||||
|
reqs += mo.requests || 0;
|
||||||
|
inTok += mo.inputTokens || 0;
|
||||||
|
outTok += mo.outputTokens || 0;
|
||||||
|
cost += mo.costUsd || 0;
|
||||||
|
}
|
||||||
|
return { cost, reqs, inTok, outTok };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate monthly/today stats for all models that route to a given target
|
||||||
|
function findModelStats(data, providerName, modelName) {
|
||||||
|
let prov = data.providers?.[providerName];
|
||||||
|
if (!prov) return null;
|
||||||
|
|
||||||
|
// Sum all models whose routesTo matches, OR whose key matches
|
||||||
|
let monthly = { inputTokens: 0, outputTokens: 0, requests: 0, costUsd: 0 };
|
||||||
|
let today = { inputTokens: 0, outputTokens: 0, requests: 0, costUsd: 0 };
|
||||||
|
let total = { inputTokens: 0, outputTokens: 0, requests: 0, costUsd: 0 };
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
for (let [key, m] of Object.entries(prov.models || {})) {
|
||||||
|
if (m.routesTo === modelName || key === modelName) {
|
||||||
|
found = true;
|
||||||
|
let mo = m.monthly || {};
|
||||||
|
monthly.inputTokens += mo.inputTokens || 0;
|
||||||
|
monthly.outputTokens += mo.outputTokens || 0;
|
||||||
|
monthly.requests += mo.requests || 0;
|
||||||
|
monthly.costUsd += mo.costUsd || 0;
|
||||||
|
|
||||||
|
let td = m.today || {};
|
||||||
|
today.inputTokens += td.inputTokens || 0;
|
||||||
|
today.outputTokens += td.outputTokens || 0;
|
||||||
|
today.requests += td.requests || 0;
|
||||||
|
today.costUsd += td.costUsd || 0;
|
||||||
|
|
||||||
|
let tt = m.total || {};
|
||||||
|
total.inputTokens += tt.inputTokens || 0;
|
||||||
|
total.outputTokens += tt.outputTokens || 0;
|
||||||
|
total.requests += tt.requests || 0;
|
||||||
|
total.costUsd += tt.costUsd || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) return null;
|
||||||
|
return { monthly, today, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SMALL WIDGET
|
||||||
|
// ========================================
|
||||||
|
function buildSmall(w, data) {
|
||||||
|
w.setPadding(12, 12, 12, 12);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let hdr = w.addStack();
|
||||||
|
hdr.layoutHorizontally();
|
||||||
|
hdr.centerAlignContent();
|
||||||
|
addText(hdr, "AI Proxy", Font.boldMonospacedSystemFont(13), COLORS.title);
|
||||||
|
hdr.addSpacer();
|
||||||
|
addText(hdr, fmtPeriod(data.currentPeriod), Font.mediumMonospacedSystemFont(10), COLORS.label);
|
||||||
|
|
||||||
|
w.addSpacer(6);
|
||||||
|
|
||||||
|
// Monthly cost
|
||||||
|
let c = w.addText(fmt$(data.totals?.monthlyCostUsd));
|
||||||
|
c.font = Font.boldMonospacedSystemFont(26);
|
||||||
|
c.textColor = COLORS.accent;
|
||||||
|
c.minimumScaleFactor = 0.6;
|
||||||
|
addText(w, "monthly spend", Font.systemFont(10), COLORS.label);
|
||||||
|
|
||||||
|
w.addSpacer(4);
|
||||||
|
|
||||||
|
// Today
|
||||||
|
let todayReqs = data.totals?.todayRequests ?? 0;
|
||||||
|
let tRow = w.addStack();
|
||||||
|
tRow.layoutHorizontally();
|
||||||
|
addText(tRow, "today", Font.systemFont(10), COLORS.label);
|
||||||
|
tRow.addSpacer();
|
||||||
|
addText(tRow, todayReqs > 0 ? fmt$(data.totals.todayCostUsd) : "no usage",
|
||||||
|
Font.mediumMonospacedSystemFont(11), todayReqs > 0 ? COLORS.value : COLORS.dim);
|
||||||
|
|
||||||
|
w.addSpacer(3);
|
||||||
|
|
||||||
|
// DeepSeek balance
|
||||||
|
let dsBal = data.providers?.deepseek?.balance?.totalBalance;
|
||||||
|
let bRow = w.addStack();
|
||||||
|
bRow.layoutHorizontally();
|
||||||
|
addText(bRow, "deepseek", Font.systemFont(10), COLORS.label);
|
||||||
|
bRow.addSpacer();
|
||||||
|
addText(bRow, dsBal ? "$" + dsBal : "—", Font.mediumMonospacedSystemFont(11), COLORS.green);
|
||||||
|
|
||||||
|
w.addSpacer();
|
||||||
|
addFooter(w, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// MEDIUM WIDGET
|
||||||
|
// ========================================
|
||||||
|
function buildMedium(w, data) {
|
||||||
|
w.setPadding(12, 14, 12, 14);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let hdr = w.addStack();
|
||||||
|
hdr.layoutHorizontally();
|
||||||
|
hdr.centerAlignContent();
|
||||||
|
addText(hdr, "AI Proxy Usage", Font.boldMonospacedSystemFont(13), COLORS.title);
|
||||||
|
hdr.addSpacer();
|
||||||
|
addText(hdr, fmtPeriod(data.currentPeriod), Font.mediumMonospacedSystemFont(11), COLORS.label);
|
||||||
|
|
||||||
|
w.addSpacer(6);
|
||||||
|
|
||||||
|
let body = w.addStack();
|
||||||
|
body.layoutHorizontally();
|
||||||
|
|
||||||
|
// -- Left: cost + stats --
|
||||||
|
let left = body.addStack();
|
||||||
|
left.layoutVertically();
|
||||||
|
|
||||||
|
let c = left.addText(fmt$(data.totals?.monthlyCostUsd));
|
||||||
|
c.font = Font.boldMonospacedSystemFont(26);
|
||||||
|
c.textColor = COLORS.accent;
|
||||||
|
c.minimumScaleFactor = 0.6;
|
||||||
|
addText(left, "monthly spend", Font.systemFont(10), COLORS.label);
|
||||||
|
|
||||||
|
left.addSpacer(5);
|
||||||
|
addText(left, pl(data.totals?.monthlyRequests ?? 0, "request"),
|
||||||
|
Font.mediumMonospacedSystemFont(11), COLORS.value);
|
||||||
|
|
||||||
|
left.addSpacer(3);
|
||||||
|
|
||||||
|
// Today summary
|
||||||
|
let tReqs = data.totals?.todayRequests ?? 0;
|
||||||
|
let tStr = tReqs > 0
|
||||||
|
? "today: " + fmt$(data.totals.todayCostUsd) + " / " + pl(tReqs, "req")
|
||||||
|
: "today: no usage";
|
||||||
|
addText(left, tStr, Font.regularMonospacedSystemFont(9), COLORS.dim);
|
||||||
|
|
||||||
|
body.addSpacer();
|
||||||
|
|
||||||
|
// -- Right: providers --
|
||||||
|
let right = body.addStack();
|
||||||
|
right.layoutVertically();
|
||||||
|
|
||||||
|
let provs = data.providers || {};
|
||||||
|
for (let name of ["zai", "anthropic", "deepseek"]) {
|
||||||
|
let prov = provs[name];
|
||||||
|
if (!prov) continue;
|
||||||
|
|
||||||
|
let row = right.addStack();
|
||||||
|
row.layoutHorizontally();
|
||||||
|
row.centerAlignContent();
|
||||||
|
|
||||||
|
addText(row, "●", Font.systemFont(8), provColor(name));
|
||||||
|
row.addSpacer(4);
|
||||||
|
let nm = addText(row, name, Font.mediumMonospacedSystemFont(11), COLORS.value);
|
||||||
|
nm.lineLimit = 1;
|
||||||
|
row.addSpacer(6);
|
||||||
|
|
||||||
|
let detail = "";
|
||||||
|
let stats = providerMonthly(prov);
|
||||||
|
if (name === "zai") {
|
||||||
|
detail = "$" + (prov.monthlyCostUsd || 10) + "/mo flat";
|
||||||
|
} else if (stats.reqs > 0) {
|
||||||
|
detail = fmt$(stats.cost);
|
||||||
|
} else if (name === "deepseek" && prov.balance?.totalBalance) {
|
||||||
|
detail = "$" + prov.balance.totalBalance + " bal";
|
||||||
|
} else if (name === "anthropic") {
|
||||||
|
detail = "see console";
|
||||||
|
} else {
|
||||||
|
detail = "no usage";
|
||||||
|
}
|
||||||
|
|
||||||
|
let dt = addText(row, detail, Font.regularMonospacedSystemFont(10), COLORS.label);
|
||||||
|
dt.lineLimit = 1;
|
||||||
|
right.addSpacer(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
w.addSpacer();
|
||||||
|
addFooter(w, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// LARGE WIDGET
|
||||||
|
// ========================================
|
||||||
|
function buildLarge(w, data) {
|
||||||
|
w.setPadding(12, 14, 12, 14);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let hdr = w.addStack();
|
||||||
|
hdr.layoutHorizontally();
|
||||||
|
hdr.centerAlignContent();
|
||||||
|
addText(hdr, "AI Proxy Usage", Font.boldMonospacedSystemFont(15), COLORS.title);
|
||||||
|
hdr.addSpacer();
|
||||||
|
addText(hdr, fmtPeriod(data.currentPeriod), Font.mediumMonospacedSystemFont(12), COLORS.label);
|
||||||
|
|
||||||
|
w.addSpacer(6);
|
||||||
|
|
||||||
|
// Summary row
|
||||||
|
let sum = w.addStack();
|
||||||
|
sum.layoutHorizontally();
|
||||||
|
|
||||||
|
let costCol = sum.addStack();
|
||||||
|
costCol.layoutVertically();
|
||||||
|
let cv = costCol.addText(fmt$(data.totals?.monthlyCostUsd));
|
||||||
|
cv.font = Font.boldMonospacedSystemFont(28);
|
||||||
|
cv.textColor = COLORS.accent;
|
||||||
|
cv.minimumScaleFactor = 0.5;
|
||||||
|
addText(costCol, "monthly spend", Font.systemFont(10), COLORS.label);
|
||||||
|
|
||||||
|
sum.addSpacer();
|
||||||
|
|
||||||
|
let statsCol = sum.addStack();
|
||||||
|
statsCol.layoutVertically();
|
||||||
|
addKVRow(statsCol, "requests", pl(data.totals?.monthlyRequests ?? 0, "req"), COLORS.value);
|
||||||
|
statsCol.addSpacer(3);
|
||||||
|
addKVRow(statsCol, "all-time", fmt$(data.totals?.totalCostUsd), COLORS.yellow);
|
||||||
|
statsCol.addSpacer(3);
|
||||||
|
let tReqs = data.totals?.todayRequests ?? 0;
|
||||||
|
let todayVal = tReqs > 0
|
||||||
|
? fmt$(data.totals.todayCostUsd) + " / " + pl(tReqs, "req")
|
||||||
|
: "no usage";
|
||||||
|
addKVRow(statsCol, "today", todayVal, tReqs > 0 ? COLORS.value : COLORS.dim);
|
||||||
|
|
||||||
|
w.addSpacer(8);
|
||||||
|
addDivider(w);
|
||||||
|
w.addSpacer(6);
|
||||||
|
|
||||||
|
// -- Per-provider sections using routing data --
|
||||||
|
let routing = data.routing || {};
|
||||||
|
let providers = data.providers || {};
|
||||||
|
|
||||||
|
for (let name of ["zai", "anthropic", "deepseek"]) {
|
||||||
|
let prov = providers[name];
|
||||||
|
if (!prov) continue;
|
||||||
|
|
||||||
|
// Provider header
|
||||||
|
let phdr = w.addStack();
|
||||||
|
phdr.layoutHorizontally();
|
||||||
|
phdr.centerAlignContent();
|
||||||
|
addText(phdr, "●", Font.systemFont(9), provColor(name));
|
||||||
|
phdr.addSpacer(5);
|
||||||
|
addText(phdr, name.toUpperCase(), Font.boldMonospacedSystemFont(11), COLORS.value);
|
||||||
|
phdr.addSpacer();
|
||||||
|
|
||||||
|
// Provider-level info (balance / flat rate / console note)
|
||||||
|
if (name === "deepseek" && prov.balance?.totalBalance) {
|
||||||
|
addText(phdr, "$" + prov.balance.totalBalance + " balance", Font.regularMonospacedSystemFont(10), COLORS.green);
|
||||||
|
} else if (name === "zai") {
|
||||||
|
addText(phdr, "$" + (prov.monthlyCostUsd || 10) + "/mo flat", Font.regularMonospacedSystemFont(10), COLORS.purple);
|
||||||
|
} else if (name === "anthropic") {
|
||||||
|
addText(phdr, "see console", Font.regularMonospacedSystemFont(10), COLORS.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show status if error
|
||||||
|
if (prov.status === "error") {
|
||||||
|
phdr.addSpacer(6);
|
||||||
|
addText(phdr, "ERROR", Font.boldMonospacedSystemFont(9), COLORS.red);
|
||||||
|
}
|
||||||
|
|
||||||
|
w.addSpacer(3);
|
||||||
|
|
||||||
|
// Find routes that belong to this provider
|
||||||
|
let provRoutes = [];
|
||||||
|
for (let [tier, route] of Object.entries(routing)) {
|
||||||
|
if (tier === "default") continue;
|
||||||
|
if (route.provider === name) {
|
||||||
|
provRoutes.push({ tier, ...route });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provRoutes.length === 0) {
|
||||||
|
let noRow = w.addStack();
|
||||||
|
noRow.setPadding(2, 18, 2, 8);
|
||||||
|
addText(noRow, "No active routes", Font.regularMonospacedSystemFont(10), COLORS.dim);
|
||||||
|
} else {
|
||||||
|
for (let route of provRoutes) {
|
||||||
|
let modelData = findModelStats(data, name, route.model);
|
||||||
|
let mo = modelData?.monthly || {};
|
||||||
|
let hasUsage = (mo.requests || 0) > 0 || (mo.inputTokens || 0) > 0 || (mo.outputTokens || 0) > 0;
|
||||||
|
|
||||||
|
let card = w.addStack();
|
||||||
|
card.layoutHorizontally();
|
||||||
|
card.centerAlignContent();
|
||||||
|
card.setPadding(4, 18, 4, 8);
|
||||||
|
card.backgroundColor = COLORS.surface;
|
||||||
|
card.cornerRadius = 6;
|
||||||
|
|
||||||
|
// Left: tier + model + stats
|
||||||
|
let lc = card.addStack();
|
||||||
|
lc.layoutVertically();
|
||||||
|
|
||||||
|
// Tier label + actual model
|
||||||
|
let tierRow = lc.addStack();
|
||||||
|
tierRow.layoutHorizontally();
|
||||||
|
tierRow.centerAlignContent();
|
||||||
|
addText(tierRow, route.tier, Font.boldMonospacedSystemFont(11), COLORS.value);
|
||||||
|
tierRow.addSpacer(6);
|
||||||
|
let arrow = addText(tierRow, "→ " + route.model, Font.regularMonospacedSystemFont(10), COLORS.label);
|
||||||
|
arrow.lineLimit = 1;
|
||||||
|
arrow.minimumScaleFactor = 0.7;
|
||||||
|
|
||||||
|
if (hasUsage) {
|
||||||
|
let tokStr = fmtTok(mo.inputTokens) + " in / " + fmtTok(mo.outputTokens) + " out tokens";
|
||||||
|
addText(lc, tokStr, Font.regularMonospacedSystemFont(10), COLORS.label);
|
||||||
|
addText(lc, pl(mo.requests, "request"), Font.regularMonospacedSystemFont(10), COLORS.dim);
|
||||||
|
} else {
|
||||||
|
addText(lc, "no usage this period", Font.regularMonospacedSystemFont(10), COLORS.dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.addSpacer();
|
||||||
|
|
||||||
|
// Right: cost or token count for flat rate
|
||||||
|
if (name === "zai") {
|
||||||
|
if (hasUsage) {
|
||||||
|
addText(card, pl(mo.requests, "req"), Font.mediumMonospacedSystemFont(12), COLORS.purple);
|
||||||
|
}
|
||||||
|
} else if (hasUsage) {
|
||||||
|
addText(card, fmt$(mo.costUsd), Font.boldMonospacedSystemFont(14), provColor(name));
|
||||||
|
} else {
|
||||||
|
addText(card, "$0.00", Font.regularMonospacedSystemFont(12), COLORS.dim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.addSpacer(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
w.addSpacer();
|
||||||
|
addFooter(w, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Shared UI helpers
|
||||||
|
// ========================================
|
||||||
|
function addText(parent, str, font, color) {
|
||||||
|
let t = parent.addText(str);
|
||||||
|
t.font = font;
|
||||||
|
t.textColor = color;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKVRow(stack, label, value, color) {
|
||||||
|
let row = stack.addStack();
|
||||||
|
row.layoutHorizontally();
|
||||||
|
addText(row, label + " ", Font.systemFont(10), COLORS.label);
|
||||||
|
row.addSpacer();
|
||||||
|
addText(row, value, Font.boldMonospacedSystemFont(11), color);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDivider(w) {
|
||||||
|
let ctx = new DrawContext();
|
||||||
|
ctx.opaque = false;
|
||||||
|
ctx.respectScreenScale = true;
|
||||||
|
ctx.size = new Size(320, 1);
|
||||||
|
ctx.setFillColor(COLORS.border);
|
||||||
|
ctx.fillRect(new Rect(0, 0, 320, 1));
|
||||||
|
let img = w.addImage(ctx.getImage());
|
||||||
|
img.imageSize = new Size(320, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFooter(w, data) {
|
||||||
|
let f = w.addStack();
|
||||||
|
f.layoutHorizontally();
|
||||||
|
f.addSpacer();
|
||||||
|
addText(f, "updated " + fmtTime(data.generatedAt), Font.regularMonospacedSystemFont(9), COLORS.dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Error --
|
||||||
|
function buildError(w, msg) {
|
||||||
|
w.setPadding(12, 12, 12, 12);
|
||||||
|
addText(w, "AI Proxy Usage", Font.boldMonospacedSystemFont(12), COLORS.title);
|
||||||
|
w.addSpacer(8);
|
||||||
|
let e = addText(w, msg, Font.systemFont(11), COLORS.red);
|
||||||
|
e.minimumScaleFactor = 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Main --
|
||||||
|
async function main() {
|
||||||
|
let w = new ListWidget();
|
||||||
|
|
||||||
|
let grad = new LinearGradient();
|
||||||
|
grad.locations = [0, 1];
|
||||||
|
grad.colors = [COLORS.bg, new Color("#0a0e14")];
|
||||||
|
w.backgroundGradient = grad;
|
||||||
|
w.url = API_URL;
|
||||||
|
|
||||||
|
let data = await fetchUsage();
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
buildError(w, "Failed to reach API");
|
||||||
|
} else {
|
||||||
|
let family = config.widgetFamily;
|
||||||
|
if (family === "small") buildSmall(w, data);
|
||||||
|
else if (family === "large") buildLarge(w, data);
|
||||||
|
else buildMedium(w, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
w.refreshAfterDate = new Date(Date.now() + 30 * 60 * 1000);
|
||||||
|
|
||||||
|
if (config.runsInWidget) {
|
||||||
|
Script.setWidget(w);
|
||||||
|
} else {
|
||||||
|
await w.presentLarge();
|
||||||
|
}
|
||||||
|
|
||||||
|
Script.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
Reference in New Issue
Block a user