diff --git a/plans/usage-api-enhancements.md b/plans/usage-api-enhancements.md deleted file mode 100644 index a8787c2..0000000 --- a/plans/usage-api-enhancements.md +++ /dev/null @@ -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`. diff --git a/scriptable-widget/ein-ai-proxy-widget.js b/scriptable-widget/ein-ai-proxy-widget.js new file mode 100644 index 0000000..21bb77d --- /dev/null +++ b/scriptable-widget/ein-ai-proxy-widget.js @@ -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(); \ No newline at end of file