From fdf5b597ae558f07372036dce32faa395be74d1b Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Wed, 22 Jul 2026 05:08:20 +0700 Subject: [PATCH] refactor: decommission the InsForge cloud backend from repo + CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #71. Removes the repo/CI/web-dev cloud surface. None of this ships in the npm package, so deleting it undeploys nothing — the live InsForge backend is retired separately by rotating its service-role key (a user action on the InsForge dashboard; nothing in this repo held that secret). Scope kept to web/CI/repo per decision; native-app OAuth (Swift/C#) is a separate follow-up on #71. Removed: - dashboard/edge-patches/ — all 18 InsForge Edge functions (ingest, leaderboard*, device-*, account-*, badge/embed svg, public-visibility, profile-likes) and the 3 tests that only guarded them (edge-patch- pricing-sync, badge-svg-render, and two leaderboard-refresh byte-equality cases in model-breakdown.test.js; the live buildFleetData/pricing tests stay). - InsForge creds from all 4 workflows (ci, npm-publish, release-dmg, release-windows) + the stale comments claiming their absence breaks the build or "disables cloud OAuth (isCloudInsforgeConfigured())" — that function was deleted in #72 and nothing reads VITE_INSFORGE_* anymore. - dashboard/vercel.json (deploy that no longer exists), dashboard/.mcp.json (InsForge MCP dev tooling), the /api/auth→InsForge proxy and the insforgeBaseUrl passthrough in dashboard/vite.config.js. - InsForge/service-role/device rows from .env.example; rewrote dashboard/.env.example to the one local (mock) var. - Stale references in SECURITY.md (insforge/ path), CLAUDE.md, the curated-overrides pricing note, the guardrails ignore list, and the leaderboard-absence item in the publish checklist. Verified: dashboard:build succeeds with NO VITE_INSFORGE_* env (proving the "must be present or the build fails" comments were stale), bundle carries no insforge/statuspage host, and ci:local is green end-to-end — dashboard 233/233, root 752 + 4, all validators pass. No secret was ever in the repo (edge functions read INSFORGE_SERVICE_ROLE_KEY from Deno env; CI injected only the public ik_ anon key, now removed too). --- .env.example | 10 - .github/workflows/ci.yml | 7 - .github/workflows/npm-publish.yml | 10 - .github/workflows/release-dmg.yml | 8 - .github/workflows/release-windows.yml | 7 +- CLAUDE.md | 2 +- SECURITY.md | 1 - dashboard/.env.example | 7 +- dashboard/.mcp.json | 15 - .../tokentracker-account-daily.ts | 444 ----------- .../tokentracker-account-heatmap.ts | 258 ------- .../tokentracker-account-hourly.ts | 283 ------- .../tokentracker-account-model-breakdown.ts | 499 ------------- .../tokentracker-account-monthly.ts | 216 ------ .../tokentracker-account-summary.ts | 576 --------------- .../edge-patches/tokentracker-badge-svg.ts | 282 ------- .../tokentracker-device-flow-authorize.ts | 115 --- .../tokentracker-device-flow-grant.ts | 129 ---- .../tokentracker-device-flow-poll.ts | 152 ---- .../tokentracker-device-token-issue.ts | 210 ------ .../edge-patches/tokentracker-embed-svg.ts | 307 -------- dashboard/edge-patches/tokentracker-ingest.ts | 128 ---- .../tokentracker-leaderboard-profile.ts | 615 ---------------- .../tokentracker-leaderboard-refresh.ts | 696 ------------------ .../edge-patches/tokentracker-leaderboard.ts | 189 ----- .../tokentracker-profile-likes.ts | 117 --- .../tokentracker-public-visibility.ts | 207 ------ dashboard/src/App.navigation-preload.test.jsx | 8 - dashboard/src/App.preload.test.jsx | 8 - dashboard/vercel.json | 5 - dashboard/vite.config.js | 38 +- docs/npm-publish-checklist.md | 2 - scripts/validate-architecture-guardrails.cjs | 1 - src/lib/pricing/curated-overrides.json | 2 +- test/badge-svg-render.test.js | 147 ---- test/edge-patch-pricing-sync.test.js | 107 --- test/model-breakdown.test.js | 44 -- 37 files changed, 15 insertions(+), 5837 deletions(-) delete mode 100644 dashboard/.mcp.json delete mode 100644 dashboard/edge-patches/tokentracker-account-daily.ts delete mode 100644 dashboard/edge-patches/tokentracker-account-heatmap.ts delete mode 100644 dashboard/edge-patches/tokentracker-account-hourly.ts delete mode 100644 dashboard/edge-patches/tokentracker-account-model-breakdown.ts delete mode 100644 dashboard/edge-patches/tokentracker-account-monthly.ts delete mode 100644 dashboard/edge-patches/tokentracker-account-summary.ts delete mode 100644 dashboard/edge-patches/tokentracker-badge-svg.ts delete mode 100644 dashboard/edge-patches/tokentracker-device-flow-authorize.ts delete mode 100644 dashboard/edge-patches/tokentracker-device-flow-grant.ts delete mode 100644 dashboard/edge-patches/tokentracker-device-flow-poll.ts delete mode 100644 dashboard/edge-patches/tokentracker-device-token-issue.ts delete mode 100644 dashboard/edge-patches/tokentracker-embed-svg.ts delete mode 100644 dashboard/edge-patches/tokentracker-ingest.ts delete mode 100644 dashboard/edge-patches/tokentracker-leaderboard-profile.ts delete mode 100644 dashboard/edge-patches/tokentracker-leaderboard-refresh.ts delete mode 100644 dashboard/edge-patches/tokentracker-leaderboard.ts delete mode 100644 dashboard/edge-patches/tokentracker-profile-likes.ts delete mode 100644 dashboard/edge-patches/tokentracker-public-visibility.ts delete mode 100644 dashboard/vercel.json delete mode 100644 test/badge-svg-render.test.js delete mode 100644 test/edge-patch-pricing-sync.test.js diff --git a/.env.example b/.env.example index 66ea9de6..1022c50b 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,8 @@ # Core runtime / CLI TOKENTRACKER_DEVICE_TOKEN= -TOKENTRACKER_ACCESS_TOKEN= -TOKENTRACKER_INSFORGE_BASE_URL= TOKENTRACKER_HTTP_TIMEOUT_MS= # Dashboard (Vite) -VITE_INSFORGE_BASE_URL= -VITE_INSFORGE_ANON_KEY= VITE_TOKENTRACKER_HTTP_TIMEOUT_MS= VITE_TOKENTRACKER_MOCK= VITE_TOKENTRACKER_MOCK_NOW= @@ -14,12 +10,6 @@ VITE_TOKENTRACKER_MOCK_TODAY= VITE_TOKENTRACKER_MOCK_SEED= VITE_TOKENTRACKER_MOCK_MISSING= -# InsForge / service role -INSFORGE_BASE_URL= -INSFORGE_ANON_KEY= -INSFORGE_SERVICE_ROLE_KEY= -INSFORGE_API_KEY= - # Pricing sync / external OPENROUTER_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f845efa..37e99379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,11 +54,4 @@ jobs: run: npm ci --prefix dashboard - name: Run local CI gate - env: - # Public client-side values inlined into the dashboard bundle at - # build time. Not secrets — they ship in every published bundle and - # must be present or Vite inlines empty strings and the build fails. - # Kept in sync with npm-publish.yml. - VITE_INSFORGE_BASE_URL: https://srctyff5.us-east.insforge.app - VITE_INSFORGE_ANON_KEY: ik_9f35735991b684f7cf57fa00bb4d0487 run: npm run ci:local diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index e9b37115..8ca0f17b 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -34,9 +34,6 @@ jobs: run: npm ci --prefix dashboard - name: Run local CI gate - env: - VITE_INSFORGE_BASE_URL: https://srctyff5.us-east.insforge.app - VITE_INSFORGE_ANON_KEY: ik_9f35735991b684f7cf57fa00bb4d0487 run: npm run ci:local publish: @@ -72,15 +69,8 @@ jobs: if: steps.version-check.outputs.exists == 'false' run: npm ci --prefix dashboard - # InsForge URL + anon key are public client-side values shipped in every - # dashboard bundle. They MUST be present at build time or Vite inlines - # empty strings — breaks leaderboard fetch and disables cloud OAuth. - # `dashboard/.env.local` is gitignored so CI cannot read it. - name: Build dashboard if: steps.version-check.outputs.exists == 'false' - env: - VITE_INSFORGE_BASE_URL: https://srctyff5.us-east.insforge.app - VITE_INSFORGE_ANON_KEY: ik_9f35735991b684f7cf57fa00bb4d0487 run: npm run dashboard:build - name: Publish to npm diff --git a/.github/workflows/release-dmg.yml b/.github/workflows/release-dmg.yml index fced753d..7ef00604 100644 --- a/.github/workflows/release-dmg.yml +++ b/.github/workflows/release-dmg.yml @@ -87,15 +87,7 @@ jobs: - name: Install dashboard dependencies run: npm ci --prefix dashboard - # InsForge URL + anon key are public client-side values (shipped in every - # dashboard bundle). They MUST be present at build time or Vite inlines - # empty strings, which breaks the leaderboard page (no baseUrl → no fetch) - # and disables cloud OAuth (`isCloudInsforgeConfigured()` returns false). - # `dashboard/.env.local` is gitignored so CI cannot read it — inject here. - name: Build dashboard - env: - VITE_INSFORGE_BASE_URL: https://srctyff5.us-east.insforge.app - VITE_INSFORGE_ANON_KEY: ik_9f35735991b684f7cf57fa00bb4d0487 run: npm run dashboard:build # 2. Bundle EmbeddedServer (Node.js + tokentracker source) diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index bb3e25f3..185797f3 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -63,17 +63,12 @@ jobs: } Write-Host "Version OK: $env:VERSION" - # 1. Build dashboard. InsForge URL + anon key are public client-side values - # (shipped in every dashboard bundle) but MUST be present at build time or - # Vite inlines empty strings — breaks the leaderboard fetch and disables - # cloud OAuth. `dashboard/.env.local` is gitignored so CI cannot read it. + # 1. Build dashboard. - name: Install dashboard dependencies run: npm ci --prefix dashboard - name: Build dashboard env: - VITE_INSFORGE_BASE_URL: https://srctyff5.us-east.insforge.app - VITE_INSFORGE_ANON_KEY: ik_9f35735991b684f7cf57fa00bb4d0487 # Include the floating-pet entry (pet.html). Off by default so the macOS + # web builds stay byte-identical; only the Windows build opts in. TOKENTRACKER_BUILD_PET: "1" diff --git a/CLAUDE.md b/CLAUDE.md index cdf27a4a..6f6512ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ The macOS + Windows release is **one workflow**: `release-dmg.yml` (display name | `src/` or `dashboard/` | ✅ | ✅ | ✅ | ✅ | | `TokenTrackerBar/` Swift only | ✅ | ✅ | ✅ | ✅ | | `TokenTrackerWin/` only | ✅ | ✅ | ✅ | ✅ | -| `dashboard/edge-patches/`, scripts, docs, CI | — | — | — | — | +| scripts, docs, CI | — | — | — | — | All four version locations must match or the workflows' "Verify version" steps fail (DMG checks `package.json` + `project.yml`; Windows checks `package.json` + `csproj`). diff --git a/SECURITY.md b/SECURITY.md index e1298f8b..7b078da5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,7 +31,6 @@ TokenTracker is a local-first tool that reads AI CLI tool logs from your home di - **`src/lib/cursor-config.js`** — reads Cursor's local SQLite to extract auth tokens for the Cursor usage API. Tokens must never leave the user's machine. - **`src/lib/local-api.js`** — local HTTP server bound to `127.0.0.1`. Should not accept connections from other hosts. - **`TokenTrackerBar/`** — macOS app, ad-hoc signed. Has filesystem access via macOS TCC permissions; should never write outside its own data directories or the documented snapshot/queue paths. -- **`insforge/`** — optional cloud edge functions. Only used if the user opts into the leaderboard. ## Out of Scope diff --git a/dashboard/.env.example b/dashboard/.env.example index 5a60c36b..cd4143c6 100644 --- a/dashboard/.env.example +++ b/dashboard/.env.example @@ -1,7 +1,2 @@ -# InsForge:OAuth SDK 与排行榜 Edge(Leaderboard 用 getLeaderboardBaseUrl;仪表盘在 localhost 仍走相对路径 CLI API)。 -VITE_INSFORGE_BASE_URL=https://your-project.us-east.insforge.app -VITE_INSFORGE_ANON_KEY= - -# 设为 1 时仪表盘/排行榜用本地假数据;真实排行榜需关闭 mock。 +# Set to 1 to render the dashboard with local mock data instead of the live CLI API. # VITE_TOKENTRACKER_MOCK=0 - diff --git a/dashboard/.mcp.json b/dashboard/.mcp.json deleted file mode 100644 index 9e84c770..00000000 --- a/dashboard/.mcp.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "mcpServers": { - "insforge": { - "command": "npx", - "args": [ - "-y", - "@insforge/mcp@latest" - ], - "env": { - "API_KEY": "ik_9f35735991b684f7cf57fa00bb4d0487", - "API_BASE_URL": "https://srctyff5.us-east.insforge.app" - } - } - } -} \ No newline at end of file diff --git a/dashboard/edge-patches/tokentracker-account-daily.ts b/dashboard/edge-patches/tokentracker-account-daily.ts deleted file mode 100644 index 95c8a838..00000000 --- a/dashboard/edge-patches/tokentracker-account-daily.ts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * InsForge Edge: account-wide daily usage (cross-device, aggregated by user_id). - * Mirrors local-api.js `tokentracker-usage-daily` response schema. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -/** - * Convert a UTC timestamp to the caller's local YYYY-MM-DD. Mirrors the - * convention used by src/lib/local-api.js#getZonedParts: - * - tz: IANA name (preferred); resolved via Intl.DateTimeFormat("en-CA") - * - offsetMinutes: positive = east of UTC (e.g. +480 for Asia/Shanghai) - * Falls back to UTC slice when neither is provided/valid. - */ -function zonedDayKey(hourStart: string, tz: string | null, offsetMinutes: number | null): string { - if (tz) { - try { - const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date(hourStart)); - const y = parts.find((p) => p.type === "year")?.value; - const m = parts.find((p) => p.type === "month")?.value; - const d = parts.find((p) => p.type === "day")?.value; - if (y && m && d) return `${y}-${m}-${d}`; - } catch { /* fall through */ } - } - if (offsetMinutes != null && Number.isFinite(offsetMinutes)) { - const shifted = new Date(new Date(hourStart).getTime() + offsetMinutes * 60000); - return shifted.toISOString().slice(0, 10); - } - return hourStart.slice(0, 10); -} - -function b64urlToBytes(s: string): Uint8Array { - const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const raw = atob(b64 + "=".repeat(pad)); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; -} - -/** - * Verify HS256 JWT against JWT_SECRET and return its sub. Mirrors the helper - * in tokentracker-device-token-issue.ts. Returns null on any failure — caller - * surfaces that as 401. InsForge does NOT validate JWTs at the gateway, so - * exposing per-user data without local verification lets anyone forge - * {"sub":""} and read another user's data. - */ -async function verifiedUserIdFromJwt(authHeader: string | null): Promise { - if (!authHeader) return null; - const token = authHeader.replace(/^Bearer\s+/i, "").trim(); - if (!token) return null; - const secret = Deno.env.get("JWT_SECRET"); - if (!secret) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); - const sig = b64urlToBytes(parts[2]); - const ok = await crypto.subtle.verify("HMAC", key, sig, data); - if (!ok) return null; - const payload = JSON.parse(new TextDecoder().decode(b64urlToBytes(parts[1]))) as Record; - if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) return null; - const sub = payload.sub; - if (typeof sub === "string" && sub.length > 0) return sub; - const uid = payload.user_id; - if (typeof uid === "string" && uid.length > 0) return uid; - } catch { /* ignore */ } - return null; -} - -async function fetchActiveDeviceIds( - client: ReturnType, - userId: string, -): Promise { - const { data, error } = await client.database - .from("tokentracker_devices") - .select("id") - .eq("user_id", userId) - .is("revoked_at", null); - if (error) throw new Error(error.message); - const rows = (data ?? []) as Array<{ id: string }>; - return rows.map((r) => r.id).filter((id): id is string => typeof id === "string" && id.length > 0); -} - - -// MODEL_PRICING + getModelPricing synced from tokentracker-leaderboard-refresh.ts -// 2026-05-28: includes fable, mimo, gpt-5.5, glm, grok, deepseek-v4, kiro, hy3-preview (87 models). -// Keep this block byte-identical with leaderboard-refresh.ts; see feedback_model_pricing_sync. -const MODEL_PRICING: Record = { - // ── Anthropic Claude ── - "claude-fable-5": { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, - "claude-mythos-5": { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, - "claude-opus-4-6": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, - "claude-opus-4-5-20250414": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, - "claude-sonnet-5": { input: 2, output: 10, cache_read: 0.2, cache_write: 2.5 }, - "claude-sonnet-4-6": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-sonnet-4-5-20250514": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-sonnet-4-20250514": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-haiku-4-5-20251001": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, - "claude-3-5-sonnet-20241022": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-3-5-haiku-20241022": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, - // ── OpenAI GPT / Codex ── - "gpt-5": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-codex": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-codex-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-mini": { input: 0.25, output: 2, cache_read: 0.025 }, - "gpt-5.1-codex-max": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-xhigh-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.2": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-high-fast": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-codex": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-codex-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.3-codex": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.3-codex-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.4": { input: 2.5, output: 15, cache_read: 0.25 }, - "gpt-5.4-mini": { input: 0.75, output: 4.5, cache_read: 0.075 }, - "gpt-5.4-medium": { input: 1.5, output: 10, cache_read: 0.15 }, - "gpt-5.5": { input: 5, output: 30, cache_read: 0.5 }, - "gpt-5-mini": { input: 0.25, output: 2, cache_read: 0.025 }, - "o3": { input: 2, output: 8, cache_read: 0.5 }, - // ── Google Gemini ── - "gemini-2.5-pro": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-pro-preview-06-05": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-pro-preview-05-06": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-flash": { input: 0.3, output: 2.5, cache_read: 0.03 }, - "gemini-3-flash-preview": { input: 0.5, output: 3, cache_read: 0.05 }, - "gemini-3-pro-preview": { input: 2, output: 12, cache_read: 0.2 }, - "gemini-3.1-pro-preview": { input: 2, output: 12, cache_read: 0.2 }, - // ── Cursor Composer ── - "composer-1": { input: 1.25, output: 10, cache_read: 0.125 }, - "composer-1.5": { input: 3.5, output: 17.5, cache_read: 0.35 }, - "composer-2": { input: 0.5, output: 2.5, cache_read: 0.2 }, - "composer-2-fast": { input: 1.5, output: 7.5, cache_read: 0.15 }, - // ── Moonshot Kimi ── - "kimi-for-coding": { input: 0.6, output: 2, cache_read: 0.15 }, - "kimi-k2.5": { input: 0.6, output: 2, cache_read: 0.15 }, - "kimi-k2.5-free": { input: 0, output: 0, cache_read: 0 }, - "kimi-k2.6": { input: 0.95, output: 4, cache_read: 0.16 }, - // ── Z.ai GLM (mirrored from src/lib/pricing/curated-overrides.json). - // LiteLLM only keys these under provider prefixes like `zai/glm-5`, - // `openrouter/z-ai/glm-4.6`, etc. The reverse-substring fallback in the - // matcher requires the user-supplied model name to CONTAIN the LiteLLM - // key, so the bare `glm-5.1` / `glm-4.6` strings reported by Claude - // Code-compatible GLM endpoints never match. Curate them here. ── - "glm-5.1": { input: 1.4, output: 4.4, cache_read: 0.26 }, - "glm-5": { input: 1.0, output: 3.2, cache_read: 0.2 }, - "glm-5-turbo": { input: 1.2, output: 4.0, cache_read: 0.24 }, - "glm-4.7": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.7-flashx": { input: 0.07, output: 0.4, cache_read: 0.01 }, - "glm-4.7-flash": { input: 0, output: 0, cache_read: 0 }, - "glm-4.6": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.5": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.5-x": { input: 2.2, output: 8.9, cache_read: 0.45 }, - "glm-4.5-air": { input: 0.2, output: 1.1, cache_read: 0.03 }, - "glm-4.5-airx": { input: 1.1, output: 4.5, cache_read: 0.22 }, - "glm-4.5-flash": { input: 0, output: 0, cache_read: 0 }, - // ── MiniMax / DeepSeek ── - "MiniMax-M2.7": { input: 0.3, output: 1.2, cache_read: 0.06, cache_write: 0.375 }, - "MiniMax-M2.7-highspeed": { input: 0.6, output: 2.4, cache_read: 0.06, cache_write: 0.375 }, - "deepseek-v4-flash": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "deepseek-v4-pro": { input: 0.435, output: 0.87, cache_read: 0.003625, cache_write: 0.435 }, - "deepseek-chat": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "deepseek-reasoner": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - // ── xAI Grok (mirrored from src/lib/pricing/curated-overrides.json; - // Grok parser emits cache_creation_input_tokens = 0, so cache_write is - // omitted — same as the canonical table). ── - "grok-build": { input: 1.25, output: 2.50, cache_read: 0.20 }, - "grok-4-0709": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4-latest": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4-fast": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-fast-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-fast-non-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-1-fast-non-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - // ── AWS Kiro (mirrored byte-for-byte from src/lib/local-api.js to - // prevent cloud/local cost drift — Kiro routes through Bedrock, - // most commonly claude-sonnet-4). ── - "kiro-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "kiro-cli-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - // ── Tencent CodeBuddy (hy3-preview family). Mirrored from - // src/lib/local-api.js — Tencent has not published $/MTok rates so - // these stay at 0. TODO: confirm Tencent hy3 pricing. ── - "hy3-preview-agent": { input: 0, output: 0, cache_read: 0 }, - "hy3-preview": { input: 0, output: 0, cache_read: 0 }, - // ── Misc / Free ── - "glm-4.7-free": { input: 0, output: 0, cache_read: 0 }, - "nemotron-3-super-free": { input: 0, output: 0, cache_read: 0 }, - "mimo-v2-pro-free": { input: 0, output: 0, cache_read: 0 }, - "minimax-m2.1-free": { input: 0, output: 0, cache_read: 0 }, - "MiniMax-M2.1": { input: 0.5, output: 3, cache_read: 0.05 }, - // ── Xiaomi MiMo (mirrored from src/lib/pricing/seed-snapshot.json LiteLLM - // entries openrouter/xiaomi/mimo-*; queue rows report the bare names. - // Kept in lockstep with the matcher's litellm:prefix-strip resolution — - // cache_read for mimo-v2-flash uses novita's 0.02 (the lexicographically - // smallest provider key the matcher deterministically picks). ── - "mimo-v2.5-pro": { input: 1, output: 3, cache_read: 0.2 }, - "mimo-v2.5": { input: 0.4, output: 2, cache_read: 0.08 }, - "mimo-v2-flash": { input: 0.1, output: 0.3, cache_read: 0.02 }, -}; -const ZERO_PRICING = { input: 0, output: 0, cache_read: 0, cache_write: 0 }; - -function getModelPricing(model: string) { - if (!model) return ZERO_PRICING; - const exact = MODEL_PRICING[model]; - if (exact) return exact; - const lower = model.toLowerCase(); - if (lower.includes("fable")) return MODEL_PRICING["claude-fable-5"]; - if (lower.includes("mythos-5")) return MODEL_PRICING["claude-mythos-5"]; - if (lower.includes("opus")) return MODEL_PRICING["claude-opus-4-6"]; - if (lower.includes("haiku")) return MODEL_PRICING["claude-haiku-4-5-20251001"]; - if (lower.includes("sonnet")) return MODEL_PRICING["claude-sonnet-4-6"]; - if (lower.includes("gpt-5.4")) return MODEL_PRICING["gpt-5.4"]; - if (lower.includes("gpt-5.5")) return MODEL_PRICING["gpt-5.5"]; - if (lower.includes("gpt-5-mini")) return MODEL_PRICING["gpt-5-mini"]; - if (lower.includes("gpt-5.3")) return MODEL_PRICING["gpt-5.3-codex"]; - if (lower.includes("gpt-5.2")) return MODEL_PRICING["gpt-5.2"]; - if (lower.includes("gpt-5.1")) return MODEL_PRICING["gpt-5.1-codex"]; - if (lower.includes("gpt-5")) return MODEL_PRICING["gpt-5"]; - if (lower.includes("gemini-3")) return MODEL_PRICING["gemini-3-flash-preview"]; - if (lower.includes("gemini-2.5")) return MODEL_PRICING["gemini-2.5-pro"]; - if (lower.includes("minimax-m2.7-highspeed")) return MODEL_PRICING["MiniMax-M2.7-highspeed"]; - if (lower.includes("minimax-m2.7")) return MODEL_PRICING["MiniMax-M2.7"]; - if (lower.includes("deepseek-v4-flash")) return MODEL_PRICING["deepseek-v4-flash"]; - if (lower.includes("deepseek-v4-pro")) return MODEL_PRICING["deepseek-v4-pro"]; - if (lower.includes("deepseek-reasoner")) return MODEL_PRICING["deepseek-reasoner"]; - if (lower.includes("deepseek-chat")) return MODEL_PRICING["deepseek-chat"]; - if (lower.includes("grok-build")) return MODEL_PRICING["grok-build"]; - if (lower.includes("grok-4-fast")) return MODEL_PRICING["grok-4-fast"]; - // grok-4-1-fast-* must precede the generic grok-4 matcher. Cloud rows may - // carry a provider prefix or `-latest` suffix (e.g. xai/grok-4-1-fast- - // non-reasoning-latest), and the substring "grok-4-fast" does NOT match - // "grok-4-1-fast" (the "-1-" separates them). Without this specific match - // these rows fall through to grok-4 and get billed at $3/$15 MTok instead - // of the $0.20/$0.50 MTok fast-tier rate (15x / 30x overestimate). - if (lower.includes("grok-4-1-fast")) return MODEL_PRICING["grok-4-1-fast-non-reasoning"]; - if (lower.includes("grok-4")) return MODEL_PRICING["grok-4"]; - if (lower.includes("kimi-k2.6")) return MODEL_PRICING["kimi-k2.6"]; - if (lower.includes("kimi")) return MODEL_PRICING["kimi-k2.5"]; - // MiMo ordering: more specific suffixes first (mimo-v2.5-pro before - // mimo-v2.5 which is a substring; the free tier is a distinct name). - if (lower.includes("mimo-v2-pro-free")) return MODEL_PRICING["mimo-v2-pro-free"]; - if (lower.includes("mimo-v2.5-pro")) return MODEL_PRICING["mimo-v2.5-pro"]; - if (lower.includes("mimo-v2.5")) return MODEL_PRICING["mimo-v2.5"]; - if (lower.includes("mimo-v2-flash")) return MODEL_PRICING["mimo-v2-flash"]; - // GLM ordering: more specific suffixes (-airx/-air/-x/-flash/-flashx/-turbo) - // must precede the base matchers. glm-5.1 must precede glm-5 (substring). - if (lower.includes("glm-4.5-airx")) return MODEL_PRICING["glm-4.5-airx"]; - if (lower.includes("glm-4.5-air")) return MODEL_PRICING["glm-4.5-air"]; - if (lower.includes("glm-4.5-x")) return MODEL_PRICING["glm-4.5-x"]; - if (lower.includes("glm-4.5-flash")) return MODEL_PRICING["glm-4.5-flash"]; - if (lower.includes("glm-4.5")) return MODEL_PRICING["glm-4.5"]; - if (lower.includes("glm-4.7-flashx")) return MODEL_PRICING["glm-4.7-flashx"]; - if (lower.includes("glm-4.7-flash")) return MODEL_PRICING["glm-4.7-flash"]; - if (lower.includes("glm-4.7")) return MODEL_PRICING["glm-4.7"]; - if (lower.includes("glm-4.6")) return MODEL_PRICING["glm-4.6"]; - if (lower.includes("glm-5-turbo")) return MODEL_PRICING["glm-5-turbo"]; - if (lower.includes("glm-5.1")) return MODEL_PRICING["glm-5.1"]; - if (lower.includes("glm-5")) return MODEL_PRICING["glm-5"]; - if (lower.includes("kiro")) return MODEL_PRICING["kiro-cli-agent"]; - if (lower.includes("hy3")) return MODEL_PRICING["hy3-preview-agent"]; - if (lower.includes("composer")) return MODEL_PRICING["composer-1"]; - if (lower === "auto") return MODEL_PRICING["composer-1"]; - return ZERO_PRICING; -} - -interface HourlyRow { - hour_start: string; - source: string; - model: string; - total_tokens: number | null; - input_tokens: number | null; - output_tokens: number | null; - cached_input_tokens: number | null; - cache_creation_input_tokens: number | null; - reasoning_output_tokens: number | null; - conversations: number | null; -} - -function computeRowCost(row: HourlyRow): number { - const p = getModelPricing(row.model); - return ( - ((Number(row.input_tokens) || 0) * (p.input || 0) + - (Number(row.output_tokens) || 0) * (p.output || 0) + - (Number(row.cached_input_tokens) || 0) * (p.cache_read || 0) + - (Number(row.cache_creation_input_tokens) || 0) * ((p.cache_write ?? 0)) + - (Number(row.reasoning_output_tokens) || 0) * (p.output || 0)) / - 1_000_000 - ); -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") - return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "GET") return json({ error: "Method not allowed" }, 405); - - const url = new URL(req.url); - const from = url.searchParams.get("from") || ""; - const to = url.searchParams.get("to") || ""; - if (!from || !to) return json({ error: "Missing from/to" }, 400); - const tz = url.searchParams.get("tz") || null; - const tzOffsetRaw = url.searchParams.get("tz_offset_minutes"); - const tzOffsetMinutes = tzOffsetRaw != null && tzOffsetRaw !== "" ? Number(tzOffsetRaw) : null; - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const dbToken = serviceRoleKey || anonKey; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - const userId = await verifiedUserIdFromJwt(req.headers.get("Authorization")); - if (!userId) return json({ error: "Unauthorized" }, 401); - - let activeDeviceIds: string[]; - try { - activeDeviceIds = await fetchActiveDeviceIds(client, userId); - } catch (e) { - return json({ error: (e as Error).message }, 500); - } - - // Widen the UTC query window by ±1 day so TZ-shifted buckets at the - // edges still capture the relevant hours (up to 14h offset worldwide). - const startDate = new Date(`${from}T00:00:00Z`); - startDate.setUTCDate(startDate.getUTCDate() - 1); - const endDate = new Date(`${to}T00:00:00Z`); - endDate.setUTCDate(endDate.getUTCDate() + 2); - const rangeStart = startDate.toISOString(); - const rangeEnd = endDate.toISOString(); - - const rawRows: HourlyRow[] = []; - const PAGE_SIZE = 1000; - const DEVICE_CHUNK = 25; - for (let i = 0; i < activeDeviceIds.length; i += DEVICE_CHUNK) { - const chunk = activeDeviceIds.slice(i, i + DEVICE_CHUNK); - let offset = 0; - while (true) { - const { data, error } = await client.database - .from("tokentracker_hourly") - .select( - "hour_start, source, model, total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, reasoning_output_tokens, conversations", - ) - .eq("user_id", userId) - .in("device_id", chunk) - .gte("hour_start", rangeStart) - .lt("hour_start", rangeEnd) - .order("hour_start", { ascending: true }) - .range(offset, offset + PAGE_SIZE - 1); - if (error) return json({ error: error.message }, 500); - if (!data || data.length === 0) break; - rawRows.push(...(data as unknown as HourlyRow[])); - if (data.length < PAGE_SIZE) break; - offset += PAGE_SIZE; - } - } - - const rows = rawRows; - - const byDay = new Map(); - for (const row of rows) { - if (!row.hour_start) continue; - const day = zonedDayKey(String(row.hour_start), tz, tzOffsetMinutes); - if (day < from || day > to) continue; - let a = byDay.get(day); - if (!a) { - a = { - day, - total_tokens: 0, - billable_total_tokens: 0, - total_cost_usd: 0, - input_tokens: 0, - output_tokens: 0, - cached_input_tokens: 0, - cache_creation_input_tokens: 0, - reasoning_output_tokens: 0, - conversation_count: 0, - }; - byDay.set(day, a); - } - const tt = Number(row.total_tokens) || 0; - a.total_tokens += tt; - a.billable_total_tokens += tt; - a.total_cost_usd += computeRowCost(row); - a.input_tokens += Number(row.input_tokens) || 0; - a.output_tokens += Number(row.output_tokens) || 0; - a.cached_input_tokens += Number(row.cached_input_tokens) || 0; - a.cache_creation_input_tokens += Number(row.cache_creation_input_tokens) || 0; - a.reasoning_output_tokens += Number(row.reasoning_output_tokens) || 0; - a.conversation_count += Number(row.conversations) || 0; - } - - const data = Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day)); - return json({ from, to, data }); -} diff --git a/dashboard/edge-patches/tokentracker-account-heatmap.ts b/dashboard/edge-patches/tokentracker-account-heatmap.ts deleted file mode 100644 index 5a6585bd..00000000 --- a/dashboard/edge-patches/tokentracker-account-heatmap.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * InsForge Edge: account-wide activity heatmap (cross-device, by user_id). - * Mirrors local-api.js `tokentracker-usage-heatmap` response schema. - * Level algorithm: 0 if no billable tokens, else 1..4 based on ratio to max. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -/** - * Convert UTC timestamp to local YYYY-MM-DD (see local-api.js#getZonedParts). - * Positive offsetMinutes = east of UTC. - */ -function zonedDayKey(hourStart: string, tz: string | null, offsetMinutes: number | null): string { - if (tz) { - try { - const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date(hourStart)); - const y = parts.find((p) => p.type === "year")?.value; - const m = parts.find((p) => p.type === "month")?.value; - const d = parts.find((p) => p.type === "day")?.value; - if (y && m && d) return `${y}-${m}-${d}`; - } catch { /* fall through */ } - } - if (offsetMinutes != null && Number.isFinite(offsetMinutes)) { - const shifted = new Date(new Date(hourStart).getTime() + offsetMinutes * 60000); - return shifted.toISOString().slice(0, 10); - } - return hourStart.slice(0, 10); -} - -function b64urlToBytes(s: string): Uint8Array { - const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const raw = atob(b64 + "=".repeat(pad)); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; -} - -/** - * Verify HS256 JWT against JWT_SECRET and return its sub. Mirrors the helper - * in tokentracker-device-token-issue.ts. Returns null on any failure — caller - * surfaces that as 401. InsForge does NOT validate JWTs at the gateway, so - * exposing per-user data without local verification lets anyone forge - * {"sub":""} and read another user's data. - */ -async function verifiedUserIdFromJwt(authHeader: string | null): Promise { - if (!authHeader) return null; - const token = authHeader.replace(/^Bearer\s+/i, "").trim(); - if (!token) return null; - const secret = Deno.env.get("JWT_SECRET"); - if (!secret) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); - const sig = b64urlToBytes(parts[2]); - const ok = await crypto.subtle.verify("HMAC", key, sig, data); - if (!ok) return null; - const payload = JSON.parse(new TextDecoder().decode(b64urlToBytes(parts[1]))) as Record; - if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) return null; - const sub = payload.sub; - if (typeof sub === "string" && sub.length > 0) return sub; - const uid = payload.user_id; - if (typeof uid === "string" && uid.length > 0) return uid; - } catch { /* ignore */ } - return null; -} - -async function fetchActiveDeviceIds( - client: ReturnType, - userId: string, -): Promise { - const { data, error } = await client.database - .from("tokentracker_devices") - .select("id") - .eq("user_id", userId) - .is("revoked_at", null); - if (error) throw new Error(error.message); - const rows = (data ?? []) as Array<{ id: string }>; - return rows.map((r) => r.id).filter((id): id is string => typeof id === "string" && id.length > 0); -} - -interface HourlyRow { - hour_start: string; - source: string | null; - model: string | null; - total_tokens: number | null; - input_tokens: number | null; - output_tokens: number | null; - cached_input_tokens: number | null; - cache_creation_input_tokens: number | null; - reasoning_output_tokens: number | null; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") - return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "GET") return json({ error: "Method not allowed" }, 405); - - const url = new URL(req.url); - const weeks = parseInt(url.searchParams.get("weeks") || "52", 10); - const tz = url.searchParams.get("tz") || null; - const tzOffsetRaw = url.searchParams.get("tz_offset_minutes"); - const tzOffsetMinutes = tzOffsetRaw != null && tzOffsetRaw !== "" ? Number(tzOffsetRaw) : null; - const toParam = url.searchParams.get("to") || ""; - const weekStartsOnRaw = (url.searchParams.get("week_starts_on") || "sun").toLowerCase(); - const weekStartsOn = weekStartsOnRaw === "mon" ? "mon" : "sun"; - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const dbToken = serviceRoleKey || anonKey; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - const userId = await verifiedUserIdFromJwt(req.headers.get("Authorization")); - if (!userId) return json({ error: "Unauthorized" }, 401); - - let activeDeviceIds: string[]; - try { - activeDeviceIds = await fetchActiveDeviceIds(client, userId); - } catch (e) { - return json({ error: (e as Error).message }, 500); - } - - // End anchor: caller-supplied `to` in their local day, else local today. - const toStr = toParam || zonedDayKey(new Date().toISOString(), tz, tzOffsetMinutes); - const end = new Date(`${toStr}T00:00:00Z`); - const start = new Date(end); - start.setUTCDate(start.getUTCDate() - weeks * 7 + 1); - const from = start.toISOString().slice(0, 10); - const to = toStr; - - // Widen ±1 day so TZ-shifted edges still get caught by the UTC query. - const startDate = new Date(`${from}T00:00:00Z`); - startDate.setUTCDate(startDate.getUTCDate() - 1); - const nextDay = new Date(`${to}T00:00:00Z`); - nextDay.setUTCDate(nextDay.getUTCDate() + 2); - const rangeStart = startDate.toISOString(); - const rangeEnd = nextDay.toISOString(); - - const rawRows: HourlyRow[] = []; - const PAGE_SIZE = 1000; - const DEVICE_CHUNK = 25; - for (let i = 0; i < activeDeviceIds.length; i += DEVICE_CHUNK) { - const chunk = activeDeviceIds.slice(i, i + DEVICE_CHUNK); - let offset = 0; - while (true) { - const { data, error } = await client.database - .from("tokentracker_hourly") - .select("hour_start, source, model, total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, reasoning_output_tokens") - .eq("user_id", userId) - .in("device_id", chunk) - .gte("hour_start", rangeStart) - .lt("hour_start", rangeEnd) - .order("hour_start", { ascending: true }) - .range(offset, offset + PAGE_SIZE - 1); - if (error) return json({ error: error.message }, 500); - if (!data || data.length === 0) break; - rawRows.push(...(data as unknown as HourlyRow[])); - if (data.length < PAGE_SIZE) break; - offset += PAGE_SIZE; - } - } - - const rows = rawRows; - - const byDay = new Map(); - for (const row of rows) { - if (!row.hour_start) continue; - const day = zonedDayKey(String(row.hour_start), tz, tzOffsetMinutes); - if (day < from || day > to) continue; - let a = byDay.get(day); - if (!a) { - a = { total_tokens: 0, billable_total_tokens: 0 }; - byDay.set(day, a); - } - const tt = Number(row.total_tokens) || 0; - a.total_tokens += tt; - a.billable_total_tokens += tt; - } - - const allValues = Array.from(byDay.values()) - .map((d) => d.billable_total_tokens) - .filter((v) => v > 0); - const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0; - const calcLevel = (v: number): 0 | 1 | 2 | 3 | 4 => { - if (v <= 0) return 0; - if (maxValue === 0) return 1; - const r = v / maxValue; - if (r <= 0.25) return 1; - if (r <= 0.5) return 2; - if (r <= 0.75) return 3; - return 4; - }; - - const cells: { day: string; total_tokens: number; billable_total_tokens: number; level: number }[] = []; - const cursor = new Date(start); - while (cursor <= end) { - const day = cursor.toISOString().slice(0, 10); - const data = byDay.get(day); - const billable = data?.billable_total_tokens || 0; - cells.push({ - day, - total_tokens: data?.total_tokens || 0, - billable_total_tokens: billable, - level: calcLevel(billable), - }); - cursor.setUTCDate(cursor.getUTCDate() + 1); - } - - const weeksArr: typeof cells[] = []; - for (let i = 0; i < cells.length; i += 7) { - weeksArr.push(cells.slice(i, i + 7)); - } - - return json({ - from, - to, - week_starts_on: weekStartsOn, - active_days: cells.filter((c) => c.billable_total_tokens > 0).length, - streak_days: 0, - weeks: weeksArr, - }); -} - diff --git a/dashboard/edge-patches/tokentracker-account-hourly.ts b/dashboard/edge-patches/tokentracker-account-hourly.ts deleted file mode 100644 index 76b88182..00000000 --- a/dashboard/edge-patches/tokentracker-account-hourly.ts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * InsForge Edge: account-wide hourly usage for a single day (cross-device). - * Mirrors local-api.js `tokentracker-usage-hourly`. Honors `tz` / `tz_offset_minutes`. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -function b64urlToBytes(s: string): Uint8Array { - const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const raw = atob(b64 + "=".repeat(pad)); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; -} - -/** - * Verify HS256 JWT against JWT_SECRET and return its sub. Mirrors the helper - * in tokentracker-device-token-issue.ts. Returns null on any failure — caller - * surfaces that as 401. InsForge does NOT validate JWTs at the gateway, so - * exposing per-user data without local verification lets anyone forge - * {"sub":""} and read another user's data. - */ -async function verifiedUserIdFromJwt(authHeader: string | null): Promise { - if (!authHeader) return null; - const token = authHeader.replace(/^Bearer\s+/i, "").trim(); - if (!token) return null; - const secret = Deno.env.get("JWT_SECRET"); - if (!secret) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); - const sig = b64urlToBytes(parts[2]); - const ok = await crypto.subtle.verify("HMAC", key, sig, data); - if (!ok) return null; - const payload = JSON.parse(new TextDecoder().decode(b64urlToBytes(parts[1]))) as Record; - if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) return null; - const sub = payload.sub; - if (typeof sub === "string" && sub.length > 0) return sub; - const uid = payload.user_id; - if (typeof uid === "string" && uid.length > 0) return uid; - } catch { /* ignore */ } - return null; -} - -async function fetchActiveDeviceIds( - client: ReturnType, - userId: string, -): Promise { - const { data, error } = await client.database - .from("tokentracker_devices") - .select("id") - .eq("user_id", userId) - .is("revoked_at", null); - if (error) throw new Error(error.message); - const rows = (data ?? []) as Array<{ id: string }>; - return rows.map((r) => r.id).filter((id): id is string => typeof id === "string" && id.length > 0); -} - -interface HourlyRow { - hour_start: string; - source: string | null; - model: string | null; - total_tokens: number | null; - input_tokens: number | null; - output_tokens: number | null; - cached_input_tokens: number | null; - cache_creation_input_tokens: number | null; - reasoning_output_tokens: number | null; - conversations: number | null; -} - -interface TzCtx { - timeZone: string | null; - offsetMinutes: number | null; -} - -interface ZonedParts { - year: number; - month: number; - day: number; - hour: number; - minute: number; - second: number; -} - -function getZonedParts(date: Date, ctx: TzCtx): ZonedParts | null { - if (!Number.isFinite(date.getTime())) return null; - if (ctx.timeZone) { - try { - const fmt = new Intl.DateTimeFormat("en-CA", { - timeZone: ctx.timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hourCycle: "h23", - }); - const parts = fmt.formatToParts(date); - const values: Record = {}; - for (const p of parts) if (p.type && p.value) values[p.type] = p.value; - const year = Number(values.year); - const month = Number(values.month); - const day = Number(values.day); - const hour = Number(values.hour); - const minute = Number(values.minute); - const second = Number(values.second); - if ([year, month, day, hour, minute, second].every(Number.isFinite)) - return { year, month, day, hour, minute, second }; - } catch { - // fall through - } - } - if (ctx.offsetMinutes !== null && Number.isFinite(ctx.offsetMinutes)) { - const shifted = new Date(date.getTime() + ctx.offsetMinutes * 60 * 1000); - return { - year: shifted.getUTCFullYear(), - month: shifted.getUTCMonth() + 1, - day: shifted.getUTCDate(), - hour: shifted.getUTCHours(), - minute: shifted.getUTCMinutes(), - second: shifted.getUTCSeconds(), - }; - } - return { - year: date.getUTCFullYear(), - month: date.getUTCMonth() + 1, - day: date.getUTCDate(), - hour: date.getUTCHours(), - minute: date.getUTCMinutes(), - second: date.getUTCSeconds(), - }; -} - -function formatDayKey(parts: ZonedParts): string { - return `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") - return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "GET") return json({ error: "Method not allowed" }, 405); - - const url = new URL(req.url); - const day = url.searchParams.get("day") || new Date().toISOString().slice(0, 10); - const tz = String(url.searchParams.get("tz") || "").trim(); - const rawOffset = Number(url.searchParams.get("tz_offset_minutes")); - const tzCtx: TzCtx = { - timeZone: tz || null, - offsetMinutes: Number.isFinite(rawOffset) ? Math.trunc(rawOffset) : null, - }; - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const dbToken = serviceRoleKey || anonKey; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - const userId = await verifiedUserIdFromJwt(req.headers.get("Authorization")); - if (!userId) return json({ error: "Unauthorized" }, 401); - - let activeDeviceIds: string[]; - try { - activeDeviceIds = await fetchActiveDeviceIds(client, userId); - } catch (e) { - return json({ error: (e as Error).message }, 500); - } - - // Query a 3-day UTC window around `day` to cover all TZ offsets (±14h max) - const dayDate = new Date(`${day}T00:00:00Z`); - const start = new Date(dayDate); - start.setUTCDate(start.getUTCDate() - 1); - const end = new Date(dayDate); - end.setUTCDate(end.getUTCDate() + 2); - const rangeStart = start.toISOString(); - const rangeEnd = end.toISOString(); - - const rawRows: HourlyRow[] = []; - const PAGE_SIZE = 1000; - const DEVICE_CHUNK = 25; - for (let i = 0; i < activeDeviceIds.length; i += DEVICE_CHUNK) { - const chunk = activeDeviceIds.slice(i, i + DEVICE_CHUNK); - let offset = 0; - while (true) { - const { data, error } = await client.database - .from("tokentracker_hourly") - .select( - "hour_start, source, model, total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, reasoning_output_tokens, conversations", - ) - .eq("user_id", userId) - .in("device_id", chunk) - .gte("hour_start", rangeStart) - .lt("hour_start", rangeEnd) - .order("hour_start", { ascending: true }) - .range(offset, offset + PAGE_SIZE - 1); - if (error) return json({ error: error.message }, 500); - if (!data || data.length === 0) break; - rawRows.push(...(data as unknown as HourlyRow[])); - if (data.length < PAGE_SIZE) break; - offset += PAGE_SIZE; - } - } - - const rows = rawRows; - - const byHour = new Map(); - for (const row of rows) { - if (!row.hour_start) continue; - const parts = getZonedParts(new Date(row.hour_start), tzCtx); - if (!parts) continue; - if (formatDayKey(parts) !== day) continue; - const hourKey = `${day}T${String(parts.hour).padStart(2, "0")}:00:00`; - let bucket = byHour.get(hourKey); - if (!bucket) { - bucket = { - hour: hourKey, - total_tokens: 0, - billable_total_tokens: 0, - input_tokens: 0, - output_tokens: 0, - cached_input_tokens: 0, - cache_creation_input_tokens: 0, - reasoning_output_tokens: 0, - conversation_count: 0, - }; - byHour.set(hourKey, bucket); - } - const tt = Number(row.total_tokens) || 0; - bucket.total_tokens += tt; - bucket.billable_total_tokens += tt; - bucket.input_tokens += Number(row.input_tokens) || 0; - bucket.output_tokens += Number(row.output_tokens) || 0; - bucket.cached_input_tokens += Number(row.cached_input_tokens) || 0; - bucket.cache_creation_input_tokens += Number(row.cache_creation_input_tokens) || 0; - bucket.reasoning_output_tokens += Number(row.reasoning_output_tokens) || 0; - bucket.conversation_count += Number(row.conversations) || 0; - } - - const data = Array.from(byHour.values()).sort((a, b) => a.hour.localeCompare(b.hour)); - return json({ day, data }); -} - diff --git a/dashboard/edge-patches/tokentracker-account-model-breakdown.ts b/dashboard/edge-patches/tokentracker-account-model-breakdown.ts deleted file mode 100644 index 1df5eae7..00000000 --- a/dashboard/edge-patches/tokentracker-account-model-breakdown.ts +++ /dev/null @@ -1,499 +0,0 @@ -/** - * InsForge Edge: account-wide usage broken down by source + model (cross-device, by user_id). - * Mirrors local-api.js `tokentracker-usage-model-breakdown` response schema. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -/** - * Convert a UTC timestamp to a local YYYY-MM-DD key using either an IANA tz - * name or a fixed offset in minutes. Positive offsetMinutes = east of UTC. - * Mirrors the helper in the other 5 account-* edge functions. - */ -function zonedDayKey(hourStart: string, tz: string | null, offsetMinutes: number | null): string { - if (tz) { - try { - const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date(hourStart)); - const y = parts.find((p) => p.type === "year")?.value; - const m = parts.find((p) => p.type === "month")?.value; - const d = parts.find((p) => p.type === "day")?.value; - if (y && m && d) return `${y}-${m}-${d}`; - } catch { /* fall through */ } - } - if (offsetMinutes != null && Number.isFinite(offsetMinutes)) { - const shifted = new Date(new Date(hourStart).getTime() + offsetMinutes * 60000); - return shifted.toISOString().slice(0, 10); - } - return hourStart.slice(0, 10); -} - -function b64urlToBytes(s: string): Uint8Array { - const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const raw = atob(b64 + "=".repeat(pad)); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; -} - -/** - * Verify HS256 JWT against JWT_SECRET and return its sub. Mirrors the helper - * in tokentracker-device-token-issue.ts. Returns null on any failure — caller - * surfaces that as 401. InsForge does NOT validate JWTs at the gateway, so - * exposing per-user data without local verification lets anyone forge - * {"sub":""} and read another user's data. - */ -async function verifiedUserIdFromJwt(authHeader: string | null): Promise { - if (!authHeader) return null; - const token = authHeader.replace(/^Bearer\s+/i, "").trim(); - if (!token) return null; - const secret = Deno.env.get("JWT_SECRET"); - if (!secret) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); - const sig = b64urlToBytes(parts[2]); - const ok = await crypto.subtle.verify("HMAC", key, sig, data); - if (!ok) return null; - const payload = JSON.parse(new TextDecoder().decode(b64urlToBytes(parts[1]))) as Record; - if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) return null; - const sub = payload.sub; - if (typeof sub === "string" && sub.length > 0) return sub; - const uid = payload.user_id; - if (typeof uid === "string" && uid.length > 0) return uid; - } catch { /* ignore */ } - return null; -} - -async function fetchActiveDeviceIds( - client: ReturnType, - userId: string, -): Promise { - const { data, error } = await client.database - .from("tokentracker_devices") - .select("id") - .eq("user_id", userId) - .is("revoked_at", null); - if (error) throw new Error(error.message); - const rows = (data ?? []) as Array<{ id: string }>; - return rows.map((r) => r.id).filter((id): id is string => typeof id === "string" && id.length > 0); -} - - -// MODEL_PRICING + getModelPricing synced from tokentracker-leaderboard-refresh.ts -// 2026-05-28: includes fable, mimo, gpt-5.5, glm, grok, deepseek-v4, kiro, hy3-preview (87 models). -// Keep this block byte-identical with leaderboard-refresh.ts; see feedback_model_pricing_sync. -const MODEL_PRICING: Record = { - // ── Anthropic Claude ── - "claude-fable-5": { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, - "claude-mythos-5": { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, - "claude-opus-4-6": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, - "claude-opus-4-5-20250414": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, - "claude-sonnet-5": { input: 2, output: 10, cache_read: 0.2, cache_write: 2.5 }, - "claude-sonnet-4-6": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-sonnet-4-5-20250514": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-sonnet-4-20250514": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-haiku-4-5-20251001": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, - "claude-3-5-sonnet-20241022": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-3-5-haiku-20241022": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, - // ── OpenAI GPT / Codex ── - "gpt-5": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-codex": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-codex-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-mini": { input: 0.25, output: 2, cache_read: 0.025 }, - "gpt-5.1-codex-max": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-xhigh-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.2": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-high-fast": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-codex": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-codex-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.3-codex": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.3-codex-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.4": { input: 2.5, output: 15, cache_read: 0.25 }, - "gpt-5.4-mini": { input: 0.75, output: 4.5, cache_read: 0.075 }, - "gpt-5.4-medium": { input: 1.5, output: 10, cache_read: 0.15 }, - "gpt-5.5": { input: 5, output: 30, cache_read: 0.5 }, - "gpt-5-mini": { input: 0.25, output: 2, cache_read: 0.025 }, - "o3": { input: 2, output: 8, cache_read: 0.5 }, - // ── Google Gemini ── - "gemini-2.5-pro": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-pro-preview-06-05": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-pro-preview-05-06": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-flash": { input: 0.3, output: 2.5, cache_read: 0.03 }, - "gemini-3-flash-preview": { input: 0.5, output: 3, cache_read: 0.05 }, - "gemini-3-pro-preview": { input: 2, output: 12, cache_read: 0.2 }, - "gemini-3.1-pro-preview": { input: 2, output: 12, cache_read: 0.2 }, - // ── Cursor Composer ── - "composer-1": { input: 1.25, output: 10, cache_read: 0.125 }, - "composer-1.5": { input: 3.5, output: 17.5, cache_read: 0.35 }, - "composer-2": { input: 0.5, output: 2.5, cache_read: 0.2 }, - "composer-2-fast": { input: 1.5, output: 7.5, cache_read: 0.15 }, - // ── Moonshot Kimi ── - "kimi-for-coding": { input: 0.6, output: 2, cache_read: 0.15 }, - "kimi-k2.5": { input: 0.6, output: 2, cache_read: 0.15 }, - "kimi-k2.5-free": { input: 0, output: 0, cache_read: 0 }, - "kimi-k2.6": { input: 0.95, output: 4, cache_read: 0.16 }, - // ── Z.ai GLM (mirrored from src/lib/pricing/curated-overrides.json). - // LiteLLM only keys these under provider prefixes like `zai/glm-5`, - // `openrouter/z-ai/glm-4.6`, etc. The reverse-substring fallback in the - // matcher requires the user-supplied model name to CONTAIN the LiteLLM - // key, so the bare `glm-5.1` / `glm-4.6` strings reported by Claude - // Code-compatible GLM endpoints never match. Curate them here. ── - "glm-5.1": { input: 1.4, output: 4.4, cache_read: 0.26 }, - "glm-5": { input: 1.0, output: 3.2, cache_read: 0.2 }, - "glm-5-turbo": { input: 1.2, output: 4.0, cache_read: 0.24 }, - "glm-4.7": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.7-flashx": { input: 0.07, output: 0.4, cache_read: 0.01 }, - "glm-4.7-flash": { input: 0, output: 0, cache_read: 0 }, - "glm-4.6": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.5": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.5-x": { input: 2.2, output: 8.9, cache_read: 0.45 }, - "glm-4.5-air": { input: 0.2, output: 1.1, cache_read: 0.03 }, - "glm-4.5-airx": { input: 1.1, output: 4.5, cache_read: 0.22 }, - "glm-4.5-flash": { input: 0, output: 0, cache_read: 0 }, - // ── MiniMax / DeepSeek ── - "MiniMax-M2.7": { input: 0.3, output: 1.2, cache_read: 0.06, cache_write: 0.375 }, - "MiniMax-M2.7-highspeed": { input: 0.6, output: 2.4, cache_read: 0.06, cache_write: 0.375 }, - "deepseek-v4-flash": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "deepseek-v4-pro": { input: 0.435, output: 0.87, cache_read: 0.003625, cache_write: 0.435 }, - "deepseek-chat": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "deepseek-reasoner": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - // ── xAI Grok (mirrored from src/lib/pricing/curated-overrides.json; - // Grok parser emits cache_creation_input_tokens = 0, so cache_write is - // omitted — same as the canonical table). ── - "grok-build": { input: 1.25, output: 2.50, cache_read: 0.20 }, - "grok-4-0709": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4-latest": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4-fast": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-fast-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-fast-non-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-1-fast-non-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - // ── AWS Kiro (mirrored byte-for-byte from src/lib/local-api.js to - // prevent cloud/local cost drift — Kiro routes through Bedrock, - // most commonly claude-sonnet-4). ── - "kiro-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "kiro-cli-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - // ── Tencent CodeBuddy (hy3-preview family). Mirrored from - // src/lib/local-api.js — Tencent has not published $/MTok rates so - // these stay at 0. TODO: confirm Tencent hy3 pricing. ── - "hy3-preview-agent": { input: 0, output: 0, cache_read: 0 }, - "hy3-preview": { input: 0, output: 0, cache_read: 0 }, - // ── Misc / Free ── - "glm-4.7-free": { input: 0, output: 0, cache_read: 0 }, - "nemotron-3-super-free": { input: 0, output: 0, cache_read: 0 }, - "mimo-v2-pro-free": { input: 0, output: 0, cache_read: 0 }, - "minimax-m2.1-free": { input: 0, output: 0, cache_read: 0 }, - "MiniMax-M2.1": { input: 0.5, output: 3, cache_read: 0.05 }, - // ── Xiaomi MiMo (mirrored from src/lib/pricing/seed-snapshot.json LiteLLM - // entries openrouter/xiaomi/mimo-*; queue rows report the bare names. - // Kept in lockstep with the matcher's litellm:prefix-strip resolution — - // cache_read for mimo-v2-flash uses novita's 0.02 (the lexicographically - // smallest provider key the matcher deterministically picks). ── - "mimo-v2.5-pro": { input: 1, output: 3, cache_read: 0.2 }, - "mimo-v2.5": { input: 0.4, output: 2, cache_read: 0.08 }, - "mimo-v2-flash": { input: 0.1, output: 0.3, cache_read: 0.02 }, -}; -const ZERO_PRICING = { input: 0, output: 0, cache_read: 0, cache_write: 0 }; - -function getModelPricing(model: string) { - if (!model) return ZERO_PRICING; - const exact = MODEL_PRICING[model]; - if (exact) return exact; - const lower = model.toLowerCase(); - if (lower.includes("fable")) return MODEL_PRICING["claude-fable-5"]; - if (lower.includes("mythos-5")) return MODEL_PRICING["claude-mythos-5"]; - if (lower.includes("opus")) return MODEL_PRICING["claude-opus-4-6"]; - if (lower.includes("haiku")) return MODEL_PRICING["claude-haiku-4-5-20251001"]; - if (lower.includes("sonnet")) return MODEL_PRICING["claude-sonnet-4-6"]; - if (lower.includes("gpt-5.4")) return MODEL_PRICING["gpt-5.4"]; - if (lower.includes("gpt-5.5")) return MODEL_PRICING["gpt-5.5"]; - if (lower.includes("gpt-5-mini")) return MODEL_PRICING["gpt-5-mini"]; - if (lower.includes("gpt-5.3")) return MODEL_PRICING["gpt-5.3-codex"]; - if (lower.includes("gpt-5.2")) return MODEL_PRICING["gpt-5.2"]; - if (lower.includes("gpt-5.1")) return MODEL_PRICING["gpt-5.1-codex"]; - if (lower.includes("gpt-5")) return MODEL_PRICING["gpt-5"]; - if (lower.includes("gemini-3")) return MODEL_PRICING["gemini-3-flash-preview"]; - if (lower.includes("gemini-2.5")) return MODEL_PRICING["gemini-2.5-pro"]; - if (lower.includes("minimax-m2.7-highspeed")) return MODEL_PRICING["MiniMax-M2.7-highspeed"]; - if (lower.includes("minimax-m2.7")) return MODEL_PRICING["MiniMax-M2.7"]; - if (lower.includes("deepseek-v4-flash")) return MODEL_PRICING["deepseek-v4-flash"]; - if (lower.includes("deepseek-v4-pro")) return MODEL_PRICING["deepseek-v4-pro"]; - if (lower.includes("deepseek-reasoner")) return MODEL_PRICING["deepseek-reasoner"]; - if (lower.includes("deepseek-chat")) return MODEL_PRICING["deepseek-chat"]; - if (lower.includes("grok-build")) return MODEL_PRICING["grok-build"]; - if (lower.includes("grok-4-fast")) return MODEL_PRICING["grok-4-fast"]; - // grok-4-1-fast-* must precede the generic grok-4 matcher. Cloud rows may - // carry a provider prefix or `-latest` suffix (e.g. xai/grok-4-1-fast- - // non-reasoning-latest), and the substring "grok-4-fast" does NOT match - // "grok-4-1-fast" (the "-1-" separates them). Without this specific match - // these rows fall through to grok-4 and get billed at $3/$15 MTok instead - // of the $0.20/$0.50 MTok fast-tier rate (15x / 30x overestimate). - if (lower.includes("grok-4-1-fast")) return MODEL_PRICING["grok-4-1-fast-non-reasoning"]; - if (lower.includes("grok-4")) return MODEL_PRICING["grok-4"]; - if (lower.includes("kimi-k2.6")) return MODEL_PRICING["kimi-k2.6"]; - if (lower.includes("kimi")) return MODEL_PRICING["kimi-k2.5"]; - // MiMo ordering: more specific suffixes first (mimo-v2.5-pro before - // mimo-v2.5 which is a substring; the free tier is a distinct name). - if (lower.includes("mimo-v2-pro-free")) return MODEL_PRICING["mimo-v2-pro-free"]; - if (lower.includes("mimo-v2.5-pro")) return MODEL_PRICING["mimo-v2.5-pro"]; - if (lower.includes("mimo-v2.5")) return MODEL_PRICING["mimo-v2.5"]; - if (lower.includes("mimo-v2-flash")) return MODEL_PRICING["mimo-v2-flash"]; - // GLM ordering: more specific suffixes (-airx/-air/-x/-flash/-flashx/-turbo) - // must precede the base matchers. glm-5.1 must precede glm-5 (substring). - if (lower.includes("glm-4.5-airx")) return MODEL_PRICING["glm-4.5-airx"]; - if (lower.includes("glm-4.5-air")) return MODEL_PRICING["glm-4.5-air"]; - if (lower.includes("glm-4.5-x")) return MODEL_PRICING["glm-4.5-x"]; - if (lower.includes("glm-4.5-flash")) return MODEL_PRICING["glm-4.5-flash"]; - if (lower.includes("glm-4.5")) return MODEL_PRICING["glm-4.5"]; - if (lower.includes("glm-4.7-flashx")) return MODEL_PRICING["glm-4.7-flashx"]; - if (lower.includes("glm-4.7-flash")) return MODEL_PRICING["glm-4.7-flash"]; - if (lower.includes("glm-4.7")) return MODEL_PRICING["glm-4.7"]; - if (lower.includes("glm-4.6")) return MODEL_PRICING["glm-4.6"]; - if (lower.includes("glm-5-turbo")) return MODEL_PRICING["glm-5-turbo"]; - if (lower.includes("glm-5.1")) return MODEL_PRICING["glm-5.1"]; - if (lower.includes("glm-5")) return MODEL_PRICING["glm-5"]; - if (lower.includes("kiro")) return MODEL_PRICING["kiro-cli-agent"]; - if (lower.includes("hy3")) return MODEL_PRICING["hy3-preview-agent"]; - if (lower.includes("composer")) return MODEL_PRICING["composer-1"]; - if (lower === "auto") return MODEL_PRICING["composer-1"]; - return ZERO_PRICING; -} - -interface HourlyRow { - hour_start: string; - source: string; - model: string; - total_tokens: number | null; - input_tokens: number | null; - output_tokens: number | null; - cached_input_tokens: number | null; - cache_creation_input_tokens: number | null; - reasoning_output_tokens: number | null; -} - -interface Totals { - total_tokens: number; - billable_total_tokens: number; - input_tokens: number; - output_tokens: number; - cached_input_tokens: number; - cache_creation_input_tokens: number; - reasoning_output_tokens: number; - total_cost_usd: string; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") - return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "GET") return json({ error: "Method not allowed" }, 405); - - const url = new URL(req.url); - const from = url.searchParams.get("from") || ""; - const to = url.searchParams.get("to") || ""; - if (!from || !to) return json({ error: "Missing from/to" }, 400); - const tz = url.searchParams.get("tz") || null; - const tzOffsetRaw = url.searchParams.get("tz_offset_minutes"); - const tzOffsetMinutes = tzOffsetRaw != null && tzOffsetRaw !== "" ? Number(tzOffsetRaw) : null; - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const dbToken = serviceRoleKey || anonKey; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - const userId = await verifiedUserIdFromJwt(req.headers.get("Authorization")); - if (!userId) return json({ error: "Unauthorized" }, 401); - - let activeDeviceIds: string[]; - try { - activeDeviceIds = await fetchActiveDeviceIds(client, userId); - } catch (e) { - return json({ error: (e as Error).message }, 500); - } - - // Widen the UTC query window by ±1 day so a caller in a non-UTC zone (e.g. - // Asia/Shanghai for Day=2026-05-18, which spans UTC 2026-05-17T16:00 to - // 2026-05-18T16:00) still gets every hourly row that maps into a local day - // in [from, to]. Matches the same widening other account-* aggregators do. - const startDate = new Date(`${from}T00:00:00Z`); - startDate.setUTCDate(startDate.getUTCDate() - 1); - const endDate = new Date(`${to}T00:00:00Z`); - endDate.setUTCDate(endDate.getUTCDate() + 2); - const rangeStart = startDate.toISOString(); - const rangeEnd = endDate.toISOString(); - - const rawRows: HourlyRow[] = []; - const PAGE_SIZE = 1000; - const DEVICE_CHUNK = 25; - for (let i = 0; i < activeDeviceIds.length; i += DEVICE_CHUNK) { - const chunk = activeDeviceIds.slice(i, i + DEVICE_CHUNK); - let offset = 0; - while (true) { - const { data, error } = await client.database - .from("tokentracker_hourly") - .select( - "hour_start, source, model, total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, reasoning_output_tokens", - ) - .eq("user_id", userId) - .in("device_id", chunk) - .gte("hour_start", rangeStart) - .lt("hour_start", rangeEnd) - .order("hour_start", { ascending: true }) - .range(offset, offset + PAGE_SIZE - 1); - if (error) return json({ error: error.message }, 500); - if (!data || data.length === 0) break; - rawRows.push(...(data as unknown as HourlyRow[])); - if (data.length < PAGE_SIZE) break; - offset += PAGE_SIZE; - } - } - - const rows = rawRows; - - // Filter on inclusive [from, to] by *local* day (honoring tz / tz_offset_minutes), - // not raw UTC. Without this, Day=2026-05-18 in Asia/Shanghai (which maps to - // UTC 2026-05-17T16:00–2026-05-18T16:00) returns an empty source list because - // every relevant row's UTC YYYY-MM-DD lands on 2026-05-17 or 2026-05-18 in a - // mix that the strict d>=from && d<=to UTC compare partially drops. - const filtered = rows.filter((r) => { - if (!r.hour_start) return false; - const d = zonedDayKey(String(r.hour_start), tz, tzOffsetMinutes); - return d >= from && d <= to; - }); - - interface ModelAgg { - model: string; - model_id: string; - totals: Totals; - } - interface SourceAgg { - source: string; - totals: Totals; - models: Map; - } - - const newTotals = (): Totals => ({ - total_tokens: 0, - billable_total_tokens: 0, - input_tokens: 0, - output_tokens: 0, - cached_input_tokens: 0, - cache_creation_input_tokens: 0, - reasoning_output_tokens: 0, - total_cost_usd: "0", - }); - - const bySource = new Map(); - for (const row of filtered) { - const src = row.source || "unknown"; - const mdl = row.model || "unknown"; - let sa = bySource.get(src); - if (!sa) { - sa = { source: src, totals: newTotals(), models: new Map() }; - bySource.set(src, sa); - } - const tt = Number(row.total_tokens) || 0; - sa.totals.total_tokens += tt; - sa.totals.billable_total_tokens += tt; - sa.totals.input_tokens += Number(row.input_tokens) || 0; - sa.totals.output_tokens += Number(row.output_tokens) || 0; - sa.totals.cached_input_tokens += Number(row.cached_input_tokens) || 0; - sa.totals.cache_creation_input_tokens += Number(row.cache_creation_input_tokens) || 0; - sa.totals.reasoning_output_tokens += Number(row.reasoning_output_tokens) || 0; - - let ma = sa.models.get(mdl); - if (!ma) { - ma = { model: mdl, model_id: mdl, totals: newTotals() }; - sa.models.set(mdl, ma); - } - ma.totals.total_tokens += tt; - ma.totals.billable_total_tokens += tt; - ma.totals.input_tokens += Number(row.input_tokens) || 0; - ma.totals.output_tokens += Number(row.output_tokens) || 0; - ma.totals.cached_input_tokens += Number(row.cached_input_tokens) || 0; - ma.totals.cache_creation_input_tokens += Number(row.cache_creation_input_tokens) || 0; - ma.totals.reasoning_output_tokens += Number(row.reasoning_output_tokens) || 0; - } - - const sources = Array.from(bySource.values()).map((s) => { - const models = Array.from(s.models.values()) - .map((m) => { - const p = getModelPricing(m.model); - const cost = - ((m.totals.input_tokens || 0) * (p.input || 0) + - (m.totals.output_tokens || 0) * (p.output || 0) + - (m.totals.cached_input_tokens || 0) * (p.cache_read || 0) + - (m.totals.cache_creation_input_tokens || 0) * ((p.cache_write ?? 0)) + - (m.totals.reasoning_output_tokens || 0) * (p.output || 0)) / - 1_000_000; - return { - model: m.model, - model_id: m.model_id, - totals: { ...m.totals, total_cost_usd: cost.toFixed(6) }, - }; - }) - .sort((a, b) => b.totals.total_tokens - a.totals.total_tokens); - const sourceCost = models.reduce((sum, m) => sum + Number(m.totals.total_cost_usd), 0); - return { - source: s.source, - totals: { ...s.totals, total_cost_usd: sourceCost.toFixed(6) }, - models, - }; - }); - - return json({ - from, - to, - days: 0, - sources, - pricing: { - model: "per-model", - pricing_mode: "per_token_type", - source: "litellm", - effective_from: new Date().toISOString().slice(0, 10), - }, - }); -} diff --git a/dashboard/edge-patches/tokentracker-account-monthly.ts b/dashboard/edge-patches/tokentracker-account-monthly.ts deleted file mode 100644 index ef6d3a7f..00000000 --- a/dashboard/edge-patches/tokentracker-account-monthly.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * InsForge Edge: account-wide monthly usage (cross-device, aggregated by user_id). - * Mirrors local-api.js `tokentracker-usage-monthly` response schema. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -function b64urlToBytes(s: string): Uint8Array { - const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const raw = atob(b64 + "=".repeat(pad)); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; -} - -/** - * Verify HS256 JWT against JWT_SECRET and return its sub. Mirrors the helper - * in tokentracker-device-token-issue.ts. Returns null on any failure — caller - * surfaces that as 401. InsForge does NOT validate JWTs at the gateway, so - * exposing per-user data without local verification lets anyone forge - * {"sub":""} and read another user's data. - */ -async function verifiedUserIdFromJwt(authHeader: string | null): Promise { - if (!authHeader) return null; - const token = authHeader.replace(/^Bearer\s+/i, "").trim(); - if (!token) return null; - const secret = Deno.env.get("JWT_SECRET"); - if (!secret) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); - const sig = b64urlToBytes(parts[2]); - const ok = await crypto.subtle.verify("HMAC", key, sig, data); - if (!ok) return null; - const payload = JSON.parse(new TextDecoder().decode(b64urlToBytes(parts[1]))) as Record; - if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) return null; - const sub = payload.sub; - if (typeof sub === "string" && sub.length > 0) return sub; - const uid = payload.user_id; - if (typeof uid === "string" && uid.length > 0) return uid; - } catch { /* ignore */ } - return null; -} - -async function fetchActiveDeviceIds( - client: ReturnType, - userId: string, -): Promise { - const { data, error } = await client.database - .from("tokentracker_devices") - .select("id") - .eq("user_id", userId) - .is("revoked_at", null); - if (error) throw new Error(error.message); - const rows = (data ?? []) as Array<{ id: string }>; - return rows.map((r) => r.id).filter((id): id is string => typeof id === "string" && id.length > 0); -} - -interface HourlyRow { - hour_start: string; - source: string | null; - model: string | null; - total_tokens: number | null; - input_tokens: number | null; - output_tokens: number | null; - cached_input_tokens: number | null; - cache_creation_input_tokens: number | null; - reasoning_output_tokens: number | null; - conversations: number | null; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") - return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "GET") return json({ error: "Method not allowed" }, 405); - - const url = new URL(req.url); - let from = url.searchParams.get("from") || ""; - let to = url.searchParams.get("to") || ""; - const monthsParam = parseInt(url.searchParams.get("months") || "", 10); - // Matches local /functions/tokentracker-usage-monthly contract: caller - // may send months (+ optional to) instead of an explicit from/to range. - if ((!from || !to) && Number.isFinite(monthsParam) && monthsParam > 0) { - const toDate = to ? new Date(`${to}T00:00:00Z`) : new Date(); - if (!to) to = toDate.toISOString().slice(0, 10); - const fromDate = new Date(Date.UTC(toDate.getUTCFullYear(), toDate.getUTCMonth() - (monthsParam - 1), 1)); - if (!from) from = fromDate.toISOString().slice(0, 10); - } - if (!from || !to) return json({ error: "Missing from/to or months" }, 400); - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const dbToken = serviceRoleKey || anonKey; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - const userId = await verifiedUserIdFromJwt(req.headers.get("Authorization")); - if (!userId) return json({ error: "Unauthorized" }, 401); - - let activeDeviceIds: string[]; - try { - activeDeviceIds = await fetchActiveDeviceIds(client, userId); - } catch (e) { - return json({ error: (e as Error).message }, 500); - } - - const rangeStart = `${from}T00:00:00Z`; - const nextDay = new Date(`${to}T00:00:00Z`); - nextDay.setUTCDate(nextDay.getUTCDate() + 1); - const rangeEnd = nextDay.toISOString(); - - const rawRows: HourlyRow[] = []; - const PAGE_SIZE = 1000; - const DEVICE_CHUNK = 25; - for (let i = 0; i < activeDeviceIds.length; i += DEVICE_CHUNK) { - const chunk = activeDeviceIds.slice(i, i + DEVICE_CHUNK); - let offset = 0; - while (true) { - const { data, error } = await client.database - .from("tokentracker_hourly") - .select( - "hour_start, source, model, total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, reasoning_output_tokens, conversations", - ) - .eq("user_id", userId) - .in("device_id", chunk) - .gte("hour_start", rangeStart) - .lt("hour_start", rangeEnd) - .order("hour_start", { ascending: true }) - .range(offset, offset + PAGE_SIZE - 1); - if (error) return json({ error: error.message }, 500); - if (!data || data.length === 0) break; - rawRows.push(...(data as unknown as HourlyRow[])); - if (data.length < PAGE_SIZE) break; - offset += PAGE_SIZE; - } - } - - const rows = rawRows; - - const byMonth = new Map(); - - for (const row of rows) { - if (!row.hour_start) continue; - const day = String(row.hour_start).slice(0, 10); - if (day < from || day > to) continue; - const month = day.slice(0, 7); - let a = byMonth.get(month); - if (!a) { - a = { - month, - total_tokens: 0, - billable_total_tokens: 0, - input_tokens: 0, - output_tokens: 0, - cached_input_tokens: 0, - cache_creation_input_tokens: 0, - reasoning_output_tokens: 0, - conversation_count: 0, - }; - byMonth.set(month, a); - } - const tt = Number(row.total_tokens) || 0; - a.total_tokens += tt; - a.billable_total_tokens += tt; - a.input_tokens += Number(row.input_tokens) || 0; - a.output_tokens += Number(row.output_tokens) || 0; - a.cached_input_tokens += Number(row.cached_input_tokens) || 0; - a.cache_creation_input_tokens += Number(row.cache_creation_input_tokens) || 0; - a.reasoning_output_tokens += Number(row.reasoning_output_tokens) || 0; - a.conversation_count += Number(row.conversations) || 0; - } - - const data = Array.from(byMonth.values()).sort((a, b) => a.month.localeCompare(b.month)); - return json({ from, to, data }); -} - diff --git a/dashboard/edge-patches/tokentracker-account-summary.ts b/dashboard/edge-patches/tokentracker-account-summary.ts deleted file mode 100644 index 81b0e7a6..00000000 --- a/dashboard/edge-patches/tokentracker-account-summary.ts +++ /dev/null @@ -1,576 +0,0 @@ -/** - * InsForge Edge: account-wide usage summary (cross-device, aggregated by user_id). - * Mirrors local-api.js `tokentracker-usage-summary` response schema. - * - * Auth: HS256 JWT_SECRET signature verification (same template as - * tokentracker-device-token-issue). InsForge does NOT validate JWTs at the - * gateway, so edge functions that expose per-user data MUST verify the - * signature themselves — otherwise any caller can forge {"sub":""} - * and read another user's full token history. - * - * Aggregation safety: - * 1. Only rows tied to an active device row (revoked_at IS NULL) are - * considered. tokentracker_devices_active_unique enforces one active - * device per (user, platform, device_name), so this filter drops the - * historic device_id churn from before partial-unique was enforced. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -/** - * Convert UTC timestamp to local YYYY-MM-DD (see local-api.js#getZonedParts). - * Positive offsetMinutes = east of UTC. - */ -function zonedDayKey(hourStart: string, tz: string | null, offsetMinutes: number | null): string { - if (tz) { - try { - const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date(hourStart)); - const y = parts.find((p) => p.type === "year")?.value; - const m = parts.find((p) => p.type === "month")?.value; - const d = parts.find((p) => p.type === "day")?.value; - if (y && m && d) return `${y}-${m}-${d}`; - } catch { /* fall through */ } - } - if (offsetMinutes != null && Number.isFinite(offsetMinutes)) { - const shifted = new Date(new Date(hourStart).getTime() + offsetMinutes * 60000); - return shifted.toISOString().slice(0, 10); - } - return hourStart.slice(0, 10); -} - -function b64urlToBytes(s: string): Uint8Array { - const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const raw = atob(b64 + "=".repeat(pad)); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; -} - -/** - * Verify HS256 JWT against JWT_SECRET and return its sub. Mirrors the helper - * in tokentracker-device-token-issue.ts. Returns null on any failure (bad - * shape, bad signature, expired) — caller surfaces that as 401. - */ -async function verifiedUserIdFromJwt(authHeader: string | null): Promise { - if (!authHeader) return null; - const token = authHeader.replace(/^Bearer\s+/i, "").trim(); - if (!token) return null; - const secret = Deno.env.get("JWT_SECRET"); - if (!secret) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); - const sig = b64urlToBytes(parts[2]); - const ok = await crypto.subtle.verify("HMAC", key, sig, data); - if (!ok) return null; - const payload = JSON.parse(new TextDecoder().decode(b64urlToBytes(parts[1]))) as Record; - if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) return null; - const sub = payload.sub; - if (typeof sub === "string" && sub.length > 0) return sub; - const uid = payload.user_id; - if (typeof uid === "string" && uid.length > 0) return uid; - } catch { /* ignore */ } - return null; -} - -/** - * Fetch the list of currently-active device_ids for a user. Used to filter - * `tokentracker_hourly` so rows tied to a revoked (historic) device_id do - * not inflate aggregated totals. - */ -async function fetchActiveDeviceIds( - client: ReturnType, - userId: string, -): Promise { - const { data, error } = await client.database - .from("tokentracker_devices") - .select("id") - .eq("user_id", userId) - .is("revoked_at", null); - if (error) throw new Error(error.message); - const rows = (data ?? []) as Array<{ id: string }>; - return rows.map((r) => r.id).filter((id): id is string => typeof id === "string" && id.length > 0); -} - -/** Per-model pricing (USD per million tokens). Synced from src/lib/local-api.js. */ - -// NOTE: MODEL_PRICING + getModelPricing synced from tokentracker-leaderboard-refresh.ts -// to fix dashboard cost under-reporting (e.g. mimo-v2.5-pro → $0, gpt-5.5 → fallback gpt-5). -// TODO: extract to a shared edge-pricing module; tracked in feedback_model_pricing_sync. -const MODEL_PRICING: Record = { - // ── Anthropic Claude ── - "claude-fable-5": { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, - "claude-mythos-5": { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, - "claude-opus-4-6": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, - "claude-opus-4-5-20250414": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, - "claude-sonnet-5": { input: 2, output: 10, cache_read: 0.2, cache_write: 2.5 }, - "claude-sonnet-4-6": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-sonnet-4-5-20250514": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-sonnet-4-20250514": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-haiku-4-5-20251001": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, - "claude-3-5-sonnet-20241022": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-3-5-haiku-20241022": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, - // ── OpenAI GPT / Codex ── - "gpt-5": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-codex": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-codex-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-mini": { input: 0.25, output: 2, cache_read: 0.025 }, - "gpt-5.1-codex-max": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-xhigh-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.2": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-high-fast": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-codex": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-codex-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.3-codex": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.3-codex-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.4": { input: 2.5, output: 15, cache_read: 0.25 }, - "gpt-5.4-mini": { input: 0.75, output: 4.5, cache_read: 0.075 }, - "gpt-5.4-medium": { input: 1.5, output: 10, cache_read: 0.15 }, - "gpt-5.5": { input: 5, output: 30, cache_read: 0.5 }, - "gpt-5-mini": { input: 0.25, output: 2, cache_read: 0.025 }, - "o3": { input: 2, output: 8, cache_read: 0.5 }, - // ── Google Gemini ── - "gemini-2.5-pro": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-pro-preview-06-05": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-pro-preview-05-06": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-flash": { input: 0.3, output: 2.5, cache_read: 0.03 }, - "gemini-3-flash-preview": { input: 0.5, output: 3, cache_read: 0.05 }, - "gemini-3-pro-preview": { input: 2, output: 12, cache_read: 0.2 }, - "gemini-3.1-pro-preview": { input: 2, output: 12, cache_read: 0.2 }, - // ── Cursor Composer ── - "composer-1": { input: 1.25, output: 10, cache_read: 0.125 }, - "composer-1.5": { input: 3.5, output: 17.5, cache_read: 0.35 }, - "composer-2": { input: 0.5, output: 2.5, cache_read: 0.2 }, - "composer-2-fast": { input: 1.5, output: 7.5, cache_read: 0.15 }, - // ── Moonshot Kimi ── - "kimi-for-coding": { input: 0.6, output: 2, cache_read: 0.15 }, - "kimi-k2.5": { input: 0.6, output: 2, cache_read: 0.15 }, - "kimi-k2.5-free": { input: 0, output: 0, cache_read: 0 }, - "kimi-k2.6": { input: 0.95, output: 4, cache_read: 0.16 }, - // ── Z.ai GLM (mirrored from src/lib/pricing/curated-overrides.json). - // LiteLLM only keys these under provider prefixes like `zai/glm-5`, - // `openrouter/z-ai/glm-4.6`, etc. The reverse-substring fallback in the - // matcher requires the user-supplied model name to CONTAIN the LiteLLM - // key, so the bare `glm-5.1` / `glm-4.6` strings reported by Claude - // Code-compatible GLM endpoints never match. Curate them here. ── - "glm-5.1": { input: 1.4, output: 4.4, cache_read: 0.26 }, - "glm-5": { input: 1.0, output: 3.2, cache_read: 0.2 }, - "glm-5-turbo": { input: 1.2, output: 4.0, cache_read: 0.24 }, - "glm-4.7": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.7-flashx": { input: 0.07, output: 0.4, cache_read: 0.01 }, - "glm-4.7-flash": { input: 0, output: 0, cache_read: 0 }, - "glm-4.6": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.5": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.5-x": { input: 2.2, output: 8.9, cache_read: 0.45 }, - "glm-4.5-air": { input: 0.2, output: 1.1, cache_read: 0.03 }, - "glm-4.5-airx": { input: 1.1, output: 4.5, cache_read: 0.22 }, - "glm-4.5-flash": { input: 0, output: 0, cache_read: 0 }, - // ── MiniMax / DeepSeek ── - "MiniMax-M2.7": { input: 0.3, output: 1.2, cache_read: 0.06, cache_write: 0.375 }, - "MiniMax-M2.7-highspeed": { input: 0.6, output: 2.4, cache_read: 0.06, cache_write: 0.375 }, - "deepseek-v4-flash": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "deepseek-v4-pro": { input: 0.435, output: 0.87, cache_read: 0.003625, cache_write: 0.435 }, - "deepseek-chat": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "deepseek-reasoner": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - // ── xAI Grok (mirrored from src/lib/pricing/curated-overrides.json; - // Grok parser emits cache_creation_input_tokens = 0, so cache_write is - // omitted — same as the canonical table). ── - "grok-build": { input: 1.25, output: 2.50, cache_read: 0.20 }, - "grok-4-0709": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4-latest": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4-fast": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-fast-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-fast-non-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-1-fast-non-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - // ── AWS Kiro (mirrored byte-for-byte from src/lib/local-api.js to - // prevent cloud/local cost drift — Kiro routes through Bedrock, - // most commonly claude-sonnet-4). ── - "kiro-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "kiro-cli-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - // ── Tencent CodeBuddy (hy3-preview family). Mirrored from - // src/lib/local-api.js — Tencent has not published $/MTok rates so - // these stay at 0. TODO: confirm Tencent hy3 pricing. ── - "hy3-preview-agent": { input: 0, output: 0, cache_read: 0 }, - "hy3-preview": { input: 0, output: 0, cache_read: 0 }, - // ── Misc / Free ── - "glm-4.7-free": { input: 0, output: 0, cache_read: 0 }, - "nemotron-3-super-free": { input: 0, output: 0, cache_read: 0 }, - "mimo-v2-pro-free": { input: 0, output: 0, cache_read: 0 }, - "minimax-m2.1-free": { input: 0, output: 0, cache_read: 0 }, - "MiniMax-M2.1": { input: 0.5, output: 3, cache_read: 0.05 }, - // ── Xiaomi MiMo (mirrored from src/lib/pricing/seed-snapshot.json LiteLLM - // entries openrouter/xiaomi/mimo-*; queue rows report the bare names. - // Kept in lockstep with the matcher's litellm:prefix-strip resolution — - // cache_read for mimo-v2-flash uses novita's 0.02 (the lexicographically - // smallest provider key the matcher deterministically picks). ── - "mimo-v2.5-pro": { input: 1, output: 3, cache_read: 0.2 }, - "mimo-v2.5": { input: 0.4, output: 2, cache_read: 0.08 }, - "mimo-v2-flash": { input: 0.1, output: 0.3, cache_read: 0.02 }, -}; -const ZERO_PRICING = { input: 0, output: 0, cache_read: 0, cache_write: 0 }; - -function getModelPricing(model: string) { - if (!model) return ZERO_PRICING; - const exact = MODEL_PRICING[model]; - if (exact) return exact; - const lower = model.toLowerCase(); - if (lower.includes("fable")) return MODEL_PRICING["claude-fable-5"]; - if (lower.includes("mythos-5")) return MODEL_PRICING["claude-mythos-5"]; - if (lower.includes("opus")) return MODEL_PRICING["claude-opus-4-6"]; - if (lower.includes("haiku")) return MODEL_PRICING["claude-haiku-4-5-20251001"]; - if (lower.includes("sonnet")) return MODEL_PRICING["claude-sonnet-4-6"]; - if (lower.includes("gpt-5.4")) return MODEL_PRICING["gpt-5.4"]; - if (lower.includes("gpt-5.5")) return MODEL_PRICING["gpt-5.5"]; - if (lower.includes("gpt-5-mini")) return MODEL_PRICING["gpt-5-mini"]; - if (lower.includes("gpt-5.3")) return MODEL_PRICING["gpt-5.3-codex"]; - if (lower.includes("gpt-5.2")) return MODEL_PRICING["gpt-5.2"]; - if (lower.includes("gpt-5.1")) return MODEL_PRICING["gpt-5.1-codex"]; - if (lower.includes("gpt-5")) return MODEL_PRICING["gpt-5"]; - if (lower.includes("gemini-3")) return MODEL_PRICING["gemini-3-flash-preview"]; - if (lower.includes("gemini-2.5")) return MODEL_PRICING["gemini-2.5-pro"]; - if (lower.includes("minimax-m2.7-highspeed")) return MODEL_PRICING["MiniMax-M2.7-highspeed"]; - if (lower.includes("minimax-m2.7")) return MODEL_PRICING["MiniMax-M2.7"]; - if (lower.includes("deepseek-v4-flash")) return MODEL_PRICING["deepseek-v4-flash"]; - if (lower.includes("deepseek-v4-pro")) return MODEL_PRICING["deepseek-v4-pro"]; - if (lower.includes("deepseek-reasoner")) return MODEL_PRICING["deepseek-reasoner"]; - if (lower.includes("deepseek-chat")) return MODEL_PRICING["deepseek-chat"]; - if (lower.includes("grok-build")) return MODEL_PRICING["grok-build"]; - if (lower.includes("grok-4-fast")) return MODEL_PRICING["grok-4-fast"]; - // grok-4-1-fast-* must precede the generic grok-4 matcher. Cloud rows may - // carry a provider prefix or `-latest` suffix (e.g. xai/grok-4-1-fast- - // non-reasoning-latest), and the substring "grok-4-fast" does NOT match - // "grok-4-1-fast" (the "-1-" separates them). Without this specific match - // these rows fall through to grok-4 and get billed at $3/$15 MTok instead - // of the $0.20/$0.50 MTok fast-tier rate (15x / 30x overestimate). - if (lower.includes("grok-4-1-fast")) return MODEL_PRICING["grok-4-1-fast-non-reasoning"]; - if (lower.includes("grok-4")) return MODEL_PRICING["grok-4"]; - if (lower.includes("kimi-k2.6")) return MODEL_PRICING["kimi-k2.6"]; - if (lower.includes("kimi")) return MODEL_PRICING["kimi-k2.5"]; - // MiMo ordering: more specific suffixes first (mimo-v2.5-pro before - // mimo-v2.5 which is a substring; the free tier is a distinct name). - if (lower.includes("mimo-v2-pro-free")) return MODEL_PRICING["mimo-v2-pro-free"]; - if (lower.includes("mimo-v2.5-pro")) return MODEL_PRICING["mimo-v2.5-pro"]; - if (lower.includes("mimo-v2.5")) return MODEL_PRICING["mimo-v2.5"]; - if (lower.includes("mimo-v2-flash")) return MODEL_PRICING["mimo-v2-flash"]; - // GLM ordering: more specific suffixes (-airx/-air/-x/-flash/-flashx/-turbo) - // must precede the base matchers. glm-5.1 must precede glm-5 (substring). - if (lower.includes("glm-4.5-airx")) return MODEL_PRICING["glm-4.5-airx"]; - if (lower.includes("glm-4.5-air")) return MODEL_PRICING["glm-4.5-air"]; - if (lower.includes("glm-4.5-x")) return MODEL_PRICING["glm-4.5-x"]; - if (lower.includes("glm-4.5-flash")) return MODEL_PRICING["glm-4.5-flash"]; - if (lower.includes("glm-4.5")) return MODEL_PRICING["glm-4.5"]; - if (lower.includes("glm-4.7-flashx")) return MODEL_PRICING["glm-4.7-flashx"]; - if (lower.includes("glm-4.7-flash")) return MODEL_PRICING["glm-4.7-flash"]; - if (lower.includes("glm-4.7")) return MODEL_PRICING["glm-4.7"]; - if (lower.includes("glm-4.6")) return MODEL_PRICING["glm-4.6"]; - if (lower.includes("glm-5-turbo")) return MODEL_PRICING["glm-5-turbo"]; - if (lower.includes("glm-5.1")) return MODEL_PRICING["glm-5.1"]; - if (lower.includes("glm-5")) return MODEL_PRICING["glm-5"]; - if (lower.includes("kiro")) return MODEL_PRICING["kiro-cli-agent"]; - if (lower.includes("hy3")) return MODEL_PRICING["hy3-preview-agent"]; - if (lower.includes("composer")) return MODEL_PRICING["composer-1"]; - if (lower === "auto") return MODEL_PRICING["composer-1"]; - return ZERO_PRICING; -} - - -interface HourlyRow { - hour_start: string; - source: string; - model: string; - total_tokens: number | null; - input_tokens: number | null; - output_tokens: number | null; - cached_input_tokens: number | null; - cache_creation_input_tokens: number | null; - reasoning_output_tokens: number | null; - conversations: number | null; -} - -function computeRowCost(row: HourlyRow): number { - const p = getModelPricing(row.model); - return ( - ((Number(row.input_tokens) || 0) * (p.input || 0) + - (Number(row.output_tokens) || 0) * (p.output || 0) + - (Number(row.cached_input_tokens) || 0) * (p.cache_read || 0) + - (Number(row.cache_creation_input_tokens) || 0) * ((p.cache_write ?? 0)) + - (Number(row.reasoning_output_tokens) || 0) * (p.output || 0)) / - 1_000_000 - ); -} - -interface DayAgg { - day: string; - total_tokens: number; - billable_total_tokens: number; - total_cost_usd: number; - input_tokens: number; - output_tokens: number; - cached_input_tokens: number; - cache_creation_input_tokens: number; - reasoning_output_tokens: number; - conversation_count: number; -} - -function aggregateByDay( - rows: HourlyRow[], - tz: string | null, - tzOffsetMinutes: number | null, -): DayAgg[] { - const byDay = new Map(); - for (const row of rows) { - if (!row.hour_start) continue; - const day = zonedDayKey(String(row.hour_start), tz, tzOffsetMinutes); - let a = byDay.get(day); - if (!a) { - a = { - day, - total_tokens: 0, - billable_total_tokens: 0, - total_cost_usd: 0, - input_tokens: 0, - output_tokens: 0, - cached_input_tokens: 0, - cache_creation_input_tokens: 0, - reasoning_output_tokens: 0, - conversation_count: 0, - }; - byDay.set(day, a); - } - const tt = Number(row.total_tokens) || 0; - a.total_tokens += tt; - a.billable_total_tokens += tt; - a.total_cost_usd += computeRowCost(row); - a.input_tokens += Number(row.input_tokens) || 0; - a.output_tokens += Number(row.output_tokens) || 0; - a.cached_input_tokens += Number(row.cached_input_tokens) || 0; - a.cache_creation_input_tokens += Number(row.cache_creation_input_tokens) || 0; - a.reasoning_output_tokens += Number(row.reasoning_output_tokens) || 0; - a.conversation_count += Number(row.conversations) || 0; - } - return Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day)); -} - -async function fetchAllRows( - client: ReturnType, - userId: string, - activeDeviceIds: string[], - rangeStart: string, - rangeEnd: string, - columns = "hour_start, source, model, total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, reasoning_output_tokens, conversations", -): Promise { - if (activeDeviceIds.length === 0) return []; - const out: HourlyRow[] = []; - const PAGE_SIZE = 1000; - // PostgREST `.in()` URL gets too large past ~25 IDs; chunk to be safe. - const DEVICE_CHUNK = 25; - for (let i = 0; i < activeDeviceIds.length; i += DEVICE_CHUNK) { - const chunk = activeDeviceIds.slice(i, i + DEVICE_CHUNK); - let offset = 0; - while (true) { - const { data, error } = await client.database - .from("tokentracker_hourly") - .select(columns) - .eq("user_id", userId) - .in("device_id", chunk) - .gte("hour_start", rangeStart) - .lt("hour_start", rangeEnd) - .order("hour_start", { ascending: true }) - .range(offset, offset + PAGE_SIZE - 1); - if (error) throw new Error(error.message); - if (!data || data.length === 0) break; - out.push(...(data as unknown as HourlyRow[])); - if (data.length < PAGE_SIZE) break; - offset += PAGE_SIZE; - } - } - return out; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") - return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "GET") return json({ error: "Method not allowed" }, 405); - - const url = new URL(req.url); - const from = url.searchParams.get("from") || ""; - const to = url.searchParams.get("to") || ""; - if (!from || !to) return json({ error: "Missing from/to" }, 400); - const tz = url.searchParams.get("tz") || null; - const tzOffsetRaw = url.searchParams.get("tz_offset_minutes"); - const tzOffsetMinutes = tzOffsetRaw != null && tzOffsetRaw !== "" ? Number(tzOffsetRaw) : null; - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const dbToken = serviceRoleKey || anonKey; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - const userId = await verifiedUserIdFromJwt(req.headers.get("Authorization")); - if (!userId) return json({ error: "Unauthorized" }, 401); - - let activeDeviceIds: string[]; - try { - activeDeviceIds = await fetchActiveDeviceIds(client, userId); - } catch (e) { - return json({ error: (e as Error).message }, 500); - } - - // Range for requested [from, to]; widen ±1 day to capture TZ-shifted - // edge hours for non-UTC callers. - const startDate = new Date(`${from}T00:00:00Z`); - startDate.setUTCDate(startDate.getUTCDate() - 1); - const nextDay = new Date(`${to}T00:00:00Z`); - nextDay.setUTCDate(nextDay.getUTCDate() + 2); - const rangeStart = startDate.toISOString(); - const rangeEnd = nextDay.toISOString(); - - // Anchor rolling windows to the caller's local "today" (matches daily - // buckets which are keyed by local day via zonedDayKey). - const todayStr = zonedDayKey(new Date().toISOString(), tz, tzOffsetMinutes); - const todayUtcMidnight = new Date(`${todayStr}T00:00:00Z`); - const thirtyAgo = new Date(todayUtcMidnight); - thirtyAgo.setUTCDate(thirtyAgo.getUTCDate() - 29); - const thirtyAgoStr = thirtyAgo.toISOString().slice(0, 10); - // Widen ±1 UTC day around the local-day boundary for query safety. - const rollingStartDate = new Date(`${(thirtyAgoStr < from ? thirtyAgoStr : from)}T00:00:00Z`); - rollingStartDate.setUTCDate(rollingStartDate.getUTCDate() - 1); - const rollingEndDate = new Date(todayUtcMidnight); - rollingEndDate.setUTCDate(rollingEndDate.getUTCDate() + 2); - const rollingStart = rollingStartDate.toISOString(); - const rollingEndNext = rollingEndDate; - - let allRows: HourlyRow[]; - try { - allRows = await fetchAllRows(client, userId, activeDeviceIds, rollingStart, rollingEndNext.toISOString()); - } catch (e) { - return json({ error: (e as Error).message }, 500); - } - - const allDaily = aggregateByDay(allRows, tz, tzOffsetMinutes); - const daily = allDaily.filter((d) => d.day >= from && d.day <= to); - - const totals = daily.reduce( - (acc, r) => { - acc.total_tokens += r.total_tokens; - acc.billable_total_tokens += r.billable_total_tokens; - acc.total_cost_usd += r.total_cost_usd || 0; - acc.input_tokens += r.input_tokens; - acc.output_tokens += r.output_tokens; - acc.cached_input_tokens += r.cached_input_tokens; - acc.cache_creation_input_tokens += r.cache_creation_input_tokens; - acc.reasoning_output_tokens += r.reasoning_output_tokens; - acc.conversation_count += r.conversation_count; - return acc; - }, - { - total_tokens: 0, - billable_total_tokens: 0, - total_cost_usd: 0, - input_tokens: 0, - output_tokens: 0, - cached_input_tokens: 0, - cache_creation_input_tokens: 0, - reasoning_output_tokens: 0, - conversation_count: 0, - }, - ); - const totalCost = totals.total_cost_usd; - - const collectDays = (n: number) => { - const out: DayAgg[] = []; - for (let i = n - 1; i >= 0; i--) { - const d = new Date(todayUtcMidnight); - d.setUTCDate(d.getUTCDate() - i); - const ds = d.toISOString().slice(0, 10); - const dd = allDaily.find((x) => x.day === ds); - if (dd) out.push(dd); - } - return out; - }; - const sumDays = (days: DayAgg[]) => - days.reduce( - (a, r) => { - a.billable_total_tokens += r.billable_total_tokens; - a.conversation_count += r.conversation_count; - return a; - }, - { billable_total_tokens: 0, conversation_count: 0 }, - ); - - const l7 = collectDays(7); - const l30 = collectDays(30); - const l7t = sumDays(l7); - const l30t = sumDays(l30); - const l7from = new Date(todayUtcMidnight); - l7from.setUTCDate(l7from.getUTCDate() - 6); - const l30from = new Date(todayUtcMidnight); - l30from.setUTCDate(l30from.getUTCDate() - 29); - - return json({ - from, - to, - days: daily.length, - totals: { ...totals, total_cost_usd: totalCost.toFixed(6) }, - rolling: { - last_7d: { - from: l7from.toISOString().slice(0, 10), - to: todayStr, - active_days: l7.length, - totals: l7t, - }, - last_30d: { - from: l30from.toISOString().slice(0, 10), - to: todayStr, - active_days: l30.length, - totals: l30t, - avg_per_active_day: - l30.length > 0 ? Math.round(l30t.billable_total_tokens / l30.length) : 0, - }, - }, - }); -} diff --git a/dashboard/edge-patches/tokentracker-badge-svg.ts b/dashboard/edge-patches/tokentracker-badge-svg.ts deleted file mode 100644 index c3227c85..00000000 --- a/dashboard/edge-patches/tokentracker-badge-svg.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * InsForge Edge: README badge SVG endpoint. - * - * Returns a shields.io-compatible SVG badge for a given user_id. - * - * Usage in README.md: - * ![tokens](https:///functions/tokentracker-badge-svg?user_id=&metric=tokens) - * - * Query params: - * user_id (required) – UUID of the user (from tokentracker_leaderboard_snapshots) - * metric tokens | cost | rank (default: tokens) - * period week | month | total (default: total) - * style flat | flat-square (default: flat) - * compact 1 to shorten numbers (1.2B vs 1,234,567,890) - * label custom left-side label (default: "tokentracker" or the metric name) - * color custom right-side color hex (default: brand green #059669) - * - * Security: snapshots are public data; we only expose rows where is_public=true - * (mirrors the leaderboard profile endpoint privacy gate). - * - * Caching: 60s ISR (Cache-Control: public, max-age=60). - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", -}; - -const BRAND_GREEN = "#059669"; -const LABEL_BG = "#555"; - -function svgResponse(svg: string, status = 200, cacheSeconds = 60): Response { - return new Response(svg, { - status, - headers: { - ...corsHeaders, - "Content-Type": "image/svg+xml; charset=utf-8", - "Cache-Control": `public, max-age=${cacheSeconds}, s-maxage=${cacheSeconds}`, - }, - }); -} - -function escapeXml(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function isUuid(s: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); -} - -function compactNumber(n: number): string { - if (!isFinite(n)) return "0"; - const abs = Math.abs(n); - if (abs >= 1e12) return (n / 1e12).toFixed(2).replace(/\.?0+$/, "") + "T"; - if (abs >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, "") + "B"; - if (abs >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, "") + "M"; - if (abs >= 1e3) return (n / 1e3).toFixed(2).replace(/\.?0+$/, "") + "K"; - return String(Math.round(n)); -} - -function formatCost(n: number): string { - if (n >= 1000) return "$" + Math.round(n).toLocaleString("en-US"); - if (n >= 100) return "$" + n.toFixed(0); - return "$" + n.toFixed(2); -} - -// Rough text width for "Verdana, DejaVu Sans, sans-serif" at 11px, -// good enough for shields.io-style badges. (Real shields.io measures glyph -// widths per char; we average ~6.2px which matches their default.) -function textWidth(s: string): number { - // 6.2px per ASCII char is a tested approximation that lines up with - // shields.io rendering for most strings. - return Math.max(s.length * 6.2, 0); -} - -interface SnapshotRow { - user_id: string; - display_name: string | null; - rank: number | null; - total_tokens: number | null; - estimated_cost_usd: number | null; - is_public: boolean | null; - from_day: string; - to_day: string; - generated_at: string; -} - -// deno-lint-ignore no-explicit-any -async function windowBounds(client: any, period: string): Promise<{ from_day: string; to_day: string }> { - const now = new Date(); - let from_day: string; - let to_day: string; - if (period === "week") { - const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7)) /* ISO Mon-start, matches leaderboard-refresh+dashboard */; - from_day = d.toISOString().slice(0, 10); - d.setUTCDate(d.getUTCDate() + 6); - to_day = d.toISOString().slice(0, 10); - } else if (period === "month") { - from_day = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-01`; - to_day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)) - .toISOString() - .slice(0, 10); - } else { - const { data: latest } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select("from_day, to_day") - .eq("period", "total") - .order("to_day", { ascending: false }) - .limit(1) - .maybeSingle(); - const row = latest as { from_day?: string; to_day?: string } | null; - from_day = (row?.from_day ?? "2024-01-01").slice(0, 10); - to_day = (row?.to_day ?? now.toISOString()).slice(0, 10); - } - return { from_day, to_day }; -} - -function renderBadgeSvg(opts: { - label: string; - value: string; - style: "flat" | "flat-square"; - color: string; -}): string { - const { label, value, style, color } = opts; - const padX = 6; - const labelWidth = Math.ceil(textWidth(label) + padX * 2); - const valueWidth = Math.ceil(textWidth(value) + padX * 2); - const totalWidth = labelWidth + valueWidth; - const height = 20; - const rx = style === "flat-square" ? 0 : 3; - - // We render two rect halves to make the corner radius work cleanly on flat - // style; flat-square sets rx=0 so corners stay sharp. - const safeLabel = escapeXml(label); - const safeValue = escapeXml(value); - const labelTextX = labelWidth / 2; - const valueTextX = labelWidth + valueWidth / 2; - - return ` - ${safeLabel}: ${safeValue} - - - - - - - - - - - - - - - ${safeLabel} - - ${safeValue} - -`; -} - -function renderErrorBadge(label: string, message: string): string { - return renderBadgeSvg({ - label, - value: message, - style: "flat", - color: "#9f9f9f", - }); -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") { - return new Response(null, { status: 204, headers: corsHeaders }); - } - if (req.method !== "GET") { - return svgResponse(renderErrorBadge("tokentracker", "method"), 405, 0); - } - - const url = new URL(req.url); - const userId = (url.searchParams.get("user_id") || "").trim().toLowerCase(); - const metric = (url.searchParams.get("metric") || "tokens").toLowerCase(); - const period = (url.searchParams.get("period") || "total").toLowerCase(); - const style = (url.searchParams.get("style") || "flat").toLowerCase() === "flat-square" - ? "flat-square" - : "flat"; - const compact = url.searchParams.get("compact") !== "0"; - const customLabel = url.searchParams.get("label"); - const customColor = url.searchParams.get("color"); - - if (!userId || !isUuid(userId)) { - return svgResponse(renderErrorBadge("tokentracker", "bad user_id"), 400, 0); - } - - if (!["tokens", "cost", "rank"].includes(metric)) { - return svgResponse(renderErrorBadge("tokentracker", "bad metric"), 400, 0); - } - if (!["week", "month", "total"].includes(period)) { - return svgResponse(renderErrorBadge("tokentracker", "bad period"), 400, 0); - } - - // Public endpoint: use service role key so a caller's stale Authorization - // can never trigger the InsForge gateway's JWT validator (see - // tokentracker-leaderboard.ts header for the rationale). - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const anonKey = Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY"); - const baseUrl = Deno.env.get("INSFORGE_BASE_URL"); - if (!baseUrl) { - return svgResponse(renderErrorBadge("tokentracker", "misconfigured"), 500, 0); - } - const client = createClient({ - baseUrl, - edgeFunctionToken: serviceRoleKey, - anonKey: anonKey ?? undefined, - isServerMode: true, - }); - - let from_day: string; - let to_day: string; - try { - const bounds = await windowBounds(client, period); - from_day = bounds.from_day; - to_day = bounds.to_day; - } catch { - return svgResponse(renderErrorBadge("tokentracker", "lookup failed"), 502, 0); - } - - const { data, error } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select("user_id, display_name, rank, total_tokens, estimated_cost_usd, is_public, from_day, to_day, generated_at") - .eq("user_id", userId) - .eq("period", period) - .eq("from_day", from_day) - .eq("to_day", to_day) - .order("generated_at", { ascending: false }) - .limit(1) - .maybeSingle(); - - if (error) { - return svgResponse(renderErrorBadge("tokentracker", "db error"), 502, 0); - } - - const row = data as SnapshotRow | null; - if (!row) { - return svgResponse(renderErrorBadge("tokentracker", "not found"), 404, 0); - } - if (row.is_public === false) { - // Privacy gate: anonymous badge for non-public profiles. - return svgResponse(renderErrorBadge("tokentracker", "private"), 403, 0); - } - - let value: string; - if (metric === "tokens") { - const n = Number(row.total_tokens ?? 0); - value = compact ? `${compactNumber(n)} tokens` : `${n.toLocaleString("en-US")} tokens`; - } else if (metric === "cost") { - value = formatCost(Number(row.estimated_cost_usd ?? 0)); - } else { - value = `#${row.rank ?? "?"}`; - } - - const defaultLabel = - metric === "rank" - ? row.display_name ? `${row.display_name} rank` : "rank" - : metric === "cost" - ? "cost" - : "tokens"; - const label = customLabel?.trim() || defaultLabel; - const color = customColor && /^#?[0-9a-f]{3,8}$/i.test(customColor.trim()) - ? (customColor.startsWith("#") ? customColor : `#${customColor}`) - : BRAND_GREEN; - - const svg = renderBadgeSvg({ label, value, style, color }); - return svgResponse(svg, 200, 60); -} diff --git a/dashboard/edge-patches/tokentracker-device-flow-authorize.ts b/dashboard/edge-patches/tokentracker-device-flow-authorize.ts deleted file mode 100644 index c09805f3..00000000 --- a/dashboard/edge-patches/tokentracker-device-flow-authorize.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * InsForge Edge: OAuth-style device flow — authorize step. - * - * Called from a CLI / SSH session that can't open a browser. The endpoint - * creates a pair (device_code, user_code) and stores them with a 15-minute - * expiry; the CLI then displays user_code to the human and polls - * `tokentracker-device-flow-poll` until the user grants the code from a - * browser via the dashboard /device page. - * - * Response: - * { - * device_code: , // CLI keeps; never shown to user - * user_code: "AB12-CD34", // user types this in the browser - * verification_uri: "https://www.tokentracker.cc/device", - * verification_uri_complete: "https://www.tokentracker.cc/device?user_code=AB12-CD34", - * expires_in: 900, // seconds - * interval: 5 // poll cadence in seconds - * } - * - * Public endpoint — no auth required, since the CLI by definition does - * not have a token yet. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -// 64 hex chars. crypto.getRandomValues is provided by Deno. -function generateDeviceCode(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -// 8 ambiguity-safe chars (no 0/O/1/I/L) split as XXXX-XXXX for easy typing. -function generateUserCode(): string { - const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; - const bytes = new Uint8Array(8); - crypto.getRandomValues(bytes); - let out = ""; - for (let i = 0; i < 8; i++) { - out += alphabet[bytes[i] % alphabet.length]; - if (i === 3) out += "-"; - } - return out; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); - - let body: { client_info?: string } = {}; - try { - body = await req.json(); - } catch (_e) { - /* empty body is fine */ - } - const clientInfo = typeof body.client_info === "string" ? body.client_info.slice(0, 200) : null; - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL"); - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const anonKey = Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY"); - if (!baseUrl) return json({ error: "misconfigured" }, 500); - const dbToken = serviceRoleKey || anonKey; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - // Try a few times in the (vanishingly unlikely) case of user_code collision - // — collision probability per attempt is ~10^-12, but the DB UNIQUE - // constraint will throw if it does happen. - let lastError: unknown = null; - for (let attempt = 0; attempt < 5; attempt++) { - const device_code = generateDeviceCode(); - const user_code = generateUserCode(); - const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); - const { error } = await client.database.from("tokentracker_device_codes").insert([ - { - device_code, - user_code, - status: "pending", - client_info: clientInfo, - expires_at: expiresAt, - }, - ]); - if (!error) { - return json({ - device_code, - user_code, - verification_uri: "https://www.tokentracker.cc/device", - verification_uri_complete: `https://www.tokentracker.cc/device?user_code=${encodeURIComponent(user_code)}`, - expires_in: 900, - interval: 5, - }); - } - lastError = error; - } - return json({ error: "could not allocate device code", detail: String(lastError) }, 503); -} diff --git a/dashboard/edge-patches/tokentracker-device-flow-grant.ts b/dashboard/edge-patches/tokentracker-device-flow-grant.ts deleted file mode 100644 index 217c7cac..00000000 --- a/dashboard/edge-patches/tokentracker-device-flow-grant.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * InsForge Edge: OAuth-style device flow — grant step. - * - * Called from the dashboard /device page after the user (already - * authenticated in their browser) types in the 8-char user_code shown by - * their CLI. We: - * 1. Verify the caller's JWT (must be signed with our project secret) - * 2. Look up the user_code → device_code row - * 3. Mark it approved with the caller's user_id - * - * The CLI's next poll then returns { status: "approved", user_id }. - * - * Auth: requires a valid Bearer JWT (the browser session). Anonymous - * callers are rejected. We verify locally — never via the InsForge - * gateway — for the same JWSError-cascade reasons that the leaderboard - * profile endpoint documents. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -function b64urlToBytes(s: string): Uint8Array { - const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const raw = atob(b64 + "=".repeat(pad)); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; -} - -async function verifyCallerUserId(req: Request): Promise { - const auth = req.headers.get("Authorization") ?? ""; - const token = auth.replace(/^Bearer\s+/i, "").trim(); - if (!token) return null; - const secret = Deno.env.get("JWT_SECRET"); - if (!secret) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); - const sig = b64urlToBytes(parts[2]); - const ok = await crypto.subtle.verify("HMAC", key, sig, data); - if (!ok) return null; - const payloadStr = new TextDecoder().decode(b64urlToBytes(parts[1])); - const payload = JSON.parse(payloadStr) as Record; - if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) return null; - const sub = payload.sub; - return typeof sub === "string" && sub.length > 0 ? sub : null; - } catch { - return null; - } -} - -function isValidUserCode(c: string): boolean { - // XXXX-XXXX from the alphabet "ABCDEFGHJKMNPQRSTUVWXYZ23456789" - return /^[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}$/.test(c); -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); - - const callerUserId = await verifyCallerUserId(req); - if (!callerUserId) return json({ error: "unauthorized" }, 401); - - let body: { user_code?: string } = {}; - try { body = await req.json(); } catch (_e) { /* */ } - const userCode = typeof body.user_code === "string" ? body.user_code.toUpperCase().trim() : ""; - if (!isValidUserCode(userCode)) return json({ error: "invalid user_code format" }, 400); - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL"); - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const anonKey = Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY"); - if (!baseUrl) return json({ error: "misconfigured" }, 500); - const dbToken = serviceRoleKey || anonKey; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - const { data, error: lookupErr } = await client.database - .from("tokentracker_device_codes") - .select("device_code, status, expires_at, client_info") - .eq("user_code", userCode) - .maybeSingle(); - if (lookupErr) return json({ error: "db error", detail: String(lookupErr?.message ?? lookupErr) }, 502); - if (!data) return json({ error: "user_code not found" }, 404); - - const row = data as { device_code: string; status: string; expires_at: string; client_info: string | null }; - if (Date.now() > new Date(row.expires_at).getTime()) { - return json({ error: "user_code expired", expired_at: row.expires_at }, 410); - } - if (row.status === "approved") { - return json({ status: "already_approved", client_info: row.client_info }); - } - - const { error: updateErr } = await client.database - .from("tokentracker_device_codes") - .update({ - status: "approved", - user_id: callerUserId, - approved_at: new Date().toISOString(), - }) - .eq("device_code", row.device_code); - if (updateErr) return json({ error: "db error", detail: String(updateErr?.message ?? updateErr) }, 502); - - return json({ status: "approved", client_info: row.client_info }); -} diff --git a/dashboard/edge-patches/tokentracker-device-flow-poll.ts b/dashboard/edge-patches/tokentracker-device-flow-poll.ts deleted file mode 100644 index c9d00eeb..00000000 --- a/dashboard/edge-patches/tokentracker-device-flow-poll.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * InsForge Edge: OAuth-style device flow — poll step. - * - * Called by the CLI at the cadence indicated by the authorize response - * (default 5s). Returns: - * - 200 { status: "pending" } – still waiting on the user - * - 200 { status: "approved", user_id, device_token, device_id } - * – user granted the code - * - 410 { status: "expired" } – the 15-minute window lapsed - * - 404 { status: "unknown" } – device_code is bogus - * - * Public endpoint — the device_code itself is the bearer credential. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -async function sha256Hex(input: string): Promise { - const data = new TextEncoder().encode(input); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -// deno-lint-ignore no-explicit-any -async function issueDeviceToken(client: any, userId: string, clientInfo: string | null) { - const platform = "cli-device-flow"; - const deviceName = `TokenTracker CLI${clientInfo ? ` (${clientInfo})` : ""}`.slice(0, 128); - const newDeviceId = crypto.randomUUID(); - const { data: insertedDevice } = await client.database - .from("tokentracker_devices") - .insert([{ id: newDeviceId, user_id: userId, device_name: deviceName, platform }], { - onConflict: "user_id,platform,device_name", - ignoreDuplicates: true, - }) - .select("id"); - - let deviceId: string; - if (Array.isArray(insertedDevice) && insertedDevice.length > 0) { - deviceId = (insertedDevice[0] as { id: string }).id; - } else { - const { data: winner, error: lookupErr } = await client.database - .from("tokentracker_devices") - .select("id") - .eq("user_id", userId) - .eq("platform", platform) - .eq("device_name", deviceName) - .is("revoked_at", null) - .order("created_at", { ascending: true }) - .limit(1) - .maybeSingle(); - if (lookupErr || !winner) { - throw new Error(lookupErr?.message || "device lookup failed"); - } - deviceId = (winner as { id: string }).id; - } - - const createdAt = new Date().toISOString(); - const { error: revokeErr } = await client.database - .from("tokentracker_device_tokens") - .update({ revoked_at: createdAt }) - .eq("device_id", deviceId) - .is("revoked_at", null); - if (revokeErr) throw new Error(revokeErr.message); - - const token = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, ""); - const { error: tokenErr } = await client.database.from("tokentracker_device_tokens").insert([ - { - id: crypto.randomUUID(), - device_id: deviceId, - user_id: userId, - token_hash: await sha256Hex(token), - }, - ]); - if (tokenErr) throw new Error(tokenErr.message); - - return { token, deviceId }; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); - - let body: { device_code?: string } = {}; - try { body = await req.json(); } catch (_e) { /* */ } - const deviceCode = typeof body.device_code === "string" ? body.device_code.trim() : ""; - if (!/^[0-9a-f]{64}$/.test(deviceCode)) return json({ status: "unknown" }, 404); - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL"); - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const anonKey = Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY"); - if (!baseUrl) return json({ error: "misconfigured" }, 500); - const dbToken = serviceRoleKey || anonKey; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - const { data, error } = await client.database - .from("tokentracker_device_codes") - .select("device_code, user_id, status, expires_at, approved_at, client_info") - .eq("device_code", deviceCode) - .maybeSingle(); - - if (error) return json({ error: "db error", detail: String(error?.message ?? error) }, 502); - if (!data) return json({ status: "unknown" }, 404); - - const row = data as { user_id: string | null; status: string; expires_at: string; client_info: string | null }; - const expiresAt = new Date(row.expires_at).getTime(); - if (Date.now() > expiresAt) { - // Best-effort cleanup. Scope the UPDATE to status='pending' so two - // concurrent CLI polls racing past the same expiry don't both write — - // PostgREST has no transactional read-modify-write here, but the - // predicate makes the second update a no-op. - await client.database - .from("tokentracker_device_codes") - .update({ status: "expired" }) - .eq("device_code", deviceCode) - .eq("status", "pending"); - return json({ status: "expired" }, 410); - } - - if (row.status === "approved" && row.user_id) { - try { - const issued = await issueDeviceToken(client, row.user_id, row.client_info); - return json({ - status: "approved", - user_id: row.user_id, - device_token: issued.token, - device_id: issued.deviceId, - }); - } catch (e) { - return json({ error: "Failed to issue device token", detail: String((e as Error)?.message ?? e) }, 500); - } - } - return json({ status: "pending" }); -} diff --git a/dashboard/edge-patches/tokentracker-device-token-issue.ts b/dashboard/edge-patches/tokentracker-device-token-issue.ts deleted file mode 100644 index ff8df8dd..00000000 --- a/dashboard/edge-patches/tokentracker-device-token-issue.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * InsForge Edge:为当前登录用户签发 device token(写入 tokentracker_devices / tokentracker_device_tokens)。 - * 与文档中 historical 名称 tokentracker-device-token-issue 不同:本项目云端 slug 为 tokentracker-device-token-issue。 - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-tokentracker-device-token-hash", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -function b64urlToBytes(s: string): Uint8Array { - const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const raw = atob(b64 + "=".repeat(pad)); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; -} - -/** - * Verify a HS256 JWT signature locally with JWT_SECRET and return its `sub`. - * - * Previously this function only decoded the payload without verifying the - * signature, which let any caller forge `{"sub":""}` and obtain a - * service-role-signed device token bound to that victim's account. The - * companion endpoint `tokentracker-leaderboard-profile.ts` already verifies - * signatures here for the same reason — InsForge does NOT validate JWTs at - * the gateway, so edge functions must do it themselves. - * - * Returns null on any failure (bad shape, bad signature, expired); the - * caller surfaces that as 401. - */ -async function verifiedUserIdFromJwt(token: string): Promise { - const secret = Deno.env.get("JWT_SECRET"); - if (!secret) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); - const sig = b64urlToBytes(parts[2]); - const ok = await crypto.subtle.verify("HMAC", key, sig, data); - if (!ok) return null; - const payloadStr = new TextDecoder().decode(b64urlToBytes(parts[1])); - const payload = JSON.parse(payloadStr) as Record; - if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) return null; - const sub = payload.sub; - if (typeof sub === "string" && sub.length > 0) return sub; - const uid = payload.user_id; - if (typeof uid === "string" && uid.length > 0) return uid; - } catch { - /* ignore */ - } - return null; -} - -function resolveUserIdForUserMode(bearer: string): Promise { - return verifiedUserIdFromJwt(bearer); -} - -async function sha256Hex(input: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(input); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - - const bearer = req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "") ?? ""; - if (!bearer) return json({ error: "Missing bearer token" }, 401); - - const body = await req.json().catch(() => ({})) as Record; - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const adminMode = Boolean(serviceRoleKey && bearer === serviceRoleKey); - - let userId: string | null = null; - let dbClient: ReturnType; - - if (adminMode) { - const fromBody = typeof body.user_id === "string" ? body.user_id : null; - const dataObj = body.data && typeof body.data === "object" ? (body.data as Record) : null; - const fromData = dataObj && typeof dataObj.user_id === "string" ? dataObj.user_id : null; - userId = fromBody || fromData; - if (!userId) return json({ error: "user_id is required (admin mode)" }, 400); - dbClient = createClient({ - baseUrl, - edgeFunctionToken: serviceRoleKey!, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - } else { - userId = await resolveUserIdForUserMode(bearer); - if (!userId) return json({ error: "Unauthorized" }, 401); - // 用 service role key 操作 DB:用户身份已通过 JWT 签名验证(HS256 + JWT_SECRET), - // 不再依赖用户的短期 access token(15 min 过期)做 DB 写入。 - const dbToken = serviceRoleKey || bearer; - dbClient = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - } - - const deviceName = String(body.device_name ?? (body.data as Record | undefined)?.device_name ?? "Token Tracker") - .slice(0, 128); - const platform = String(body.platform ?? (body.data as Record | undefined)?.platform ?? "web").slice( - 0, - 32, - ); - - // Reuse an existing active device for the same (user, platform, device_name) - // instead of minting a fresh one on every issue. Client localStorage is - // isolated across Safari / Chrome / WKWebView, so the client asks for a new - // token on every environment — if we created a fresh device_id each time, - // `tokentracker_hourly` ends up with the same logical bucket written under - // many device_ids, and leaderboard SUM would double-count. Keeping a single - // device_id per logical device means every sync upserts onto the same row. - // - // Concurrency: two parallel calls (tab + webview on first login) must not - // each INSERT a fresh row. The partial unique index - // `tokentracker_devices_active_unique` on (user_id, platform, device_name) - // WHERE revoked_at IS NULL guarantees one active row per logical device. - // We INSERT with ON CONFLICT DO NOTHING; if the insert loses the race it - // returns zero rows, and we SELECT to get the winner's id. - const newDeviceId = crypto.randomUUID(); - const { data: insertedDevice } = await dbClient.database - .from("tokentracker_devices") - .insert([{ id: newDeviceId, user_id: userId, device_name: deviceName, platform }], { - onConflict: "user_id,platform,device_name", - ignoreDuplicates: true, - }) - .select("id"); - - let deviceId: string; - if (Array.isArray(insertedDevice) && insertedDevice.length > 0) { - deviceId = (insertedDevice[0] as { id: string }).id; - } else { - const { data: winner, error: lookupErr } = await dbClient.database - .from("tokentracker_devices") - .select("id") - .eq("user_id", userId) - .eq("platform", platform) - .eq("device_name", deviceName) - .is("revoked_at", null) - .order("created_at", { ascending: true }) - .limit(1) - .maybeSingle(); - if (lookupErr || !winner) { - return json( - { error: "Failed to issue device token", detail: lookupErr?.message || "device lookup failed" }, - 500, - ); - } - deviceId = (winner as { id: string }).id; - } - - const tokenId = crypto.randomUUID(); - const token = - crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, ""); - const tokenHash = await sha256Hex(token); - const createdAt = new Date().toISOString(); - - // NOTE: previous revision revoked all alive tokens on this device before - // inserting the new one ("rotate-on-issue"). That coupled with the dashboard - // re-minting on every WKWebView reload / module re-eval, killing the CLI's - // long-lived token on roughly every dashboard launch and stalling uploads - // for ~65% of recently-active users. Explicit rotation belongs in a - // separate "sign out devices" endpoint, not in the implicit issue path. - const { error: tokenErr } = await dbClient.database.from("tokentracker_device_tokens").insert([ - { - id: tokenId, - device_id: deviceId, - user_id: userId, - token_hash: tokenHash, - }, - ]); - - if (tokenErr) { - return json({ error: "Failed to issue device token", detail: tokenErr.message }, 500); - } - - return json({ token, device_id: deviceId, created_at: createdAt }); -} diff --git a/dashboard/edge-patches/tokentracker-embed-svg.ts b/dashboard/edge-patches/tokentracker-embed-svg.ts deleted file mode 100644 index 1f277b2e..00000000 --- a/dashboard/edge-patches/tokentracker-embed-svg.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * InsForge Edge: full profile embed SVG. - * - * Returns a 600×180 profile card (dark/light themes) suitable for embedding - * in tweets, Notion pages, GitHub READMEs, personal sites — anywhere an SVG - * tag works. Self-contained: no external font dependencies, no fetched - * avatar (we render a colored initial circle from display_name). - * - * Usage: - * - * - * Query params: - * user_id (required) UUID - * theme light | dark (default: light) - * period week | month | total (default: total) - * - * Privacy: gates on `is_public=true` (the same opt-in that the leaderboard - * profile endpoint enforces). Private profiles → "private" placeholder SVG. - * - * Caching: 60s ISR via Cache-Control. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", -}; - -const BRAND_GREEN = "#059669"; -const PALETTE = { - light: { - bg: "#ffffff", - cardBorder: "#e5e7eb", - fg: "#0f172a", - muted: "#64748b", - accent: BRAND_GREEN, - chip: "#f1f5f9", - }, - dark: { - bg: "#0f172a", - cardBorder: "#1f2937", - fg: "#f8fafc", - muted: "#94a3b8", - accent: "#34d399", - chip: "#1e293b", - }, -}; - -function svgResponse(svg: string, status = 200, cacheSeconds = 60): Response { - return new Response(svg, { - status, - headers: { - ...corsHeaders, - "Content-Type": "image/svg+xml; charset=utf-8", - "Cache-Control": `public, max-age=${cacheSeconds}, s-maxage=${cacheSeconds}`, - }, - }); -} - -function escapeXml(s: string): string { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function isUuid(s: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); -} - -function compactNumber(n: number): string { - if (!isFinite(n)) return "0"; - const abs = Math.abs(n); - if (abs >= 1e12) return (n / 1e12).toFixed(2).replace(/\.?0+$/, "") + "T"; - if (abs >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, "") + "B"; - if (abs >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, "") + "M"; - if (abs >= 1e3) return (n / 1e3).toFixed(2).replace(/\.?0+$/, "") + "K"; - return String(Math.round(n)); -} - -function formatCost(n: number): string { - if (n >= 1000) return "$" + Math.round(n).toLocaleString("en-US"); - if (n >= 100) return "$" + n.toFixed(0); - return "$" + n.toFixed(2); -} - -// Deterministic hue from a display string so the same user always gets the -// same avatar tint. Saturation/lightness fixed to keep contrast acceptable -// on both light and dark themes. -function hashHue(s: string): number { - let h = 0; - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; - return h % 360; -} - -function avatarSvg(displayName: string, theme: "light" | "dark"): string { - const seed = (displayName || "anon").trim() || "anon"; - const hue = hashHue(seed); - const initial = seed.charAt(0).toUpperCase(); - const fg = theme === "dark" ? "#0f172a" : "#ffffff"; - // Light theme: saturated mid-tone; dark theme: lighter so it pops on dark bg. - const fill = theme === "dark" - ? `hsl(${hue} 70% 65%)` - : `hsl(${hue} 65% 45%)`; - return ` - - ${escapeXml(initial)} - `; -} - -function placeholderSvg(message: string, theme: "light" | "dark"): string { - const p = PALETTE[theme]; - return ` - - ${escapeXml(message)} - `; -} - -interface SnapshotRow { - user_id: string; - display_name: string | null; - rank: number | null; - total_tokens: number | null; - estimated_cost_usd: number | null; - gpt_tokens: number | null; - claude_tokens: number | null; - gemini_tokens: number | null; - cursor_tokens: number | null; - hermes_tokens: number | null; - copilot_tokens: number | null; - is_public: boolean | null; - generated_at: string; -} - -// deno-lint-ignore no-explicit-any -async function windowBounds(client: any, period: string): Promise<{ from_day: string; to_day: string }> { - const now = new Date(); - if (period === "week") { - const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7)) /* ISO Mon-start, matches leaderboard-refresh+dashboard */; - const from_day = d.toISOString().slice(0, 10); - d.setUTCDate(d.getUTCDate() + 6); - return { from_day, to_day: d.toISOString().slice(0, 10) }; - } - if (period === "month") { - const from_day = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-01`; - const to_day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)) - .toISOString() - .slice(0, 10); - return { from_day, to_day }; - } - const { data: latest } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select("from_day, to_day") - .eq("period", "total") - .order("to_day", { ascending: false }) - .limit(1) - .maybeSingle(); - const row = latest as { from_day?: string; to_day?: string } | null; - return { - from_day: (row?.from_day ?? "2024-01-01").slice(0, 10), - to_day: (row?.to_day ?? now.toISOString()).slice(0, 10), - }; -} - -function renderProfileCard(opts: { - row: SnapshotRow; - theme: "light" | "dark"; - period: string; -}): string { - const { row, theme, period } = opts; - const p = PALETTE[theme]; - const displayName = row.display_name || "Anonymous"; - const tokens = compactNumber(Number(row.total_tokens ?? 0)); - const cost = formatCost(Number(row.estimated_cost_usd ?? 0)); - const rank = row.rank ? `#${row.rank}` : "—"; - - // Mini provider breakdown (top 3 by tokens). Compact bar so the card - // doesn't feel empty. - const providers = [ - { label: "Claude", val: Number(row.claude_tokens ?? 0) }, - { label: "Codex", val: Number(row.gpt_tokens ?? 0) }, - { label: "Gemini", val: Number(row.gemini_tokens ?? 0) }, - { label: "Cursor", val: Number(row.cursor_tokens ?? 0) }, - { label: "Copilot", val: Number(row.copilot_tokens ?? 0) }, - { label: "Hermes", val: Number(row.hermes_tokens ?? 0) }, - ]; - const total = providers.reduce((s, x) => s + x.val, 0); - const top3 = providers - .filter((x) => x.val > 0) - .sort((a, b) => b.val - a.val) - .slice(0, 3); - - let breakdownX = 460; - const breakdownY = 100; - const breakdownLines = top3.length - ? top3 - .map((bp, i) => { - const pct = total > 0 ? ((bp.val / total) * 100).toFixed(0) : "0"; - // Stack vertically along the right edge so the line never crosses - // the TOKENS/COST/RANK chips on the left. - const y = breakdownY + i * 18; - return `${escapeXml(bp.label)} ${pct}%`; - }) - .join("\n ") - : `No provider data yet`; - - const periodLabel = period.charAt(0).toUpperCase() + period.slice(1); - - return ` - ${escapeXml(displayName)} — ${tokens} tokens, ${cost}, rank ${rank} - - ${avatarSvg(displayName, theme)} - - ${escapeXml(displayName)} - TokenTracker · ${escapeXml(periodLabel)} - - - - TOKENS - ${escapeXml(tokens)} - - - - COST - ${escapeXml(cost)} - - - - RANK - ${escapeXml(rank)} - - - TOP PROVIDERS - - www.tokentracker.cc - TT - - - - ${breakdownLines} - -`; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "GET") return svgResponse(placeholderSvg("method not allowed", "light"), 405, 0); - - const url = new URL(req.url); - const userId = (url.searchParams.get("user_id") || "").trim().toLowerCase(); - const theme = (url.searchParams.get("theme") || "light").toLowerCase() === "dark" ? "dark" : "light"; - const period = (url.searchParams.get("period") || "total").toLowerCase(); - - if (!userId || !isUuid(userId)) { - return svgResponse(placeholderSvg("bad user_id", theme), 400, 0); - } - if (!["week", "month", "total"].includes(period)) { - return svgResponse(placeholderSvg("bad period", theme), 400, 0); - } - - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const anonKey = Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY"); - const baseUrl = Deno.env.get("INSFORGE_BASE_URL"); - if (!baseUrl) return svgResponse(placeholderSvg("misconfigured", theme), 500, 0); - - const client = createClient({ - baseUrl, - edgeFunctionToken: serviceRoleKey, - anonKey: anonKey ?? undefined, - isServerMode: true, - }); - - let from_day: string; - let to_day: string; - try { - const bounds = await windowBounds(client, period); - from_day = bounds.from_day; - to_day = bounds.to_day; - } catch { - return svgResponse(placeholderSvg("lookup failed", theme), 502, 0); - } - - const { data, error } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select( - "user_id, display_name, rank, total_tokens, estimated_cost_usd, gpt_tokens, claude_tokens, gemini_tokens, cursor_tokens, hermes_tokens, copilot_tokens, is_public, generated_at", - ) - .eq("user_id", userId) - .eq("period", period) - .eq("from_day", from_day) - .eq("to_day", to_day) - .order("generated_at", { ascending: false }) - .limit(1) - .maybeSingle(); - - if (error) return svgResponse(placeholderSvg("db error", theme), 502, 0); - const row = data as SnapshotRow | null; - if (!row) return svgResponse(placeholderSvg("not found", theme), 404, 0); - if (row.is_public === false) return svgResponse(placeholderSvg("profile is private", theme), 403, 0); - - const svg = renderProfileCard({ row, theme, period }); - return svgResponse(svg, 200, 60); -} diff --git a/dashboard/edge-patches/tokentracker-ingest.ts b/dashboard/edge-patches/tokentracker-ingest.ts deleted file mode 100644 index d43e4839..00000000 --- a/dashboard/edge-patches/tokentracker-ingest.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * InsForge Edge:接收本地 CLI 上传的用量数据,写入 tokentracker_hourly。 - * 用 device token(SHA-256 hash)验证身份,用 service role key 写 DB。 - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": - "Content-Type, Authorization, apikey, x-tokentracker-device-token-hash", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -async function sha256Hex(input: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(input); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") - return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); - - const authHeader = req.headers.get("Authorization"); - const deviceToken = authHeader?.replace(/^Bearer\s+/i, ""); - if (!deviceToken) return json({ error: "Missing bearer token" }, 401); - - const body = (await req.json().catch(() => null)) as Record | null; - if (!body) return json({ error: "Invalid JSON body" }, 400); - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - // 优先用 service role key;未配置时用 anon key(RLS 未启用时可用) - const dbToken = serviceRoleKey || anonKey || deviceToken; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - const tokenHash = await sha256Hex(deviceToken); - - const { data: tokenRow, error: tokenErr } = await client.database - .from("tokentracker_device_tokens") - .select("user_id, device_id") - .eq("token_hash", tokenHash) - .is("revoked_at", null) - .maybeSingle(); - - if (tokenErr) return json({ error: tokenErr.message }, 500); - if (!tokenRow) return json({ error: "Unauthorized" }, 401); - - const userId = (tokenRow as { user_id: string }).user_id; - const deviceId = (tokenRow as { device_id: string }).device_id; - - const buckets = Array.isArray(body.buckets) - ? body.buckets - : Array.isArray(body.hourly) - ? body.hourly - : []; - if (!Array.isArray(buckets) || buckets.length === 0) { - return json({ error: "No usage buckets provided" }, 400); - } - if (buckets.length > 500) { - return json({ error: "Too many buckets (max 500)" }, 400); - } - - const mappedRows = buckets.map((b: Record) => ({ - user_id: userId, - device_id: deviceId, - hour_start: b.hour_start, - source: b.source || "unknown", - model: b.model || "unknown", - input_tokens: b.input_tokens || 0, - cached_input_tokens: b.cached_input_tokens || 0, - cache_creation_input_tokens: b.cache_creation_input_tokens || 0, - output_tokens: b.output_tokens || 0, - reasoning_output_tokens: b.reasoning_output_tokens || 0, - total_tokens: b.total_tokens || 0, - billable_total_tokens: b.billable_total_tokens || 0, - total_cost_usd: Number(b.total_cost_usd) || 0, - conversations: b.conversations || 0, - })); - - // Dedupe within the batch by (hour_start, source, model), keeping the row - // with the largest total_tokens. The CLI's queue.jsonl is append-only and - // re-emits the same logical bucket multiple times as a session fills out - // (each emission carries the cumulative running total, so MAX wins). Two - // rows sharing the conflict key in one upsert make Postgres throw - // "ON CONFLICT DO UPDATE command cannot affect row a second time" and - // reject the entire batch — which stalled all clients until this dedupe. - const dedupedMap = new Map(); - for (const r of mappedRows) { - const key = `${r.hour_start}|${r.source}|${r.model}`; - const prev = dedupedMap.get(key); - if (!prev || (Number(r.total_tokens) || 0) > (Number(prev.total_tokens) || 0)) { - dedupedMap.set(key, r); - } - } - const rows = Array.from(dedupedMap.values()); - - const { error: upsertErr } = await client.database - .from("tokentracker_hourly") - .upsert(rows, { - onConflict: "user_id,device_id,hour_start,source,model", - }); - - if (upsertErr) return json({ error: upsertErr.message }, 500); - - return json({ ok: true, inserted: rows.length, skipped: 0 }); -} diff --git a/dashboard/edge-patches/tokentracker-leaderboard-profile.ts b/dashboard/edge-patches/tokentracker-leaderboard-profile.ts deleted file mode 100644 index dc690dba..00000000 --- a/dashboard/edge-patches/tokentracker-leaderboard-profile.ts +++ /dev/null @@ -1,615 +0,0 @@ -/** - * Tokentracker leaderboard profile (DETAIL). - * - * Aggregates a single user's hourly rows into a rich profile payload used by - * the in-page profile modal: hero totals, streak, best day, model favorites, - * per-provider breakdown, 365-day activity heatmap and a period-scoped daily - * trend. - * - * Previous implementation returned the existing snapshot row. The modal - * needs time-series data the snapshot table doesn't carry, so we scan - * `tokentracker_hourly` directly (single-user scan — small enough to walk - * in one request, much smaller than the cross-user refresh job). - * - * Pricing tables are inline-mirrored from `tokentracker-leaderboard-refresh.ts` - * to keep `getModelPricing` / `computeRowCost` byte-for-byte aligned across - * cloud aggregators. Memory: feedback_model_pricing_sync — every edit here - * MUST be mirrored to refresh.ts and src/lib/local-api.js. - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": - "Content-Type, Authorization, apikey, x-tokentracker-device-token-hash", -}; -const BLOCKED_LEADERBOARD_USER_IDS = new Set( - (Deno.env.get("LEADERBOARD_BLOCKED_USER_IDS") ?? "") - .split(",") - .map((id) => id.trim()) - .filter(Boolean), -); - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -function b64urlToBytes(s: string): Uint8Array { - const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const raw = atob(b64 + "=".repeat(pad)); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; -} - -async function verifyCallerUserId(req: Request): Promise { - const auth = req.headers.get("Authorization") ?? ""; - const token = auth.replace(/^Bearer\s+/i, "").trim(); - if (!token) return null; - const secret = Deno.env.get("JWT_SECRET"); - if (!secret) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); - const sig = b64urlToBytes(parts[2]); - const ok = await crypto.subtle.verify("HMAC", key, sig, data); - if (!ok) return null; - const payloadStr = new TextDecoder().decode(b64urlToBytes(parts[1])); - const payload = JSON.parse(payloadStr) as Record; - if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) return null; - const sub = payload.sub; - return typeof sub === "string" && sub.length > 0 ? sub : null; - } catch { - return null; - } -} - -function getClient() { - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const anonKey = Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY"); - return createClient({ - baseUrl: Deno.env.get("INSFORGE_BASE_URL")!, - edgeFunctionToken: serviceRoleKey, - anonKey: anonKey ?? undefined, - isServerMode: true, - }); -} - -// ─────────────────────────── Pricing (mirror from refresh.ts) ────────────────────────── -const MODEL_PRICING: Record = { - "claude-fable-5": { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, - "claude-mythos-5": { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, - "claude-opus-4-6": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, - "claude-opus-4-5-20250414": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, - "claude-sonnet-5": { input: 2, output: 10, cache_read: 0.2, cache_write: 2.5 }, - "claude-sonnet-4-6": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-sonnet-4-5-20250514": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-sonnet-4-20250514": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-haiku-4-5-20251001": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, - "claude-3-5-sonnet-20241022": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-3-5-haiku-20241022": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, - "gpt-5": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-codex": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-codex-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-mini": { input: 0.25, output: 2, cache_read: 0.025 }, - "gpt-5.1-codex-max": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-xhigh-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.2": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-high-fast": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-codex": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-codex-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.3-codex": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.3-codex-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.4": { input: 2.5, output: 15, cache_read: 0.25 }, - "gpt-5.4-mini": { input: 0.75, output: 4.5, cache_read: 0.075 }, - "gpt-5.4-medium": { input: 1.5, output: 10, cache_read: 0.15 }, - "gpt-5.5": { input: 5, output: 30, cache_read: 0.5 }, - "gpt-5-mini": { input: 0.25, output: 2, cache_read: 0.025 }, - "o3": { input: 2, output: 8, cache_read: 0.5 }, - "gemini-2.5-pro": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-pro-preview-06-05": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-pro-preview-05-06": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-flash": { input: 0.3, output: 2.5, cache_read: 0.03 }, - "gemini-3-flash-preview": { input: 0.5, output: 3, cache_read: 0.05 }, - "gemini-3-pro-preview": { input: 2, output: 12, cache_read: 0.2 }, - "gemini-3.1-pro-preview": { input: 2, output: 12, cache_read: 0.2 }, - "composer-1": { input: 1.25, output: 10, cache_read: 0.125 }, - "composer-1.5": { input: 3.5, output: 17.5, cache_read: 0.35 }, - "composer-2": { input: 0.5, output: 2.5, cache_read: 0.2 }, - "composer-2-fast": { input: 1.5, output: 7.5, cache_read: 0.15 }, - "kimi-for-coding": { input: 0.6, output: 2, cache_read: 0.15 }, - "kimi-k2.5": { input: 0.6, output: 2, cache_read: 0.15 }, - "kimi-k2.5-free": { input: 0, output: 0, cache_read: 0 }, - "kimi-k2.6": { input: 0.95, output: 4, cache_read: 0.16 }, - "glm-5.1": { input: 1.4, output: 4.4, cache_read: 0.26 }, - "glm-5": { input: 1.0, output: 3.2, cache_read: 0.2 }, - "glm-5-turbo": { input: 1.2, output: 4.0, cache_read: 0.24 }, - "glm-4.7": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.7-flashx": { input: 0.07, output: 0.4, cache_read: 0.01 }, - "glm-4.7-flash": { input: 0, output: 0, cache_read: 0 }, - "glm-4.6": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.5": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.5-x": { input: 2.2, output: 8.9, cache_read: 0.45 }, - "glm-4.5-air": { input: 0.2, output: 1.1, cache_read: 0.03 }, - "glm-4.5-airx": { input: 1.1, output: 4.5, cache_read: 0.22 }, - "glm-4.5-flash": { input: 0, output: 0, cache_read: 0 }, - "MiniMax-M2.7": { input: 0.3, output: 1.2, cache_read: 0.06, cache_write: 0.375 }, - "MiniMax-M2.7-highspeed": { input: 0.6, output: 2.4, cache_read: 0.06, cache_write: 0.375 }, - "deepseek-v4-flash": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "deepseek-v4-pro": { input: 0.435, output: 0.87, cache_read: 0.003625, cache_write: 0.435 }, - "deepseek-chat": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "deepseek-reasoner": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "grok-build": { input: 1.25, output: 2.50, cache_read: 0.20 }, - "grok-4-0709": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4-latest": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4-fast": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-fast-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-fast-non-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-1-fast-non-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "kiro-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "kiro-cli-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "hy3-preview-agent": { input: 0, output: 0, cache_read: 0 }, - "hy3-preview": { input: 0, output: 0, cache_read: 0 }, - "glm-4.7-free": { input: 0, output: 0, cache_read: 0 }, - "nemotron-3-super-free": { input: 0, output: 0, cache_read: 0 }, - "mimo-v2-pro-free": { input: 0, output: 0, cache_read: 0 }, - "minimax-m2.1-free": { input: 0, output: 0, cache_read: 0 }, - "MiniMax-M2.1": { input: 0.5, output: 3, cache_read: 0.05 }, - // ── Xiaomi MiMo (mirrored from src/lib/pricing/seed-snapshot.json LiteLLM - // entries openrouter/xiaomi/mimo-*; queue rows report the bare names. - // Kept in lockstep with the matcher's litellm:prefix-strip resolution — - // cache_read for mimo-v2-flash uses novita's 0.02 (the lexicographically - // smallest provider key the matcher deterministically picks). ── - "mimo-v2.5-pro": { input: 1, output: 3, cache_read: 0.2 }, - "mimo-v2.5": { input: 0.4, output: 2, cache_read: 0.08 }, - "mimo-v2-flash": { input: 0.1, output: 0.3, cache_read: 0.02 }, -}; -const ZERO_PRICING = { input: 0, output: 0, cache_read: 0, cache_write: 0 }; - -function getModelPricing(model: string) { - if (!model) return ZERO_PRICING; - const exact = MODEL_PRICING[model]; - if (exact) return exact; - const lower = model.toLowerCase(); - if (lower.includes("fable")) return MODEL_PRICING["claude-fable-5"]; - if (lower.includes("mythos-5")) return MODEL_PRICING["claude-mythos-5"]; - if (lower.includes("opus")) return MODEL_PRICING["claude-opus-4-6"]; - if (lower.includes("haiku")) return MODEL_PRICING["claude-haiku-4-5-20251001"]; - if (lower.includes("sonnet")) return MODEL_PRICING["claude-sonnet-4-6"]; - if (lower.includes("gpt-5.4")) return MODEL_PRICING["gpt-5.4"]; - if (lower.includes("gpt-5.5")) return MODEL_PRICING["gpt-5.5"]; - if (lower.includes("gpt-5-mini")) return MODEL_PRICING["gpt-5-mini"]; - if (lower.includes("gpt-5.3")) return MODEL_PRICING["gpt-5.3-codex"]; - if (lower.includes("gpt-5.2")) return MODEL_PRICING["gpt-5.2"]; - if (lower.includes("gpt-5.1")) return MODEL_PRICING["gpt-5.1-codex"]; - if (lower.includes("gpt-5")) return MODEL_PRICING["gpt-5"]; - if (lower.includes("gemini-3")) return MODEL_PRICING["gemini-3-flash-preview"]; - if (lower.includes("gemini-2.5")) return MODEL_PRICING["gemini-2.5-pro"]; - if (lower.includes("minimax-m2.7-highspeed")) return MODEL_PRICING["MiniMax-M2.7-highspeed"]; - if (lower.includes("minimax-m2.7")) return MODEL_PRICING["MiniMax-M2.7"]; - if (lower.includes("deepseek-v4-flash")) return MODEL_PRICING["deepseek-v4-flash"]; - if (lower.includes("deepseek-v4-pro")) return MODEL_PRICING["deepseek-v4-pro"]; - if (lower.includes("deepseek-reasoner")) return MODEL_PRICING["deepseek-reasoner"]; - if (lower.includes("deepseek-chat")) return MODEL_PRICING["deepseek-chat"]; - if (lower.includes("grok-build")) return MODEL_PRICING["grok-build"]; - if (lower.includes("grok-4-fast")) return MODEL_PRICING["grok-4-fast"]; - if (lower.includes("grok-4-1-fast")) return MODEL_PRICING["grok-4-1-fast-non-reasoning"]; - if (lower.includes("grok-4")) return MODEL_PRICING["grok-4"]; - if (lower.includes("kimi-k2.6")) return MODEL_PRICING["kimi-k2.6"]; - if (lower.includes("kimi")) return MODEL_PRICING["kimi-k2.5"]; - // MiMo ordering: more specific suffixes first (mimo-v2.5-pro before - // mimo-v2.5 which is a substring; the free tier is a distinct name). - if (lower.includes("mimo-v2-pro-free")) return MODEL_PRICING["mimo-v2-pro-free"]; - if (lower.includes("mimo-v2.5-pro")) return MODEL_PRICING["mimo-v2.5-pro"]; - if (lower.includes("mimo-v2.5")) return MODEL_PRICING["mimo-v2.5"]; - if (lower.includes("mimo-v2-flash")) return MODEL_PRICING["mimo-v2-flash"]; - if (lower.includes("glm-4.5-airx")) return MODEL_PRICING["glm-4.5-airx"]; - if (lower.includes("glm-4.5-air")) return MODEL_PRICING["glm-4.5-air"]; - if (lower.includes("glm-4.5-x")) return MODEL_PRICING["glm-4.5-x"]; - if (lower.includes("glm-4.5-flash")) return MODEL_PRICING["glm-4.5-flash"]; - if (lower.includes("glm-4.5")) return MODEL_PRICING["glm-4.5"]; - if (lower.includes("glm-4.7-flashx")) return MODEL_PRICING["glm-4.7-flashx"]; - if (lower.includes("glm-4.7-flash")) return MODEL_PRICING["glm-4.7-flash"]; - if (lower.includes("glm-4.7")) return MODEL_PRICING["glm-4.7"]; - if (lower.includes("glm-4.6")) return MODEL_PRICING["glm-4.6"]; - if (lower.includes("glm-5-turbo")) return MODEL_PRICING["glm-5-turbo"]; - if (lower.includes("glm-5.1")) return MODEL_PRICING["glm-5.1"]; - if (lower.includes("glm-5")) return MODEL_PRICING["glm-5"]; - if (lower.includes("kiro")) return MODEL_PRICING["kiro-cli-agent"]; - if (lower.includes("hy3")) return MODEL_PRICING["hy3-preview-agent"]; - if (lower.includes("composer")) return MODEL_PRICING["composer-1"]; - if (lower === "auto") return MODEL_PRICING["composer-1"]; - return ZERO_PRICING; -} - -interface HourlyRow { - source: string; - model: string; - hour_start: string; - total_tokens: number | null; - input_tokens: number | null; - output_tokens: number | null; - cached_input_tokens: number | null; - cache_creation_input_tokens: number | null; - reasoning_output_tokens: number | null; -} - -function computeRowCost(row: HourlyRow): number { - const p = getModelPricing(row.model); - const reasoningIncludedInOutput = row.source === "codex" || row.source === "every-code"; - const reasoningCost = reasoningIncludedInOutput - ? 0 - : (row.reasoning_output_tokens || 0) * (p.output || 0); - return ( - ((row.input_tokens || 0) * (p.input || 0) + - (row.output_tokens || 0) * (p.output || 0) + - (row.cached_input_tokens || 0) * (p.cache_read || 0) + - (row.cache_creation_input_tokens || 0) * (p.cache_write || 0) + - reasoningCost) / - 1_000_000 - ); -} - -/** Map raw `source` to the canonical bucket used by the modal's by_provider list. */ -const KNOWN_SOURCES = new Set([ - "codex", "claude", "gemini", "cursor", "opencode", "openclaw", - "hermes", "kiro", "copilot", "kimi", "droid", -]); -function canonicalSource(s: string) { - return KNOWN_SOURCES.has(s) ? s : "other"; -} - -// ─────────────────────────── Window bounds ────────────────────────── -function windowBoundsForPeriod(period: string): { from_day: string; to_day: string } { - const now = new Date(); - if (period === "week") { - const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7)) /* ISO Mon-start, matches leaderboard-refresh+dashboard */; - const from = d.toISOString().slice(0, 10); - d.setUTCDate(d.getUTCDate() + 6); - return { from_day: from, to_day: d.toISOString().slice(0, 10) }; - } - if (period === "month") { - const from = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-01`; - const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)) - .toISOString() - .slice(0, 10); - return { from_day: from, to_day: to }; - } - // total: use heatmap range (365 days) — caller uses this same bound for trend - const heatmapStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - heatmapStart.setUTCDate(heatmapStart.getUTCDate() - 364); - return { - from_day: heatmapStart.toISOString().slice(0, 10), - to_day: now.toISOString().slice(0, 10), - }; -} - -/** Compute current & longest consecutive-day streaks within a date set. */ -function computeStreak(daysWithActivity: Set): { current_days: number; longest_days: number } { - const today = new Date(); - const todayUTC = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); - // Current: start from today; if today has nothing, allow yesterday as starting point. - let cursor = new Date(todayUTC); - let current = 0; - if (!daysWithActivity.has(cursor.toISOString().slice(0, 10))) { - cursor.setUTCDate(cursor.getUTCDate() - 1); - } - while (daysWithActivity.has(cursor.toISOString().slice(0, 10))) { - current += 1; - cursor.setUTCDate(cursor.getUTCDate() - 1); - } - // Longest: walk all days chronologically. - const sorted = Array.from(daysWithActivity).sort(); - let longest = 0; - let run = 0; - let prev: Date | null = null; - for (const d of sorted) { - const dt = new Date(d + "T00:00:00Z"); - if (prev && dt.getTime() - prev.getTime() === 86_400_000) { - run += 1; - } else { - run = 1; - } - if (run > longest) longest = run; - prev = dt; - } - return { current_days: current, longest_days: longest }; -} - -// ─────────────────────────── Main handler ────────────────────────── -// deno-lint-ignore no-explicit-any -async function scanHourlyForUser(client: any, userId: string, rangeStartIso: string, rangeEndIso: string) { - // Dedup by (source, model, hour_start) keeping MAX total_tokens — same - // multi-device strategy as refresh.ts. Within one user_id this matters when - // cumulative queue replay double-records the same hour under multiple - // device sessions. SUM would double-count; MAX picks the canonical row. - const bucketMap = new Map(); - let offset = 0; - const PAGE_SIZE = 1000; - while (true) { - const { data: rows, error } = await client.database - .from("tokentracker_hourly") - .select("source, model, hour_start, total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, reasoning_output_tokens") - .eq("user_id", userId) - .gte("hour_start", rangeStartIso) - .lt("hour_start", rangeEndIso) - .order("hour_start", { ascending: true }) - .range(offset, offset + PAGE_SIZE - 1); - if (error) throw new Error(error.message); - if (!rows || rows.length === 0) break; - for (const row of rows as HourlyRow[]) { - const key = `${row.source}|${row.model}|${row.hour_start}`; - const existing = bucketMap.get(key); - const incoming = Number(row.total_tokens) || 0; - if (!existing || incoming > (Number(existing.total_tokens) || 0)) { - bucketMap.set(key, row); - } - } - if (rows.length < PAGE_SIZE) break; - offset += PAGE_SIZE; - } - return bucketMap; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "GET") return json({ error: "Method not allowed" }, 405); - - const url = new URL(req.url); - const userId = url.searchParams.get("user_id"); - const periodInput = url.searchParams.get("period") || "week"; - const period = ["week", "month", "total"].includes(periodInput) ? periodInput : "week"; - if (!userId) return json({ error: "user_id is required" }, 400); - if (BLOCKED_LEADERBOARD_USER_IDS.has(userId)) return json({ error: "Not found" }, 404); - - const callerUserId = await verifyCallerUserId(req); - const isSelf = Boolean(callerUserId && callerUserId === userId); - const client = getClient(); - - // Privacy gate. Mirror the leaderboard list's exposure policy: if the user - // already appears in the public snapshot table, their aggregate numbers are - // visible to anyone scrolling the leaderboard — so the modal should not - // gate them behind `leaderboard_public`. The `leaderboard_anonymous` flag - // is enforced separately below by hiding display_name/avatar/github_url. - // (A `leaderboard_public=true` check would 404 ~60% of listed rows here.) - if (!isSelf) { - const { data: snap } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select("user_id") - .eq("user_id", userId) - .limit(1) - .maybeSingle(); - if (!snap) { - return json({ error: "Not found" }, 404); - } - } - - // Hero/identity row: pull display_name + avatar from profile, and - // anonymous/github flags + url from settings. - const [settingsRes, profileRes] = await Promise.all([ - client.database - .from("tokentracker_user_settings") - .select("leaderboard_anonymous, github_url, show_github_url") - .eq("user_id", userId) - .maybeSingle(), - client.database - .from("tokentracker_user_profiles") - .select("display_name, avatar_url") - .eq("user_id", userId) - .maybeSingle(), - ]); - const settings = (settingsRes.data || {}) as { - leaderboard_anonymous?: boolean; - github_url?: string | null; - show_github_url?: boolean; - }; - const profile = (profileRes.data || {}) as { - display_name?: string | null; - avatar_url?: string | null; - }; - - // Rank: read from the period snapshot (the refresh job already computes it). - const periodBounds = windowBoundsForPeriod(period); - const { data: snapRow } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select("rank, display_name, avatar_url, generated_at") - .eq("user_id", userId) - .eq("period", period) - .order("generated_at", { ascending: false }) - .limit(1) - .maybeSingle(); - const snap = (snapRow || null) as { rank?: number | null; display_name?: string | null; avatar_url?: string | null; generated_at?: string | null } | null; - - // Heatmap window: trailing 365 days (always). - const now = new Date(); - const heatmapEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)); - const heatmapStart = new Date(heatmapEnd); - heatmapStart.setUTCDate(heatmapStart.getUTCDate() - 365); - // Period range may be wider than 365d ("total" maps to 365d here); we scan - // the heatmap window and slice the period window from the same map. - const periodStartIso = `${periodBounds.from_day}T00:00:00Z`; - const periodEndIso = (() => { - const d = new Date(periodBounds.to_day + "T00:00:00Z"); - d.setUTCDate(d.getUTCDate() + 1); - return d.toISOString(); - })(); - const scanStartIso = heatmapStart < new Date(periodStartIso) - ? heatmapStart.toISOString() - : periodStartIso; - const scanEndIso = heatmapEnd > new Date(periodEndIso) - ? heatmapEnd.toISOString() - : periodEndIso; - - let bucketMap: Map; - try { - bucketMap = await scanHourlyForUser(client, userId, scanStartIso, scanEndIso); - } catch (e) { - return json({ error: (e as Error).message || "scan failed" }, 500); - } - - // ── Aggregate into the modal's shape. Two parallel passes: - // - period-scoped: totals/by_provider/best_day/favorite_model/active_days/streak/daily_trend - // - 365d heatmap: per-day token totals - const periodFrom = new Date(periodStartIso); - const periodTo = new Date(periodEndIso); - - const heatmapByDay = new Map(); - // Per-day model breakdown for the hover tooltip. Same shape the dashboard - // ActivityHeatmap consumes (cell.models = { [model_name]: tokens }). - const heatmapModelsByDay = new Map>(); - const periodByDay = new Map(); - const periodByDayCost = new Map(); - const periodByProvider = new Map(); - const periodByModel = new Map(); - let periodTotalTokens = 0; - let periodTotalCost = 0; - - for (const row of bucketMap.values()) { - const hourMs = new Date(row.hour_start).getTime(); - const day = row.hour_start.slice(0, 10); - const tokens = Number(row.total_tokens) || 0; - const cost = computeRowCost(row); - if (hourMs >= heatmapStart.getTime() && hourMs < heatmapEnd.getTime()) { - heatmapByDay.set(day, (heatmapByDay.get(day) || 0) + tokens); - if (row.model && tokens > 0) { - let dayModels = heatmapModelsByDay.get(day); - if (!dayModels) { - dayModels = new Map(); - heatmapModelsByDay.set(day, dayModels); - } - dayModels.set(row.model, (dayModels.get(row.model) || 0) + tokens); - } - } - if (hourMs >= periodFrom.getTime() && hourMs < periodTo.getTime()) { - periodByDay.set(day, (periodByDay.get(day) || 0) + tokens); - periodByDayCost.set(day, (periodByDayCost.get(day) || 0) + cost); - const src = canonicalSource(row.source); - const provider = periodByProvider.get(src) || { tokens: 0, cost: 0 }; - provider.tokens += tokens; - provider.cost += cost; - periodByProvider.set(src, provider); - if (row.model) periodByModel.set(row.model, (periodByModel.get(row.model) || 0) + tokens); - periodTotalTokens += tokens; - periodTotalCost += cost; - } - } - - // best_day in period - let bestDay: { date: string; total_tokens: number; estimated_cost_usd: number } | null = null; - for (const [day, tokens] of periodByDay.entries()) { - if (!bestDay || tokens > bestDay.total_tokens) { - bestDay = { date: day, total_tokens: tokens, estimated_cost_usd: periodByDayCost.get(day) || 0 }; - } - } - - // favorite_model - let favoriteModel: { model_name: string; total_tokens: number } | null = null; - for (const [model, tokens] of periodByModel.entries()) { - if (!favoriteModel || tokens > favoriteModel.total_tokens) { - favoriteModel = { model_name: model, total_tokens: tokens }; - } - } - - // streak (over period — set of active day strings) - const activeDaySet = new Set(periodByDay.keys()); - const streak = computeStreak(activeDaySet); - - // daily_trend (period, dense — include 0 days so frontend can chart cleanly) - const dailyTrend: Array<{ date: string; total_tokens: number }> = []; - for (let cur = new Date(periodFrom); cur < periodTo; cur.setUTCDate(cur.getUTCDate() + 1)) { - const day = cur.toISOString().slice(0, 10); - dailyTrend.push({ date: day, total_tokens: periodByDay.get(day) || 0 }); - } - // heatmap (365d dense). `models` powers the dashboard ActivityHeatmap's - // per-cell model breakdown tooltip. Days with no activity get no models - // key (the heatmap component already handles that gracefully). - const heatmap: Array<{ date: string; total_tokens: number; models?: Record }> = []; - for (let cur = new Date(heatmapStart); cur < heatmapEnd; cur.setUTCDate(cur.getUTCDate() + 1)) { - const day = cur.toISOString().slice(0, 10); - const cell: { date: string; total_tokens: number; models?: Record } = { - date: day, - total_tokens: heatmapByDay.get(day) || 0, - }; - const dayModels = heatmapModelsByDay.get(day); - if (dayModels && dayModels.size > 0) { - cell.models = Object.fromEntries(dayModels); - } - heatmap.push(cell); - } - - // by_provider sorted desc with percent - const byProvider = Array.from(periodByProvider.entries()) - .map(([source, v]) => ({ - source, - total_tokens: v.tokens, - estimated_cost_usd: v.cost, - percent: periodTotalTokens > 0 ? v.tokens / periodTotalTokens : 0, - })) - .sort((a, b) => b.total_tokens - a.total_tokens); - - const activeDays = activeDaySet.size; - const periodDayCount = dailyTrend.length || 1; - const avgPerDayUsd = periodTotalCost / periodDayCount; - - const isAnonymous = Boolean(settings.leaderboard_anonymous); - const displayName = isAnonymous - ? "Anonymous" - : (profile.display_name || snap?.display_name || ""); - const avatarUrl = isAnonymous ? null : (profile.avatar_url || snap?.avatar_url || null); - const githubUrl = !isAnonymous && settings.show_github_url ? (settings.github_url || null) : null; - - return json({ - user: { - user_id: userId, - display_name: displayName, - avatar_url: avatarUrl, - github_url: githubUrl, - is_anonymous: isAnonymous, - rank: snap?.rank ?? null, - }, - period: { - kind: period, - from: periodBounds.from_day, - to: periodBounds.to_day, - generated_at: snap?.generated_at || new Date().toISOString(), - }, - totals: { - total_tokens: periodTotalTokens, - estimated_cost_usd: periodTotalCost, - active_days: activeDays, - avg_per_day_usd: avgPerDayUsd, - }, - streak, - best_day: bestDay, - models: { - count: periodByModel.size, - favorite: favoriteModel, - }, - by_provider: byProvider, - heatmap, - daily_trend: dailyTrend, - }); -} diff --git a/dashboard/edge-patches/tokentracker-leaderboard-refresh.ts b/dashboard/edge-patches/tokentracker-leaderboard-refresh.ts deleted file mode 100644 index 90b16136..00000000 --- a/dashboard/edge-patches/tokentracker-leaderboard-refresh.ts +++ /dev/null @@ -1,696 +0,0 @@ -/** - * InsForge Edge:排行榜快照刷新。 - * 从 tokentracker_hourly 聚合数据,按 period 写入 tokentracker_leaderboard_snapshots。 - * 接受 POST,可选 body: { period: "week"|"month"|"total" },不传则刷新全部三个。 - */ -import { createClient } from "npm:@insforge/sdk"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); -} - -function logRefreshEvent(event: Record) { - console.log(JSON.stringify({ scope: "leaderboard-refresh", ...event })); -} - -type Period = "week" | "month" | "total"; -const ALL_PERIODS: Period[] = ["week", "month", "total"]; -const BLOCKED_LEADERBOARD_USER_IDS = new Set( - (Deno.env.get("LEADERBOARD_BLOCKED_USER_IDS") ?? "") - .split(",") - .map((id) => id.trim()) - .filter(Boolean), -); - -/** Per-model pricing (USD per million tokens), synced from local-api.js */ -const MODEL_PRICING: Record = { - // ── Anthropic Claude ── - "claude-fable-5": { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, - "claude-mythos-5": { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, - "claude-opus-4-6": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, - "claude-opus-4-5-20250414": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, - "claude-sonnet-5": { input: 2, output: 10, cache_read: 0.2, cache_write: 2.5 }, - "claude-sonnet-4-6": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-sonnet-4-5-20250514": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-sonnet-4-20250514": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-haiku-4-5-20251001": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, - "claude-3-5-sonnet-20241022": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "claude-3-5-haiku-20241022": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, - // ── OpenAI GPT / Codex ── - "gpt-5": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-codex": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5-codex-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-mini": { input: 0.25, output: 2, cache_read: 0.025 }, - "gpt-5.1-codex-max": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-high-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-xhigh-fast": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.1-codex-max-high": { input: 1.25, output: 10, cache_read: 0.125 }, - "gpt-5.2": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-high-fast": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-codex": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.2-codex-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.3-codex": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.3-codex-high": { input: 1.75, output: 14, cache_read: 0.175 }, - "gpt-5.4": { input: 2.5, output: 15, cache_read: 0.25 }, - "gpt-5.4-mini": { input: 0.75, output: 4.5, cache_read: 0.075 }, - "gpt-5.4-medium": { input: 1.5, output: 10, cache_read: 0.15 }, - "gpt-5.5": { input: 5, output: 30, cache_read: 0.5 }, - "gpt-5-mini": { input: 0.25, output: 2, cache_read: 0.025 }, - "o3": { input: 2, output: 8, cache_read: 0.5 }, - // ── Google Gemini ── - "gemini-2.5-pro": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-pro-preview-06-05": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-pro-preview-05-06": { input: 1.25, output: 10, cache_read: 0.125 }, - "gemini-2.5-flash": { input: 0.3, output: 2.5, cache_read: 0.03 }, - "gemini-3-flash-preview": { input: 0.5, output: 3, cache_read: 0.05 }, - "gemini-3-pro-preview": { input: 2, output: 12, cache_read: 0.2 }, - "gemini-3.1-pro-preview": { input: 2, output: 12, cache_read: 0.2 }, - // ── Cursor Composer ── - "composer-1": { input: 1.25, output: 10, cache_read: 0.125 }, - "composer-1.5": { input: 3.5, output: 17.5, cache_read: 0.35 }, - "composer-2": { input: 0.5, output: 2.5, cache_read: 0.2 }, - "composer-2-fast": { input: 1.5, output: 7.5, cache_read: 0.15 }, - // ── Moonshot Kimi ── - "kimi-for-coding": { input: 0.6, output: 2, cache_read: 0.15 }, - "kimi-k2.5": { input: 0.6, output: 2, cache_read: 0.15 }, - "kimi-k2.5-free": { input: 0, output: 0, cache_read: 0 }, - "kimi-k2.6": { input: 0.95, output: 4, cache_read: 0.16 }, - // ── Z.ai GLM (mirrored from src/lib/pricing/curated-overrides.json). - // LiteLLM only keys these under provider prefixes like `zai/glm-5`, - // `openrouter/z-ai/glm-4.6`, etc. The reverse-substring fallback in the - // matcher requires the user-supplied model name to CONTAIN the LiteLLM - // key, so the bare `glm-5.1` / `glm-4.6` strings reported by Claude - // Code-compatible GLM endpoints never match. Curate them here. ── - "glm-5.1": { input: 1.4, output: 4.4, cache_read: 0.26 }, - "glm-5": { input: 1.0, output: 3.2, cache_read: 0.2 }, - "glm-5-turbo": { input: 1.2, output: 4.0, cache_read: 0.24 }, - "glm-4.7": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.7-flashx": { input: 0.07, output: 0.4, cache_read: 0.01 }, - "glm-4.7-flash": { input: 0, output: 0, cache_read: 0 }, - "glm-4.6": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.5": { input: 0.6, output: 2.2, cache_read: 0.11 }, - "glm-4.5-x": { input: 2.2, output: 8.9, cache_read: 0.45 }, - "glm-4.5-air": { input: 0.2, output: 1.1, cache_read: 0.03 }, - "glm-4.5-airx": { input: 1.1, output: 4.5, cache_read: 0.22 }, - "glm-4.5-flash": { input: 0, output: 0, cache_read: 0 }, - // ── MiniMax / DeepSeek ── - "MiniMax-M2.7": { input: 0.3, output: 1.2, cache_read: 0.06, cache_write: 0.375 }, - "MiniMax-M2.7-highspeed": { input: 0.6, output: 2.4, cache_read: 0.06, cache_write: 0.375 }, - "deepseek-v4-flash": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "deepseek-v4-pro": { input: 0.435, output: 0.87, cache_read: 0.003625, cache_write: 0.435 }, - "deepseek-chat": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - "deepseek-reasoner": { input: 0.14, output: 0.28, cache_read: 0.0028, cache_write: 0.14 }, - // ── xAI Grok (mirrored from src/lib/pricing/curated-overrides.json; - // Grok parser emits cache_creation_input_tokens = 0, so cache_write is - // omitted — same as the canonical table). ── - "grok-build": { input: 1.25, output: 2.50, cache_read: 0.20 }, - "grok-4-0709": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4-latest": { input: 3.00, output: 15.00, cache_read: 0.75 }, - "grok-4-fast": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-fast-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-fast-non-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - "grok-4-1-fast-non-reasoning": { input: 0.20, output: 0.50, cache_read: 0.05 }, - // ── AWS Kiro (mirrored byte-for-byte from src/lib/local-api.js to - // prevent cloud/local cost drift — Kiro routes through Bedrock, - // most commonly claude-sonnet-4). ── - "kiro-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - "kiro-cli-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, - // ── Tencent CodeBuddy (hy3-preview family). Mirrored from - // src/lib/local-api.js — Tencent has not published $/MTok rates so - // these stay at 0. TODO: confirm Tencent hy3 pricing. ── - "hy3-preview-agent": { input: 0, output: 0, cache_read: 0 }, - "hy3-preview": { input: 0, output: 0, cache_read: 0 }, - // ── Misc / Free ── - "glm-4.7-free": { input: 0, output: 0, cache_read: 0 }, - "nemotron-3-super-free": { input: 0, output: 0, cache_read: 0 }, - "mimo-v2-pro-free": { input: 0, output: 0, cache_read: 0 }, - "minimax-m2.1-free": { input: 0, output: 0, cache_read: 0 }, - "MiniMax-M2.1": { input: 0.5, output: 3, cache_read: 0.05 }, - // ── Xiaomi MiMo (mirrored from src/lib/pricing/seed-snapshot.json LiteLLM - // entries openrouter/xiaomi/mimo-*; queue rows report the bare names. - // Kept in lockstep with the matcher's litellm:prefix-strip resolution — - // cache_read for mimo-v2-flash uses novita's 0.02 (the lexicographically - // smallest provider key the matcher deterministically picks). ── - "mimo-v2.5-pro": { input: 1, output: 3, cache_read: 0.2 }, - "mimo-v2.5": { input: 0.4, output: 2, cache_read: 0.08 }, - "mimo-v2-flash": { input: 0.1, output: 0.3, cache_read: 0.02 }, -}; -const ZERO_PRICING = { input: 0, output: 0, cache_read: 0, cache_write: 0 }; - -function getModelPricing(model: string) { - if (!model) return ZERO_PRICING; - const exact = MODEL_PRICING[model]; - if (exact) return exact; - const lower = model.toLowerCase(); - if (lower.includes("fable")) return MODEL_PRICING["claude-fable-5"]; - if (lower.includes("mythos-5")) return MODEL_PRICING["claude-mythos-5"]; - if (lower.includes("opus")) return MODEL_PRICING["claude-opus-4-6"]; - if (lower.includes("haiku")) return MODEL_PRICING["claude-haiku-4-5-20251001"]; - if (lower.includes("sonnet")) return MODEL_PRICING["claude-sonnet-4-6"]; - if (lower.includes("gpt-5.4")) return MODEL_PRICING["gpt-5.4"]; - if (lower.includes("gpt-5.5")) return MODEL_PRICING["gpt-5.5"]; - if (lower.includes("gpt-5-mini")) return MODEL_PRICING["gpt-5-mini"]; - if (lower.includes("gpt-5.3")) return MODEL_PRICING["gpt-5.3-codex"]; - if (lower.includes("gpt-5.2")) return MODEL_PRICING["gpt-5.2"]; - if (lower.includes("gpt-5.1")) return MODEL_PRICING["gpt-5.1-codex"]; - if (lower.includes("gpt-5")) return MODEL_PRICING["gpt-5"]; - if (lower.includes("gemini-3")) return MODEL_PRICING["gemini-3-flash-preview"]; - if (lower.includes("gemini-2.5")) return MODEL_PRICING["gemini-2.5-pro"]; - if (lower.includes("minimax-m2.7-highspeed")) return MODEL_PRICING["MiniMax-M2.7-highspeed"]; - if (lower.includes("minimax-m2.7")) return MODEL_PRICING["MiniMax-M2.7"]; - if (lower.includes("deepseek-v4-flash")) return MODEL_PRICING["deepseek-v4-flash"]; - if (lower.includes("deepseek-v4-pro")) return MODEL_PRICING["deepseek-v4-pro"]; - if (lower.includes("deepseek-reasoner")) return MODEL_PRICING["deepseek-reasoner"]; - if (lower.includes("deepseek-chat")) return MODEL_PRICING["deepseek-chat"]; - if (lower.includes("grok-build")) return MODEL_PRICING["grok-build"]; - if (lower.includes("grok-4-fast")) return MODEL_PRICING["grok-4-fast"]; - // grok-4-1-fast-* must precede the generic grok-4 matcher. Cloud rows may - // carry a provider prefix or `-latest` suffix (e.g. xai/grok-4-1-fast- - // non-reasoning-latest), and the substring "grok-4-fast" does NOT match - // "grok-4-1-fast" (the "-1-" separates them). Without this specific match - // these rows fall through to grok-4 and get billed at $3/$15 MTok instead - // of the $0.20/$0.50 MTok fast-tier rate (15x / 30x overestimate). - if (lower.includes("grok-4-1-fast")) return MODEL_PRICING["grok-4-1-fast-non-reasoning"]; - if (lower.includes("grok-4")) return MODEL_PRICING["grok-4"]; - if (lower.includes("kimi-k2.6")) return MODEL_PRICING["kimi-k2.6"]; - if (lower.includes("kimi")) return MODEL_PRICING["kimi-k2.5"]; - // MiMo ordering: more specific suffixes first (mimo-v2.5-pro before - // mimo-v2.5 which is a substring; the free tier is a distinct name). - if (lower.includes("mimo-v2-pro-free")) return MODEL_PRICING["mimo-v2-pro-free"]; - if (lower.includes("mimo-v2.5-pro")) return MODEL_PRICING["mimo-v2.5-pro"]; - if (lower.includes("mimo-v2.5")) return MODEL_PRICING["mimo-v2.5"]; - if (lower.includes("mimo-v2-flash")) return MODEL_PRICING["mimo-v2-flash"]; - // GLM ordering: more specific suffixes (-airx/-air/-x/-flash/-flashx/-turbo) - // must precede the base matchers. glm-5.1 must precede glm-5 (substring). - if (lower.includes("glm-4.5-airx")) return MODEL_PRICING["glm-4.5-airx"]; - if (lower.includes("glm-4.5-air")) return MODEL_PRICING["glm-4.5-air"]; - if (lower.includes("glm-4.5-x")) return MODEL_PRICING["glm-4.5-x"]; - if (lower.includes("glm-4.5-flash")) return MODEL_PRICING["glm-4.5-flash"]; - if (lower.includes("glm-4.5")) return MODEL_PRICING["glm-4.5"]; - if (lower.includes("glm-4.7-flashx")) return MODEL_PRICING["glm-4.7-flashx"]; - if (lower.includes("glm-4.7-flash")) return MODEL_PRICING["glm-4.7-flash"]; - if (lower.includes("glm-4.7")) return MODEL_PRICING["glm-4.7"]; - if (lower.includes("glm-4.6")) return MODEL_PRICING["glm-4.6"]; - if (lower.includes("glm-5-turbo")) return MODEL_PRICING["glm-5-turbo"]; - if (lower.includes("glm-5.1")) return MODEL_PRICING["glm-5.1"]; - if (lower.includes("glm-5")) return MODEL_PRICING["glm-5"]; - if (lower.includes("kiro")) return MODEL_PRICING["kiro-cli-agent"]; - if (lower.includes("hy3")) return MODEL_PRICING["hy3-preview-agent"]; - if (lower.includes("composer")) return MODEL_PRICING["composer-1"]; - if (lower === "auto") return MODEL_PRICING["composer-1"]; - return ZERO_PRICING; -} - -function computeRowCost(row: HourlyRow): number { - const p = getModelPricing(row.model); - // For Codex-family rollouts, `output_tokens` already includes any reasoning - // tokens (OpenAI API convention), so `reasoning_output_tokens * output_rate` - // would double-charge the reasoning slice. Kept explicit for other sources - // where reasoning is NOT guaranteed to be folded into output_tokens. - // Must stay in lockstep with local-api.js:computeRowCost. - const reasoningIncludedInOutput = row.source === "codex" || row.source === "every-code"; - const reasoningCost = reasoningIncludedInOutput - ? 0 - : (row.reasoning_output_tokens || 0) * (p.output || 0); - return ( - ((row.input_tokens || 0) * (p.input || 0) + - (row.output_tokens || 0) * (p.output || 0) + - (row.cached_input_tokens || 0) * (p.cache_read || 0) + - (row.cache_creation_input_tokens || 0) * (p.cache_write || 0) + - reasoningCost) / - 1_000_000 - ); -} - -/** source -> snapshot column name */ -const SOURCE_COLUMN_MAP: Record = { - codex: "gpt_tokens", - claude: "claude_tokens", - gemini: "gemini_tokens", - cursor: "cursor_tokens", - opencode: "opencode_tokens", - openclaw: "openclaw_tokens", - hermes: "hermes_tokens", - kiro: "kiro_tokens", - copilot: "copilot_tokens", - kimi: "kimi_tokens", -}; - -interface DateRange { - from_day: string; - to_day: string; -} - -function computeDateRange(period: Period): DateRange { - const now = new Date(); - if (period === "week") { - // ISO 8601 Monday-start week (matches dashboard/src/lib/date-range.ts:67 - // `period === "week"` branch, so cloud leaderboard rank and the - // dashboard's own "Week" tab cover the same 7 days. Previously this used - // Sunday-start which made leaderboard's week off-by-one vs the dashboard - // and confused users comparing the two numbers. - const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const offset = (d.getUTCDay() + 6) % 7; // days since Monday (Mon=0..Sun=6) - d.setUTCDate(d.getUTCDate() - offset); // Monday - const from_day = d.toISOString().slice(0, 10); - d.setUTCDate(d.getUTCDate() + 6); // Sunday - const to_day = d.toISOString().slice(0, 10); - return { from_day, to_day }; - } - if (period === "month") { - const from_day = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-01`; - const to_day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)) - .toISOString() - .slice(0, 10); - return { from_day, to_day }; - } - // total — full lifetime. `from_day` is a static epoch sentinel so snapshot - // rows always have identical (period, from_day, to_day) and upsert cleanly. - const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - return { from_day: "1970-01-01", to_day: end.toISOString().slice(0, 10) }; -} - -interface HourlyRow { - user_id: string; - source: string; - model: string; - hour_start: string; - total_tokens: number; - input_tokens: number; - output_tokens: number; - cached_input_tokens: number; - cache_creation_input_tokens: number; - reasoning_output_tokens: number; -} - -interface UserAgg { - gpt_tokens: number; - claude_tokens: number; - gemini_tokens: number; - cursor_tokens: number; - opencode_tokens: number; - openclaw_tokens: number; - hermes_tokens: number; - kiro_tokens: number; - copilot_tokens: number; - kimi_tokens: number; - other_tokens: number; - total_tokens: number; - estimated_cost_usd: number; -} - -function newUserAgg(): UserAgg { - return { - gpt_tokens: 0, - claude_tokens: 0, - gemini_tokens: 0, - cursor_tokens: 0, - opencode_tokens: 0, - openclaw_tokens: 0, - hermes_tokens: 0, - kiro_tokens: 0, - copilot_tokens: 0, - kimi_tokens: 0, - other_tokens: 0, - total_tokens: 0, - estimated_cost_usd: 0, - }; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") - return new Response(null, { status: 204, headers: corsHeaders }); - if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); - const requestStartedAt = Date.now(); - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const dbToken = serviceRoleKey || anonKey; - - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - // Parse requested periods - const body = await req.json().catch(() => ({})) as Record; - const requestSource = - typeof body.source === "string" && body.source.trim().length > 0 - ? body.source.trim().slice(0, 80) - : "unknown"; - let periods: Period[]; - if (body.period && ALL_PERIODS.includes(body.period as Period)) { - periods = [body.period as Period]; - } else { - periods = [...ALL_PERIODS]; - } - - const results: Record = {}; - const requestId = crypto.randomUUID(); - - for (const period of periods) { - const periodStartedAt = Date.now(); - const { from_day, to_day } = computeDateRange(period); - - // --- Rate limit: skip if generated_at within last 30s --- - const { data: recentSnap } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select("generated_at") - .eq("period", period) - .eq("from_day", from_day) - .eq("to_day", to_day) - .order("generated_at", { ascending: false }) - .limit(1) - .maybeSingle(); - - if (recentSnap?.generated_at) { - const elapsed = Date.now() - new Date(recentSnap.generated_at as string).getTime(); - if (elapsed < 30_000) { - results[period] = { upserted: 0, skipped: true }; - logRefreshEvent({ - event: "period_skipped", - request_id: requestId, - source: requestSource, - period, - from_day, - to_day, - upserted: 0, - skipped: true, - duration_ms: Date.now() - periodStartedAt, - recent_snapshot_age_ms: elapsed, - }); - continue; - } - } - - // --- Query hourly data for date range --- - // hour_start is timestamptz; filter by >= from_day 00:00 UTC and < to_day+1 00:00 UTC - const rangeStart = `${from_day}T00:00:00Z`; - const nextDay = new Date(to_day + "T00:00:00Z"); - nextDay.setUTCDate(nextDay.getUTCDate() + 1); - const rangeEnd = nextDay.toISOString(); - - // Paginate through all hourly rows (1000 per page). - // Dedupe by (user_id, source, model, hour_start) keeping the MAX - // total_tokens across devices — the same logical bucket can be recorded - // under multiple device_id rows (cloud-sync issues a new device per - // session; cumulative queue replays upsert under whichever device ran - // the sync). SUM across devices would double-count the same usage. - const bucketMap = new Map(); - let offset = 0; - const PAGE_SIZE = 1000; - let hasMore = true; - let scannedRows = 0; - let pageCount = 0; - - while (hasMore) { - const { data: rows, error } = await client.database - .from("tokentracker_hourly") - .select("user_id, source, model, hour_start, total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, reasoning_output_tokens") - .gte("hour_start", rangeStart) - .lt("hour_start", rangeEnd) - .order("hour_start", { ascending: true }) - .range(offset, offset + PAGE_SIZE - 1); - - if (error) { - logRefreshEvent({ - event: "period_error", - request_id: requestId, - source: requestSource, - period, - from_day, - to_day, - stage: "scan_hourly", - error: error.message, - scanned_rows: scannedRows, - pages_fetched: pageCount, - duration_ms: Date.now() - periodStartedAt, - }); - return json({ error: error.message }, 500); - } - if (!rows || rows.length === 0) break; - scannedRows += rows.length; - pageCount += 1; - - for (const row of rows as HourlyRow[]) { - const key = `${row.user_id}|${row.source}|${row.model}|${row.hour_start}`; - const existing = bucketMap.get(key); - const incoming = Number(row.total_tokens) || 0; - if (!existing || incoming > (Number(existing.total_tokens) || 0)) { - bucketMap.set(key, row); - } - } - - hasMore = rows.length === PAGE_SIZE; - offset += PAGE_SIZE; - } - - const aggMap = new Map(); - for (const row of bucketMap.values()) { - let agg = aggMap.get(row.user_id); - if (!agg) { - agg = newUserAgg(); - aggMap.set(row.user_id, agg); - } - const tokens = Number(row.total_tokens) || 0; - const col = SOURCE_COLUMN_MAP[row.source] ?? "other_tokens"; - (agg as unknown as Record)[col] += tokens; - agg.total_tokens += tokens; - agg.estimated_cost_usd += computeRowCost(row); - } - for (const blockedUserId of BLOCKED_LEADERBOARD_USER_IDS) { - aggMap.delete(blockedUserId); - } - - if (aggMap.size === 0) { - results[period] = { upserted: 0 }; - logRefreshEvent({ - event: "period_completed", - request_id: requestId, - source: requestSource, - period, - from_day, - to_day, - scanned_rows: scannedRows, - pages_fetched: pageCount, - deduped_buckets: bucketMap.size, - aggregated_users: 0, - upserted: 0, - skipped: false, - duration_ms: Date.now() - periodStartedAt, - }); - continue; - } - - // --- Fetch user settings for public/anonymous flags --- - const userIds = Array.from(aggMap.keys()); - type UserSettingsRow = { - user_id: string; - leaderboard_public: boolean; - leaderboard_anonymous: boolean; - github_url: string | null; - show_github_url: boolean; - }; - const settingsMap = new Map(); - - // Fetch in batches of 25. PostgREST .in() encodes user_ids into the URL - // (~42 bytes per UUID after URL-encoding); a batch of 100 produces a - // ~4 KB URL that the InsForge gateway silently truncates, returning - // {data: null, error}. The bug surfaced once the all-time leaderboard - // crossed ~80 users and produced an entirely Anonymous snapshot. 25 - // keeps the URL well under 1.5 KB regardless of user count. - for (let i = 0; i < userIds.length; i += 25) { - const batch = userIds.slice(i, i + 25); - const { data: settings, error: settingsErr } = await client.database - .from("tokentracker_user_settings") - .select("user_id, leaderboard_public, leaderboard_anonymous, github_url, show_github_url") - .in("user_id", batch); - - if (settingsErr) { - logRefreshEvent({ - event: "user_settings_fetch_error", - period, - batch_size: batch.length, - error: settingsErr.message, - }); - } - if (settings) { - for (const s of settings as UserSettingsRow[]) { - settingsMap.set(s.user_id, s); - } - } - } - - // --- Fetch display_name/avatar_url from auth.users --- - const userProfiles = new Map(); - for (let i = 0; i < userIds.length; i += 25) { - const batch = userIds.slice(i, i + 25); - const { data: users, error: profilesErr } = await client.database - .from("tokentracker_user_profiles") - .select("user_id, display_name, avatar_url") - .in("user_id", batch); - - if (profilesErr) { - logRefreshEvent({ - event: "user_profiles_fetch_error", - period, - batch_size: batch.length, - error: profilesErr.message, - }); - } - if (users) { - for (const u of users as { user_id: string; display_name: string | null; avatar_url: string | null }[]) { - userProfiles.set(u.user_id, { display_name: u.display_name, avatar_url: u.avatar_url }); - } - } - } - - // Fallback: existing snapshots for users not in auth.users - for (let i = 0; i < userIds.length; i += 25) { - if ([...userIds.slice(i, i + 25)].every(id => userProfiles.has(id))) continue; - const missing = userIds.slice(i, i + 25).filter(id => !userProfiles.has(id)); - if (missing.length === 0) continue; - const { data: existing, error: fallbackErr } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select("user_id, display_name, avatar_url") - .in("user_id", missing) - .order("generated_at", { ascending: false }); - if (fallbackErr) { - logRefreshEvent({ - event: "snapshot_fallback_fetch_error", - period, - batch_size: missing.length, - error: fallbackErr.message, - }); - } - if (existing) { - for (const e of existing as { user_id: string; display_name: string | null; avatar_url: string | null }[]) { - if (!userProfiles.has(e.user_id)) { - userProfiles.set(e.user_id, { display_name: e.display_name, avatar_url: e.avatar_url }); - } - } - } - } - - // --- Rank users by total_tokens DESC --- - const sorted = Array.from(aggMap.entries()).sort((a, b) => b[1].total_tokens - a[1].total_tokens); - - const generatedAt = new Date().toISOString(); - const upsertRows = sorted.map(([userId, agg], idx) => { - const settings = settingsMap.get(userId); - const isPublic = settings?.leaderboard_public ?? false; - const isAnonymous = settings?.leaderboard_anonymous ?? false; - const profile = userProfiles.get(userId); - const displayName = isAnonymous ? "Anonymous" : (profile?.display_name ?? "Anonymous"); - const avatarUrl = isAnonymous ? null : (profile?.avatar_url ?? null); - // Only surface github_url on the public snapshot when the user opted in - // AND isn't in anonymous mode — anonymous takes precedence over any - // identifying link. - const githubUrl = !isAnonymous && settings?.show_github_url && settings?.github_url - ? settings.github_url - : null; - - return { - user_id: userId, - period, - from_day, - to_day, - rank: idx + 1, - gpt_tokens: agg.gpt_tokens, - claude_tokens: agg.claude_tokens, - gemini_tokens: agg.gemini_tokens, - cursor_tokens: agg.cursor_tokens, - opencode_tokens: agg.opencode_tokens, - openclaw_tokens: agg.openclaw_tokens, - hermes_tokens: agg.hermes_tokens, - kiro_tokens: agg.kiro_tokens, - copilot_tokens: agg.copilot_tokens, - kimi_tokens: agg.kimi_tokens, - other_tokens: agg.other_tokens, - total_tokens: agg.total_tokens, - estimated_cost_usd: Math.round(agg.estimated_cost_usd * 100) / 100, - display_name: displayName, - avatar_url: avatarUrl, - github_url: githubUrl, - is_public: isPublic, - generated_at: generatedAt, - }; - }); - - // Upsert in batches of 200 - for (let i = 0; i < upsertRows.length; i += 200) { - const batch = upsertRows.slice(i, i + 200); - const { error: upsertErr } = await client.database - .from("tokentracker_leaderboard_snapshots") - .upsert(batch, { onConflict: "user_id,period,from_day,to_day" }); - - if (upsertErr) { - logRefreshEvent({ - event: "period_error", - request_id: requestId, - source: requestSource, - period, - from_day, - to_day, - stage: "upsert_snapshot", - error: upsertErr.message, - scanned_rows: scannedRows, - pages_fetched: pageCount, - deduped_buckets: bucketMap.size, - aggregated_users: aggMap.size, - duration_ms: Date.now() - periodStartedAt, - }); - return json({ error: upsertErr.message }, 500); - } - } - - results[period] = { upserted: upsertRows.length }; - logRefreshEvent({ - event: "period_completed", - request_id: requestId, - source: requestSource, - period, - from_day, - to_day, - scanned_rows: scannedRows, - pages_fetched: pageCount, - deduped_buckets: bucketMap.size, - aggregated_users: aggMap.size, - upserted: upsertRows.length, - skipped: false, - duration_ms: Date.now() - periodStartedAt, - }); - } - - logRefreshEvent({ - event: "request_completed", - request_id: requestId, - source: requestSource, - requested_periods: periods, - duration_ms: Date.now() - requestStartedAt, - results, - }); - return json({ ok: true, results }); -} diff --git a/dashboard/edge-patches/tokentracker-leaderboard.ts b/dashboard/edge-patches/tokentracker-leaderboard.ts deleted file mode 100644 index 0039d5cd..00000000 --- a/dashboard/edge-patches/tokentracker-leaderboard.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * InsForge Edge: public leaderboard list. - * - * IMPORTANT — why no Authorization header is read here: - * InsForge's platform gateway validates any incoming `Authorization: Bearer ...` - * against the service JWT secret *before* this function runs. Any token problem - * (bad signature, expired, wrong issuer, stale after key rotation) is surfaced - * as an opaque HTTP 500 JWSError instead of a proper 401. That blew up the - * Leaderboard for real users whose stored access_token drifted (GitHub issue #6 - * / Linear 001-51). - * - * Fix: this endpoint is now purely public. The client passes its own `user_id` - * as a query parameter (already known from the Insforge auth context) to get - * `is_me` highlighting. The function never touches the Authorization header, - * so no JWT validation path can ever fire for Leaderboard reads. - * - * Security note: `user_id` from the query is only used for row-highlight. All - * leaderboard entries are public data in `tokentracker_leaderboard_snapshots`, - * so spoofing `user_id` at worst re-highlights a different row — no PII or - * private data is exposed. - */ -import { createClient } from "npm:@insforge/sdk"; - -const cors = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", -}; -const BLOCKED_LEADERBOARD_USER_IDS = new Set( - (Deno.env.get("LEADERBOARD_BLOCKED_USER_IDS") ?? "") - .split(",") - .map((id) => id.trim()) - .filter(Boolean), -); - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...cors, "Content-Type": "application/json" }, - }); -} - -/** - * Decode (without verifying) the `sub` claim from a JWT. Pure back-compat - * fallback so v0.5.46 clients that still send an Authorization header - * (instead of the new `user_id` query param) still get `is_me` highlighting. - * Signature is NOT verified — the value is only used for cosmetic row - * highlighting on public data, never for authorization. - */ -function unsafeDecodeJwtSub(token: string): string | null { - try { - const parts = token.split("."); - if (parts.length < 2) return null; - const p = parts[1]; - const padded = p - .replace(/-/g, "+") - .replace(/_/g, "/") - .padEnd(p.length + ((4 - (p.length % 4)) % 4), "="); - const payload = JSON.parse(atob(padded)) as Record; - const sub = payload.sub; - if (typeof sub === "string" && sub.length > 0) return sub; - const uid = payload.user_id; - if (typeof uid === "string" && uid.length > 0) return uid; - } catch { - /* ignore */ - } - return null; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors }); - const url = new URL(req.url); - const period = url.searchParams.get("period") || "week"; - const limit = Math.min(parseInt(url.searchParams.get("limit") || "20") || 20, 100); - const offset = parseInt(url.searchParams.get("offset") || "0") || 0; - // Preferred path: client passes user_id as a query param (v0.5.51+). - // Fallback: v0.5.46 clients still send a Bearer token — decode its sub - // claim (WITHOUT signature verification) so they keep seeing is_me - // highlighting. Signature validation is unnecessary because sub is only - // used to pick which public row to highlight. - let requestedUserId = - url.searchParams.get("user_id") || url.searchParams.get("userId") || null; - if (!requestedUserId) { - const authH = req.headers.get("Authorization"); - const token = authH?.startsWith("Bearer ") ? authH.slice(7) : null; - if (token) requestedUserId = unsafeDecodeJwtSub(token); - } - - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - // Always use service role (never the caller's token) so a stale/broken - // caller token can't cascade into a 500 on the DB query. - const client = createClient({ - baseUrl, - edgeFunctionToken: serviceRoleKey, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - const now = new Date(); - let from_day: string; - let to_day: string; - if (period === "week") { - // ISO 8601 Monday-start week. Must match - // tokentracker-leaderboard-refresh.ts AND dashboard/src/lib/date-range.ts - // so the (period, from_day, to_day) tuple the reader queries matches the - // tuple the refresh wrote. - const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const offset = (d.getUTCDay() + 6) % 7; - d.setUTCDate(d.getUTCDate() - offset); // Monday - from_day = d.toISOString().slice(0, 10); - d.setUTCDate(d.getUTCDate() + 6); // Sunday - to_day = d.toISOString().slice(0, 10); - } else if (period === "month") { - from_day = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-01`; - to_day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).toISOString().slice(0, 10); - } else { - // period === "total": find the most recent (from_day, to_day) pair written - // by the refresh job. Hardcoding (2024-01-01, today) used to 404 whenever - // today's snapshot hadn't been generated yet, so the UI saw "no data". - const { data: latest } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select("from_day, to_day") - .eq("period", "total") - .order("to_day", { ascending: false }) - .limit(1) - .maybeSingle(); - const row = latest as { from_day?: string; to_day?: string } | null; - from_day = (row?.from_day ?? "2024-01-01").slice(0, 10); - to_day = (row?.to_day ?? now.toISOString()).slice(0, 10); - } - - const { - data: entries, - error, - count, - } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select("*", { count: "exact" }) - .eq("period", period) - .eq("from_day", from_day) - .eq("to_day", to_day) - .order("rank", { ascending: true }) - .range(offset, offset + limit - 1); - if (error) return json({ error: error.message }, 500); - - let me: unknown = null; - if (requestedUserId) { - try { - const { data: mr } = await client.database - .from("tokentracker_leaderboard_snapshots") - .select("*") - .eq("period", period) - .eq("from_day", from_day) - .eq("to_day", to_day) - .eq("user_id", requestedUserId) - .limit(1) - .maybeSingle(); - if (mr) me = mr; - } catch { - /* ignore */ - } - } - - const visibleEntries = (entries || []).filter( - (e: { user_id?: string }) => !e.user_id || !BLOCKED_LEADERBOARD_USER_IDS.has(e.user_id), - ); - const visibleMe = - me && !BLOCKED_LEADERBOARD_USER_IDS.has((me as { user_id?: string }).user_id || "") - ? me - : null; - - return json({ - entries: visibleEntries.map((e: { user_id?: string }) => ({ - ...e, - is_me: (visibleMe as { user_id?: string } | null)?.user_id === e.user_id, - })), - me: visibleMe, - total_entries: count || 0, - total_pages: Math.ceil((count || 0) / limit), - from: from_day, - to: to_day, - generated_at: new Date().toISOString(), - }); -} diff --git a/dashboard/edge-patches/tokentracker-profile-likes.ts b/dashboard/edge-patches/tokentracker-profile-likes.ts deleted file mode 100644 index ead85f23..00000000 --- a/dashboard/edge-patches/tokentracker-profile-likes.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Tokentracker profile likes (装饰性 like counter). - * - * Storage: `tokentracker_user_settings.profile_likes` (int, default 0). - * Single counter per target user — no liker identity recorded, no edge table. - * Allows anonymous likes (no JWT required). Per-device dedup lives client-side - * in localStorage; this endpoint trusts the {delta: ±1} payload. - * - * Endpoints: - * GET ?user_id=X → { count } - * POST { user_id, delta } → { count } (delta must be 1 or -1) - * - * Behaviour notes: - * - target in LEADERBOARD_BLOCKED_USER_IDS → 404 (consistent with profile/leaderboard). - * - Self-like is allowed (per product decision). - * - Race on concurrent +1s is accepted — this is a decorative counter, not money. - * - We require the target row to exist in tokentracker_user_settings; missing - * rows return 404 to avoid silently creating settings for non-public users. - */ -import { createClient } from "npm:@insforge/sdk"; - -const cors = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", -}; - -const BLOCKED_LEADERBOARD_USER_IDS = new Set( - (Deno.env.get("LEADERBOARD_BLOCKED_USER_IDS") ?? "") - .split(",") - .map((id) => id.trim()) - .filter(Boolean), -); - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...cors, "Content-Type": "application/json" }, - }); -} - -function getClient() { - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - const anonKey = Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY"); - return createClient({ - baseUrl: Deno.env.get("INSFORGE_BASE_URL")!, - edgeFunctionToken: serviceRoleKey, - anonKey: anonKey ?? undefined, - isServerMode: true, - }); -} - -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -// deno-lint-ignore no-explicit-any -async function readCount(client: any, userId: string): Promise { - const { data, error } = await client.database - .from("tokentracker_user_settings") - .select("profile_likes") - .eq("user_id", userId) - .maybeSingle(); - if (error) throw new Error(error.message); - if (!data) return null; - return Number((data as { profile_likes?: number }).profile_likes) || 0; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors }); - - const client = getClient(); - - if (req.method === "GET") { - const url = new URL(req.url); - const userId = url.searchParams.get("user_id"); - if (!userId || !UUID_RE.test(userId)) return json({ error: "user_id is required" }, 400); - if (BLOCKED_LEADERBOARD_USER_IDS.has(userId)) return json({ error: "Not found" }, 404); - try { - const count = await readCount(client, userId); - if (count === null) return json({ error: "Not found" }, 404); - return json({ count }); - } catch (e) { - return json({ error: (e as Error).message || "read failed" }, 500); - } - } - - if (req.method === "POST") { - let body: { user_id?: string; delta?: number }; - try { - body = await req.json(); - } catch { - return json({ error: "Invalid JSON body" }, 400); - } - const userId = body.user_id; - const delta = body.delta; - if (!userId || !UUID_RE.test(userId)) return json({ error: "user_id is required" }, 400); - if (delta !== 1 && delta !== -1) return json({ error: "delta must be 1 or -1" }, 400); - if (BLOCKED_LEADERBOARD_USER_IDS.has(userId)) return json({ error: "Not found" }, 404); - - try { - const current = await readCount(client, userId); - if (current === null) return json({ error: "Not found" }, 404); - // Clamp at 0 — never expose negative counts even if client mis-tracks state. - const next = Math.max(0, current + delta); - const { error: updErr } = await client.database - .from("tokentracker_user_settings") - .update({ profile_likes: next }) - .eq("user_id", userId); - if (updErr) throw new Error(updErr.message); - return json({ count: next }); - } catch (e) { - return json({ error: (e as Error).message || "update failed" }, 500); - } - } - - return json({ error: "Method not allowed" }, 405); -} diff --git a/dashboard/edge-patches/tokentracker-public-visibility.ts b/dashboard/edge-patches/tokentracker-public-visibility.ts deleted file mode 100644 index 0ee365a1..00000000 --- a/dashboard/edge-patches/tokentracker-public-visibility.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * InsForge Edge:公开资料开关。 - * Deno 内优先用 fetch 调 /api/auth/sessions/current 解析 user id(与浏览器 curl 一致); - * SDK 的 getCurrentUser() 在部分 Edge 运行时上对出站请求处理不稳定。 - */ -import { createClient } from "npm:@insforge/sdk"; - -const cors = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", -}; - -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { ...cors, "Content-Type": "application/json" }, - }); -} - -function b64urlToBytes(s: string): Uint8Array { - const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const raw = atob(b64 + "=".repeat(pad)); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; -} - -/** - * Verify a HS256 JWT signature locally with JWT_SECRET and return its `sub`. - * - * Previously this endpoint trusted an unverified JWT payload, which let any - * caller forge `{"sub":""}` and POST to mutate that victim's public - * profile (leaderboard_public, display_name, github_url, etc.). InsForge - * does NOT validate JWTs at the gateway (see tokentracker-leaderboard- - * profile.ts for the matching pattern), so this edge function must do it. - * - * Returns null on any failure (bad shape, bad signature, expired); the - * caller surfaces that as 401. - */ -async function verifiedUserIdFromJwt(token: string): Promise { - const secret = Deno.env.get("JWT_SECRET"); - if (!secret) return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"], - ); - const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); - const sig = b64urlToBytes(parts[2]); - const ok = await crypto.subtle.verify("HMAC", key, sig, data); - if (!ok) return null; - const payloadStr = new TextDecoder().decode(b64urlToBytes(parts[1])); - const payload = JSON.parse(payloadStr) as Record; - if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) return null; - const sub = payload.sub; - if (typeof sub === "string" && sub.length > 0) return sub; - const uid = payload.user_id; - if (typeof uid === "string" && uid.length > 0) return uid; - } catch { - /* ignore */ - } - return null; -} - -export default async function (req: Request): Promise { - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors }); - const baseUrl = Deno.env.get("INSFORGE_BASE_URL")!; - /** 与浏览器请求一致:优先环境变量,否则沿用调用方传入的 apikey(Edge 运行时未必注入 INSFORGE_ANON_KEY) */ - const incomingApiKey = - req.headers.get("apikey") ?? req.headers.get("Apikey") ?? req.headers.get("x-api-key") ?? undefined; - const anonKey = - Deno.env.get("INSFORGE_ANON_KEY") ?? Deno.env.get("ANON_KEY") ?? incomingApiKey ?? undefined; - const authH = req.headers.get("Authorization"); - const token = authH?.startsWith("Bearer ") ? authH.slice(7) : undefined; - if (!token) return json({ error: "Unauthorized" }, 401); - - const userId = await verifiedUserIdFromJwt(token); - if (!userId) return json({ error: "Unauthorized" }, 401); - - const serviceRoleKey = Deno.env.get("INSFORGE_SERVICE_ROLE_KEY"); - // 优先用 service role key 操作 DB,避免用户短期 JWT 过期导致 401 - const dbToken = serviceRoleKey || token; - const client = createClient({ - baseUrl, - edgeFunctionToken: dbToken, - anonKey, - ...(anonKey ? { headers: { apikey: anonKey } } : {}), - }); - - if (req.method === "GET") { - const { data } = await client.database - .from("tokentracker_user_settings") - .select("leaderboard_public, leaderboard_anonymous, github_url, show_github_url, updated_at") - .eq("user_id", userId) - .maybeSingle(); - const { data: pv } = await client.database - .from("tokentracker_public_views") - .select("token_hash, updated_at") - .eq("user_id", userId) - .is("revoked_at", null) - .maybeSingle(); - const { data: profile } = await client.database - .from("tokentracker_user_profiles") - .select("display_name") - .eq("user_id", userId) - .maybeSingle(); - return json({ - enabled: data?.leaderboard_public || false, - anonymous: data?.leaderboard_anonymous || false, - share_token: pv?.token_hash || null, - updated_at: data?.updated_at || null, - display_name: profile?.display_name || null, - github_url: data?.github_url || null, - show_github_url: data?.show_github_url || false, - }); - } - if (req.method === "POST") { - const body = await req.json().catch(() => ({})) as { - enabled?: boolean; - anonymous?: boolean; - display_name?: string; - github_url?: string | null; - show_github_url?: boolean; - }; - const now = new Date().toISOString(); - - // Validate github_url (optional). Accept public GitHub profile URLs only — - // any host/path shape that isn't a bare user/org page is rejected so we - // don't render arbitrary external links next to names on the leaderboard. - // `null` / empty string explicitly clears the value. - let normalizedGithubUrl: string | null | undefined = undefined; - if (body.github_url !== undefined) { - if (body.github_url === null || (typeof body.github_url === "string" && body.github_url.trim() === "")) { - normalizedGithubUrl = null; - } else if (typeof body.github_url === "string") { - const raw = body.github_url.trim(); - // Allow bare handle, "@handle", or full URL. Normalize to canonical URL. - const handleMatch = raw.match(/^@?([A-Za-z0-9][A-Za-z0-9-]{0,38})$/); - const urlMatch = raw.match(/^https:\/\/github\.com\/([A-Za-z0-9][A-Za-z0-9-]{0,38})\/?$/i); - if (handleMatch) { - normalizedGithubUrl = `https://github.com/${handleMatch[1]}`; - } else if (urlMatch) { - normalizedGithubUrl = `https://github.com/${urlMatch[1]}`; - } else { - return json({ error: "Invalid GitHub URL. Use https://github.com/ or a bare username." }, 400); - } - } - } - - // Trim + cap the display name once so the saved value and the returned - // value can't drift. - let normalizedDisplayName: string | null | undefined = undefined; - if (typeof body.display_name === "string") { - normalizedDisplayName = body.display_name.trim().slice(0, 50) || null; - } - - // Persist everything to tokentracker_user_settings — the writable base table. - // - // display_name MUST be written here, NOT to tokentracker_user_profiles: - // the latter is a VIEW (SELECT over auth.users LEFT JOIN - // tokentracker_user_settings), so upserting it fails with "cannot insert - // into view". The view exposes - // display_name = COALESCE(s.display_name, users.profile->>'name', email-local-part) - // so writing tokentracker_user_settings.display_name is exactly what makes - // the subsequent GET (which reads the view) reflect the saved name. - if ( - body.enabled !== undefined || - body.anonymous !== undefined || - normalizedGithubUrl !== undefined || - body.show_github_url !== undefined || - normalizedDisplayName !== undefined - ) { - const upsertRow: Record = { - user_id: userId, - updated_at: now, - }; - if (body.enabled !== undefined) upsertRow.leaderboard_public = Boolean(body.enabled); - if (body.anonymous !== undefined) upsertRow.leaderboard_anonymous = Boolean(body.anonymous); - if (normalizedGithubUrl !== undefined) upsertRow.github_url = normalizedGithubUrl; - if (body.show_github_url !== undefined) upsertRow.show_github_url = Boolean(body.show_github_url); - if (normalizedDisplayName !== undefined) upsertRow.display_name = normalizedDisplayName; - const { error: settingsErr } = await client.database.from("tokentracker_user_settings").upsert( - upsertRow, - { onConflict: "user_id" }, - ); - if (settingsErr) { - return json({ error: settingsErr.message || "Failed to save settings" }, 500); - } - } - - const result: Record = { updated_at: now }; - if (body.enabled !== undefined) result.enabled = Boolean(body.enabled); - if (body.anonymous !== undefined) result.anonymous = Boolean(body.anonymous); - if (normalizedDisplayName !== undefined) result.display_name = normalizedDisplayName; - if (normalizedGithubUrl !== undefined) result.github_url = normalizedGithubUrl; - if (body.show_github_url !== undefined) result.show_github_url = Boolean(body.show_github_url); - return json(result); - } - return json({ error: "Method not allowed" }, 405); -} diff --git a/dashboard/src/App.navigation-preload.test.jsx b/dashboard/src/App.navigation-preload.test.jsx index 03ab30ec..cf2c7af1 100644 --- a/dashboard/src/App.navigation-preload.test.jsx +++ b/dashboard/src/App.navigation-preload.test.jsx @@ -45,14 +45,6 @@ vi.mock("./ui/foundation/ThemeProvider.jsx", () => ({ ThemeProvider: ({ children }) => <>{children}, })); -vi.mock("@vercel/analytics/react", () => ({ - Analytics: () => null, -})); - -vi.mock("@vercel/speed-insights/react", () => ({ - SpeedInsights: () => null, -})); - vi.mock("./ui/components/Sidebar.jsx", async () => { const { Link } = await vi.importActual("react-router-dom"); return { diff --git a/dashboard/src/App.preload.test.jsx b/dashboard/src/App.preload.test.jsx index 4dbbdf80..a7873081 100644 --- a/dashboard/src/App.preload.test.jsx +++ b/dashboard/src/App.preload.test.jsx @@ -42,14 +42,6 @@ vi.mock("./ui/foundation/ThemeProvider.jsx", () => ({ ThemeProvider: ({ children }) => <>{children}, })); -vi.mock("@vercel/analytics/react", () => ({ - Analytics: () => null, -})); - -vi.mock("@vercel/speed-insights/react", () => ({ - SpeedInsights: () => null, -})); - vi.mock("./ui/components/Sidebar.jsx", () => ({ AppLayout: ({ children }) => (
diff --git a/dashboard/vercel.json b/dashboard/vercel.json deleted file mode 100644 index 0d199b2d..00000000 --- a/dashboard/vercel.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rewrites": [ - { "source": "/(.*)", "destination": "/index.html" } - ] -} diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js index 52559fab..fafad078 100644 --- a/dashboard/vite.config.js +++ b/dashboard/vite.config.js @@ -440,9 +440,6 @@ async function handleLocalApi(req, res, url) { if (typeof body.deviceToken === "string" && body.deviceToken.trim()) { extraEnv.TOKENTRACKER_DEVICE_TOKEN = body.deviceToken.trim(); } - if (typeof body.insforgeBaseUrl === "string" && /^https?:\/\//i.test(body.insforgeBaseUrl.trim())) { - extraEnv.TOKENTRACKER_INSFORGE_BASE_URL = body.insforgeBaseUrl.trim(); - } const result = await runLocalSyncCommand(extraEnv); try { const esmRequire = createRequire(import.meta.url); @@ -1176,30 +1173,17 @@ export default defineConfig(({ mode }) => { { from: /^\/functions\/.*$/, to: (ctx) => ctx.parsedUrl.pathname } ] }, - // 代理 InsForge auth/functions 请求到云端,避免跨域 cookie 问题 - proxy: (() => { - const proxies = { - // IP-check page proxies ip.net.coffee API + assets. Without this, - // 5173 dev mode shows "无数据" because trust score / geoip endpoints - // are not part of the Vite mock — they live on ip.net.coffee. - "/proxy/ipcheck": { - target: "https://ip.net.coffee", - changeOrigin: true, - secure: true, - rewrite: (p) => p.replace(/^\/proxy\/ipcheck/, ""), - }, - }; - const insforge = loadEnv("development", ROOT_DIR, "VITE_").VITE_INSFORGE_BASE_URL; - if (insforge) { - proxies["/api/auth"] = { - target: insforge, - changeOrigin: true, - secure: true, - cookieDomainRewrite: "localhost", - }; - } - return proxies; - })(), + proxy: { + // IP-check page proxies ip.net.coffee API + assets. Without this, + // 5173 dev mode shows "无数据" because trust score / geoip endpoints + // are not part of the Vite mock — they live on ip.net.coffee. + "/proxy/ipcheck": { + target: "https://ip.net.coffee", + changeOrigin: true, + secure: true, + rewrite: (p) => p.replace(/^\/proxy\/ipcheck/, ""), + }, + }, }, }; }); diff --git a/docs/npm-publish-checklist.md b/docs/npm-publish-checklist.md index 1c173f0d..cc899959 100644 --- a/docs/npm-publish-checklist.md +++ b/docs/npm-publish-checklist.md @@ -75,5 +75,3 @@ Privacy boundary for local services: - The local sync wrapper exits successfully without syncing when `TOKENTRACKER_DEVICE_TOKEN` or `~/.tokentracker/tracker/config.json` `deviceToken` is configured. -- Local dashboard mode must not preload or display leaderboard navigation on - `localhost`. diff --git a/scripts/validate-architecture-guardrails.cjs b/scripts/validate-architecture-guardrails.cjs index 0d5a4737..f5161e39 100644 --- a/scripts/validate-architecture-guardrails.cjs +++ b/scripts/validate-architecture-guardrails.cjs @@ -14,7 +14,6 @@ const IGNORED_DIRS = new Set([ "build", ".tmp", "archive", - "edge-patches", ]); const CLIENT_IMPORT_PATTERNS = []; diff --git a/src/lib/pricing/curated-overrides.json b/src/lib/pricing/curated-overrides.json index cf96b2e3..2e64d8a7 100644 --- a/src/lib/pricing/curated-overrides.json +++ b/src/lib/pricing/curated-overrides.json @@ -3,7 +3,7 @@ "note": "Curated price overrides. Always wins over LiteLLM. Two reasons to live here: (1) self-defined alias names that LiteLLM will never carry (kiro-*, hy3-*, composer-*, kimi-for-coding, free-tier OpenRouter routes); (2) prices we want to pin even if LiteLLM has the model (e.g. cache_write fields LiteLLM often omits). Units: USD per million tokens. Edit this file to override pricing without redeploying.", "units": "usd_per_million_tokens", "deepseek_v4_pro_discount_expiry": "2026-05-31T15:59:00Z — DeepSeek v4-pro is currently at a 75% promotional discount. After expiry the prices revert to 4x: input $1.74/M, output $3.48/M, cache_read $0.0145/M, cache_write $1.74/M. Update this file before the cutover.", - "sonnet5_intro_price_expiry": "2026-08-31 — Sonnet 5 intro price 2/10 reverts to 3/15 sticker. The src pricing path auto-tracks LiteLLM (no edit needed), but the 5 dashboard/edge-patches MODEL_PRICING tables hardcode 2/10 and must be reverted to 3/15 manually + re-vendor the seed. See issue #16." + "sonnet5_intro_price_expiry": "2026-08-31 — Sonnet 5 intro price 2/10 reverts to 3/15 sticker. The src pricing path auto-tracks LiteLLM (no edit needed); re-vendor the seed after the cutover. See issue #16." }, "exact": { "claude-fable-5": { "input": 10, "output": 50, "cache_read": 1, "cache_write": 12.5, "note": "Pinned from Anthropic public pricing until bundled LiteLLM seed carries Fable 5." }, diff --git a/test/badge-svg-render.test.js b/test/badge-svg-render.test.js deleted file mode 100644 index 651c52f5..00000000 --- a/test/badge-svg-render.test.js +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Pure renderer test for the README badge SVG endpoint. - * - * The InsForge Edge function in dashboard/edge-patches/tokentracker-badge-svg.ts - * runs under Deno, but its SVG rendering logic is pure — we mirror it here as - * a CommonJS port so we can unit-test the output shape, sizing math, and - * sanitization without needing a Deno runtime. - * - * Keep this file in sync with the Deno endpoint when changing renderBadgeSvg - * or compactNumber/formatCost. The Deno copy is the source of truth. - */ -"use strict"; - -const test = require("node:test"); -const assert = require("node:assert/strict"); - -function escapeXml(s) { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function compactNumber(n) { - if (!isFinite(n)) return "0"; - const abs = Math.abs(n); - if (abs >= 1e12) return (n / 1e12).toFixed(2).replace(/\.?0+$/, "") + "T"; - if (abs >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, "") + "B"; - if (abs >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, "") + "M"; - if (abs >= 1e3) return (n / 1e3).toFixed(2).replace(/\.?0+$/, "") + "K"; - return String(Math.round(n)); -} - -function formatCost(n) { - if (n >= 1000) return "$" + Math.round(n).toLocaleString("en-US"); - if (n >= 100) return "$" + n.toFixed(0); - return "$" + n.toFixed(2); -} - -function textWidth(s) { - return Math.max(s.length * 6.2, 0); -} - -const BRAND_GREEN = "#059669"; -const LABEL_BG = "#555"; - -function renderBadgeSvg({ label, value, style, color }) { - const padX = 6; - const labelWidth = Math.ceil(textWidth(label) + padX * 2); - const valueWidth = Math.ceil(textWidth(value) + padX * 2); - const totalWidth = labelWidth + valueWidth; - const height = 20; - const rx = style === "flat-square" ? 0 : 3; - - const safeLabel = escapeXml(label); - const safeValue = escapeXml(value); - const labelTextX = labelWidth / 2; - const valueTextX = labelWidth + valueWidth / 2; - - return ` - ${safeLabel}: ${safeValue} - - - - - - - - - - - - - - - ${safeLabel} - - ${safeValue} - -`; -} - -test("compactNumber covers magnitudes", () => { - assert.equal(compactNumber(0), "0"); - assert.equal(compactNumber(999), "999"); - assert.equal(compactNumber(1500), "1.5K"); - assert.equal(compactNumber(2_500_000), "2.5M"); - assert.equal(compactNumber(9_422_678_431), "9.42B"); - assert.equal(compactNumber(1.5e12), "1.5T"); -}); - -test("formatCost rounds large vs small", () => { - assert.equal(formatCost(0), "$0.00"); - assert.equal(formatCost(0.42), "$0.42"); - assert.equal(formatCost(99.5), "$99.50"); - assert.equal(formatCost(150), "$150"); - assert.equal(formatCost(9414.84), "$9,415"); -}); - -test("renderBadgeSvg produces well-formed SVG with brand color", () => { - const svg = renderBadgeSvg({ - label: "tokens", - value: "9.42B tokens", - style: "flat", - color: BRAND_GREEN, - }); - assert.match(svg, /^$/); - assert.match(svg, /role="img"/); - assert.match(svg, /aria-label="tokens: 9\.42B tokens"/); - assert.match(svg, new RegExp(BRAND_GREEN)); - // width must be deterministic given the label/value - const widthMatch = svg.match(/width="(\d+)"/); - assert.ok(widthMatch); - const w = Number(widthMatch[1]); - // "tokens" -> 6 chars * 6.2 + 12 padding = ~50, "9.42B tokens" -> 12 * 6.2 + 12 = ~87 - assert.ok(w > 100 && w < 200, `unexpected width ${w}`); -}); - -test("renderBadgeSvg escapes XML in label and value", () => { - const svg = renderBadgeSvg({ - label: "