From 7ab98cedee5d451ba7827a2d15e382de4a0989f9 Mon Sep 17 00:00:00 2001 From: Facundo Taboada Date: Mon, 4 May 2026 21:16:53 -0300 Subject: [PATCH 1/5] initial commit --- .vscode/settings.json | 1 + bun.lock | 9 +- example.env | 10 + package.json | 2 +- src/app/page.tsx | 6 + src/app/token-tracker/api/refresh/route.ts | 32 ++++ src/app/token-tracker/api/submit/route.ts | 151 +++++++++++++++ src/app/token-tracker/dashboard/page.tsx | 66 +++++++ src/app/token-tracker/page.tsx | 30 +++ src/token-tracker/README.md | 88 +++++++++ src/token-tracker/actions.ts | 53 ++++++ .../components/dashboard-table.tsx | 166 +++++++++++++++++ src/token-tracker/components/key-form.tsx | 173 ++++++++++++++++++ src/token-tracker/components/model-table.tsx | 50 +++++ .../components/password-form.tsx | 33 ++++ .../components/refresh-button.tsx | 35 ++++ .../components/setup-required.tsx | 67 +++++++ .../components/usage-summary.tsx | 48 +++++ src/token-tracker/config.ts | 22 +++ src/token-tracker/constants.ts | 51 ++++++ src/token-tracker/fetchers.ts | 158 ++++++++++++++++ src/token-tracker/pages/index.tsx | 28 +++ src/token-tracker/pages/landing.tsx | 150 +++++++++++++++ src/token-tracker/refresh.ts | 95 ++++++++++ src/token-tracker/storage.ts | 67 +++++++ src/token-tracker/types.ts | 47 +++++ src/token-tracker/utils.ts | 29 +++ vercel.json | 8 + 28 files changed, 1669 insertions(+), 6 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/app/token-tracker/api/refresh/route.ts create mode 100644 src/app/token-tracker/api/submit/route.ts create mode 100644 src/app/token-tracker/dashboard/page.tsx create mode 100644 src/app/token-tracker/page.tsx create mode 100644 src/token-tracker/README.md create mode 100644 src/token-tracker/actions.ts create mode 100644 src/token-tracker/components/dashboard-table.tsx create mode 100644 src/token-tracker/components/key-form.tsx create mode 100644 src/token-tracker/components/model-table.tsx create mode 100644 src/token-tracker/components/password-form.tsx create mode 100644 src/token-tracker/components/refresh-button.tsx create mode 100644 src/token-tracker/components/setup-required.tsx create mode 100644 src/token-tracker/components/usage-summary.tsx create mode 100644 src/token-tracker/config.ts create mode 100644 src/token-tracker/constants.ts create mode 100644 src/token-tracker/fetchers.ts create mode 100644 src/token-tracker/pages/index.tsx create mode 100644 src/token-tracker/pages/landing.tsx create mode 100644 src/token-tracker/refresh.ts create mode 100644 src/token-tracker/storage.ts create mode 100644 src/token-tracker/types.ts create mode 100644 src/token-tracker/utils.ts create mode 100644 vercel.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/bun.lock b/bun.lock index 130e116..c1bb5f6 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "open-silver", @@ -30,7 +31,7 @@ "@tanstack/react-query": "^5.90.2", "@types/formidable": "^3.4.5", "@types/pdf-parse": "^1.1.5", - "@vercel/blob": "^2.0.0", + "@vercel/blob": "^2.3.0", "adm-zip": "^0.5.16", "ai": "^5.0.61", "autoprefixer": "^10.4.21", @@ -243,8 +244,6 @@ "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], - "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], - "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], @@ -859,7 +858,7 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-XBWpUP0mHya6yGBwNefhyEa6V7HgYKCxEAY4qhTm/PcAQyBPNmjj97VZJOJkVdUsyuuii7xmq0pXWX/c2aToHQ=="], - "@vercel/blob": ["@vercel/blob@2.0.0", "", { "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^5.28.4" } }, "sha512-oAj7Pdy83YKSwIaMFoM7zFeLYWRc+qUpW3PiDSblxQMnGFb43qs4bmfq7dr/+JIfwhs6PTwe1o2YBwKhyjWxXw=="], + "@vercel/blob": ["@vercel/blob@2.3.3", "", { "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^6.23.0" } }, "sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg=="], "@vercel/oidc": ["@vercel/oidc@3.0.2", "", {}, "sha512-JekxQ0RApo4gS4un/iMGsIL1/k4KUBe3HmnGcDvzHuFBdQdudEJgTqcsJC7y6Ul4Yw5CeykgvQbX2XeEJd0+DA=="], @@ -2235,7 +2234,7 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/example.env b/example.env index 69055dc..b7d15ee 100644 --- a/example.env +++ b/example.env @@ -1,6 +1,16 @@ # Vercel BLOB_READ_WRITE_TOKEN="vercel_blob_rw_" +# Token Tracker +# 32-byte hex key for AES-256-GCM: openssl rand -hex 32 +ENCRYPTION_KEY= +# Plain-text password for the /token-tracker/dashboard gate +DASHBOARD_PASSWORD= +# Shared secret required on the submit form; share via /token-tracker?token= +SUBMIT_TOKEN= +# Injected automatically by Vercel for cron auth; set manually in local dev +CRON_SECRET= + # Resend RESEND_KEY=re_ diff --git a/package.json b/package.json index b9986a2..cf6cc75 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@tanstack/react-query": "^5.90.2", "@types/formidable": "^3.4.5", "@types/pdf-parse": "^1.1.5", - "@vercel/blob": "^2.0.0", + "@vercel/blob": "^2.3.0", "adm-zip": "^0.5.16", "ai": "^5.0.61", "autoprefixer": "^10.4.21", diff --git a/src/app/page.tsx b/src/app/page.tsx index 4c514aa..4e44a83 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -102,6 +102,12 @@ const tools: { "Generate invoices for SilverEd or as a Silver.dev Interviewer.", href: "/invoice-generator", }, + { + title: "Token Tracker", + description: + "Submit your API key to fetch token usage and costs across Anthropic and OpenAI.", + href: "/token-tracker", + }, ], }, ]; diff --git a/src/app/token-tracker/api/refresh/route.ts b/src/app/token-tracker/api/refresh/route.ts new file mode 100644 index 0000000..5d8722b --- /dev/null +++ b/src/app/token-tracker/api/refresh/route.ts @@ -0,0 +1,32 @@ +import { getTokenTrackerConfig } from "@/token-tracker/config"; +import { runRefreshAll } from "@/token-tracker/refresh"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(req: NextRequest) { + const { isConfigured } = getTokenTrackerConfig(); + if (!isConfigured) { + return NextResponse.json( + { + error: + "Token Tracker is not configured on this instance. Deploy your own instance to use this feature.", + }, + { status: 503 }, + ); + } + + const authHeader = req.headers.get("authorization"); + if ( + !process.env.CRON_SECRET || + authHeader !== `Bearer ${process.env.CRON_SECRET}` + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const outcomes = await runRefreshAll(); + + return NextResponse.json({ + refreshed: outcomes.filter((o) => o.status === "ok").length, + failed: outcomes.filter((o) => o.status === "error").length, + outcomes, + }); +} diff --git a/src/app/token-tracker/api/submit/route.ts b/src/app/token-tracker/api/submit/route.ts new file mode 100644 index 0000000..695c712 --- /dev/null +++ b/src/app/token-tracker/api/submit/route.ts @@ -0,0 +1,151 @@ +import { BLOB_PREFIX, PROVIDER_CONFIG } from "@/token-tracker/constants"; +import { getTokenTrackerConfig } from "@/token-tracker/config"; +import { fetchProviderUsage } from "@/token-tracker/fetchers"; +import { readReport, writeReport } from "@/token-tracker/storage"; +import type { + ProviderData, + SubmitRequest, + SubmitResponse, + UserReport, +} from "@/token-tracker/types"; +import { + hashEmail, + normalizeEmail, + validateEmail, +} from "@/token-tracker/utils"; +import crypto from "crypto"; +import { NextRequest, NextResponse } from "next/server"; + +const MAX_REQUESTS_PER_WINDOW = 10; +const WINDOW_MS = 60_000; + +const rateLimitMap = new Map(); + +function isRateLimited(ip: string): boolean { + const now = Date.now(); + const entry = rateLimitMap.get(ip); + + if (!entry || now >= entry.resetAt) { + rateLimitMap.set(ip, { count: 1, resetAt: now + WINDOW_MS }); + return false; + } + + if (entry.count >= MAX_REQUESTS_PER_WINDOW) { + return true; + } + + entry.count += 1; + return false; +} + +function encryptKey(apiKey: string): string { + const keyBuffer = Buffer.from(process.env.ENCRYPTION_KEY!, "hex"); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", keyBuffer, iv); + const encrypted = Buffer.concat([ + cipher.update(apiKey, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, encrypted]).toString("base64"); +} + +export async function POST(req: NextRequest) { + const { isConfigured } = getTokenTrackerConfig(); + if (!isConfigured) { + return NextResponse.json( + { + error: + "Token Tracker is not configured on this instance. Deploy your own instance to use this feature.", + }, + { status: 503 }, + ); + } + + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + + if (isRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests. Please wait a moment and try again." }, + { status: 429 }, + ); + } + + const { email, provider, apiKey, teamToken }: SubmitRequest = await req.json(); + + if (!teamToken || teamToken !== process.env.SUBMIT_TOKEN) { + return NextResponse.json( + { error: "Invalid or missing team token." }, + { status: 401 }, + ); + } + + const normalizedEmail = normalizeEmail(email ?? ""); + if (!validateEmail(normalizedEmail)) { + return NextResponse.json( + { error: "A valid work email is required." }, + { status: 400 }, + ); + } + + if (!apiKey) { + return NextResponse.json({ error: "apiKey is required." }, { status: 400 }); + } + + if (!provider || !(provider in PROVIDER_CONFIG)) { + return NextResponse.json( + { error: "Invalid or unsupported provider." }, + { status: 400 }, + ); + } + + const providerConfig = PROVIDER_CONFIG[provider]; + + if (!providerConfig.hasUsageApi) { + return NextResponse.json( + { + status: "not_supported", + error: + "noUsageApiMessage" in providerConfig + ? providerConfig.noUsageApiMessage + : "This provider does not support usage API.", + }, + { status: 422 }, + ); + } + + try { + const result = await fetchProviderUsage(provider, apiKey); + const hashedId = hashEmail(normalizedEmail); + + const providerData: ProviderData = { + encryptedKey: encryptKey(apiKey), + models: result.models, + fetchedAt: result.fetchedAt, + lastSuccessfulFetchAt: result.fetchedAt, + }; + + const existing = await readReport(hashedId); + + const updatedReport: UserReport = { + email: normalizedEmail, + providers: { + ...existing?.providers, + [provider]: providerData, + }, + updatedAt: new Date().toISOString(), + }; + + await writeReport(`${BLOB_PREFIX}/${hashedId}.json`, updatedReport); + + return NextResponse.json({ + email: normalizedEmail, + result, + } satisfies SubmitResponse); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to fetch usage data."; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/token-tracker/dashboard/page.tsx b/src/app/token-tracker/dashboard/page.tsx new file mode 100644 index 0000000..bbafd01 --- /dev/null +++ b/src/app/token-tracker/dashboard/page.tsx @@ -0,0 +1,66 @@ +import { Container } from "@/components/container"; +import { Heading } from "@/components/heading"; +import { Spacer } from "@/components/spacer"; +import { getTokenTrackerConfig } from "@/token-tracker/config"; +import { isAuthenticated } from "@/token-tracker/actions"; +import { DashboardTable } from "@/token-tracker/components/dashboard-table"; +import { PasswordForm } from "@/token-tracker/components/password-form"; +import { RefreshButton } from "@/token-tracker/components/refresh-button"; +import { SetupRequired } from "@/token-tracker/components/setup-required"; +import { listAllReports } from "@/token-tracker/storage"; +import type { UserReport } from "@/token-tracker/types"; + +type Props = { + searchParams: Promise<{ error?: string }>; +}; + +async function getReports(): Promise { + try { + const entries = await listAllReports(); + return entries + .map((e) => e.report) + .sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + } catch { + return []; + } +} + +export default async function DashboardPage({ searchParams }: Props) { + const { isConfigured, missingVars } = getTokenTrackerConfig(); + if (!isConfigured) { + return ; + } + + const authed = await isAuthenticated(); + + if (!authed) { + const { error } = await searchParams; + return ( + + + Token Usage Dashboard + + + + + ); + } + + const reports = await getReports(); + + return ( + +
+ + Token Usage Dashboard + + +
+ + +
+ ); +} diff --git a/src/app/token-tracker/page.tsx b/src/app/token-tracker/page.tsx new file mode 100644 index 0000000..2cf0341 --- /dev/null +++ b/src/app/token-tracker/page.tsx @@ -0,0 +1,30 @@ +import { METADATA } from "@/token-tracker/constants"; +import { getTokenTrackerConfig } from "@/token-tracker/config"; +import { Home } from "@/token-tracker/pages/index"; +import { LandingPage } from "@/token-tracker/pages/landing"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: METADATA.title, + description: METADATA.description, + openGraph: { + title: `${METADATA.title} • Open Silver`, + description: METADATA.description, + type: "website", + }, +}; + +type Props = { + searchParams: Promise<{ token?: string }>; +}; + +export default async function TokenTrackerPage({ searchParams }: Props) { + const { isConfigured } = getTokenTrackerConfig(); + + if (!isConfigured) { + return ; + } + + const { token } = await searchParams; + return ; +} diff --git a/src/token-tracker/README.md b/src/token-tracker/README.md new file mode 100644 index 0000000..108c4e7 --- /dev/null +++ b/src/token-tracker/README.md @@ -0,0 +1,88 @@ +# Token Tracker + +Token Tracker is a self-hosted internal team utility that collects LLM API usage (input/output tokens) from each team member and consolidates it into a shared dashboard. Each organization deploys their own isolated instance — no data leaves your Vercel project. + +## Self-hosted only + +The public Open Silver deployment at [open.silver](https://open.silver) is a **reference implementation only**. It is intentionally not configured and will display a setup-required screen. + +To use Token Tracker, you must deploy your own instance. See [How to deploy](#how-to-deploy) below. + +## What it does + +- Team members submit their work email and provider API key through a simple form. +- The server fetches this month's token usage from the provider's usage API (Anthropic, OpenAI). +- API keys are **AES-256-GCM encrypted** before being written to private blob storage. +- A scheduled cron job (every 6 hours) re-fetches usage for all stored keys so the dashboard stays fresh without requiring manual re-submission. +- The dashboard is protected by a password and shows per-user, per-provider usage with aggregate team metrics. + +## Self-hosted architecture + +``` +[Team member browser] + → POST /token-tracker/api/submit (requires SUBMIT_TOKEN) + → fetches usage from provider API + → encrypts API key + → writes private blob: token-tracker/{sha256(email)}.json + +[Vercel Cron, every 6h] + → GET /token-tracker/api/refresh (requires CRON_SECRET) + → lists all blobs + → decrypts keys, re-fetches usage + → writes updated blobs + +[Dashboard admin browser] + → GET /token-tracker/dashboard (requires DASHBOARD_PASSWORD) + → lists all private blobs + → renders per-user cards + aggregate metrics +``` + +## Required environment variables + +| Variable | Description | +|---|---| +| `ENCRYPTION_KEY` | 32-byte hex string used for AES-256-GCM encryption of API keys | +| `DASHBOARD_PASSWORD` | Password to unlock the `/token-tracker/dashboard` page | +| `SUBMIT_TOKEN` | Shared token required to authorize usage submissions | +| `BLOB_READ_WRITE_TOKEN` | Vercel Blob storage token (set automatically by Vercel when you add a Blob store) | +| `CRON_SECRET` | Secret used by Vercel to authenticate scheduled refresh calls | + +### How to generate ENCRYPTION_KEY + +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +## How to deploy + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fopen-silver%2Fopen-silver) + +1. Click the button above or import the repository manually in Vercel. +2. Add a **Vercel Blob** store to your project (Storage → Create → Blob). This automatically sets `BLOB_READ_WRITE_TOKEN`. +3. Set the remaining environment variables in your project's Settings → Environment Variables: + - `ENCRYPTION_KEY` + - `DASHBOARD_PASSWORD` + - `SUBMIT_TOKEN` + - `CRON_SECRET` +4. Redeploy after setting all variables. + +If any required variable is missing, Token Tracker pages will display a setup-required screen and API routes will return a `503` response instead of failing or exposing broken behavior. + +## Dashboard auth + +The dashboard uses a session cookie (`dashboard-auth`) that stores an SHA-256 hash of `DASHBOARD_PASSWORD`. The cookie is: + +- `httpOnly` — not accessible to JavaScript +- `sameSite: strict` — CSRF-safe +- `secure: true` in production — HTTPS only +- Valid for 30 days + +Password comparisons use `crypto.timingSafeEqual` to prevent timing attacks. + +## Submit token flow + +`SUBMIT_TOKEN` is a shared secret that authorizes team members to submit usage. Share it with your team (e.g., embed it as a URL parameter: `/token-tracker?token=YOUR_TOKEN`). The form auto-fills the team token field from the URL. + +## Isolation + +Each Vercel project is a fully isolated instance. Blobs are stored in that project's Blob store and cannot be accessed from other deployments. Deploy one instance per organization. diff --git a/src/token-tracker/actions.ts b/src/token-tracker/actions.ts new file mode 100644 index 0000000..556ba62 --- /dev/null +++ b/src/token-tracker/actions.ts @@ -0,0 +1,53 @@ +"use server"; + +import { runRefreshAll } from "@/token-tracker/refresh"; +import { dashboardCookieValue } from "@/token-tracker/utils"; +import crypto from "crypto"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; + +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + +export async function isAuthenticated(): Promise { + const expected = dashboardCookieValue(); + if (!expected) return false; + const cookieStore = await cookies(); + const value = cookieStore.get("dashboard-auth")?.value ?? ""; + return timingSafeEqual(value, expected); +} + +export async function verifyDashboardPassword(formData: FormData) { + const submitted = (formData.get("password") as string | null) ?? ""; + const expected = process.env.DASHBOARD_PASSWORD ?? ""; + + const isMatch = + expected.length > 0 && timingSafeEqual(submitted, expected); + + if (isMatch) { + const cookieStore = await cookies(); + cookieStore.set("dashboard-auth", dashboardCookieValue(), { + httpOnly: true, + sameSite: "strict", + path: "/token-tracker/dashboard", + maxAge: 60 * 60 * 24 * 30, + secure: process.env.NODE_ENV === "production", + }); + redirect("/token-tracker/dashboard"); + } + + redirect("/token-tracker/dashboard?error=1"); +} + +export async function refreshAllAction(): Promise { + const authed = await isAuthenticated(); + if (!authed) { + redirect("/token-tracker/dashboard?error=1"); + } + + await runRefreshAll(); + revalidatePath("/token-tracker/dashboard"); +} diff --git a/src/token-tracker/components/dashboard-table.tsx b/src/token-tracker/components/dashboard-table.tsx new file mode 100644 index 0000000..fb08325 --- /dev/null +++ b/src/token-tracker/components/dashboard-table.tsx @@ -0,0 +1,166 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { PROVIDER_CONFIG } from "@/token-tracker/constants"; +import { ModelTable } from "@/token-tracker/components/model-table"; +import { formatTokens } from "@/token-tracker/utils"; +import type { Provider, UserReport } from "@/token-tracker/types"; + +const STALE_HOURS = 12; + +interface DashboardTableProps { + reports: UserReport[]; +} + +interface AggregateMetrics { + totalInputTokens: number; + totalOutputTokens: number; + activeUsers: number; + activeProviders: number; +} + +function computeAggregates(reports: UserReport[]): AggregateMetrics { + let totalInputTokens = 0; + let totalOutputTokens = 0; + let activeProviders = 0; + const userSet = new Set(); + + for (const report of reports) { + for (const [, data] of Object.entries(report.providers)) { + if (!data) continue; + for (const m of data.models) { + totalInputTokens += m.inputTokens; + totalOutputTokens += m.outputTokens; + } + activeProviders += 1; + userSet.add(report.email); + } + } + + return { + totalInputTokens, + totalOutputTokens, + activeUsers: userSet.size, + activeProviders, + }; +} + +function isStale(fetchedAt: string): boolean { + const ageMs = Date.now() - new Date(fetchedAt).getTime(); + return ageMs > STALE_HOURS * 60 * 60 * 1000; +} + +export function DashboardTable({ reports }: DashboardTableProps) { + if (reports.length === 0) { + return ( +

+ No reports submitted yet. +

+ ); + } + + const metrics = computeAggregates(reports); + + return ( +
+
+ + + + +
+ {reports.map((report) => ( + + ))} +
+ ); +} + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function UserCard({ report }: { report: UserReport }) { + const providers = Object.entries(report.providers) as [ + Provider, + NonNullable, + ][]; + + return ( + + + + {report.email} + + Updated {new Date(report.updatedAt).toLocaleString()} + + + + + {providers.map(([provider, data]) => ( + + ))} + + + ); +} + +interface ProviderSectionProps { + provider: Provider; + data: NonNullable; +} + +function ProviderSection({ provider, data }: ProviderSectionProps) { + const label = PROVIDER_CONFIG[provider].label; + const totalInput = data.models.reduce((s, m) => s + m.inputTokens, 0); + const totalOutput = data.models.reduce((s, m) => s + m.outputTokens, 0); + const stale = isStale(data.fetchedAt); + + return ( +
+
+ {label} +
+ {stale && ( + + Stale + + )} + + {formatTokens(totalInput + totalOutput)} tokens · fetched{" "} + {new Date(data.fetchedAt).toLocaleString()} + +
+
+ {data.lastError && ( +
+ Last refresh failed:{" "} + {data.lastError} + {data.lastSuccessfulFetchAt && ( + + · last success{" "} + {new Date(data.lastSuccessfulFetchAt).toLocaleString()} + + )} +
+ )} + {data.models.length === 0 ? ( +

No usage this month.

+ ) : ( + + )} +
+ ); +} diff --git a/src/token-tracker/components/key-form.tsx b/src/token-tracker/components/key-form.tsx new file mode 100644 index 0000000..f8dea1e --- /dev/null +++ b/src/token-tracker/components/key-form.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { PROVIDER_CONFIG } from "@/token-tracker/constants"; +import { UsageSummary } from "@/token-tracker/components/usage-summary"; +import type { Provider, SubmitRequest, SubmitState } from "@/token-tracker/types"; +import { useState } from "react"; + +const PROVIDERS = Object.entries(PROVIDER_CONFIG) as [ + Provider, + (typeof PROVIDER_CONFIG)[Provider], +][]; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +interface KeyFormProps { + initialToken?: string; +} + +export function KeyForm({ initialToken }: KeyFormProps) { + const [email, setEmail] = useState(""); + const [provider, setProvider] = useState("anthropic"); + const [apiKey, setApiKey] = useState(""); + const [teamToken, setTeamToken] = useState(initialToken ?? ""); + const [state, setState] = useState({ status: "idle" }); + + const config = PROVIDER_CONFIG[provider]; + const supportsUsage = config.hasUsageApi; + const isValidEmail = EMAIL_RE.test(email.trim().toLowerCase()); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setState({ status: "loading" }); + + const body: SubmitRequest = { + email: email.trim().toLowerCase(), + provider, + apiKey, + teamToken, + }; + + try { + const res = await fetch("/token-tracker/api/submit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const { error } = await res.json(); + setState({ + status: "error", + message: error ?? "Something went wrong.", + }); + return; + } + + const { email: returnedEmail, result } = await res.json(); + setApiKey(""); + setState({ status: "success", email: returnedEmail, result }); + } catch { + setState({ + status: "error", + message: "Network error. Please try again.", + }); + } + } + + const isLoading = state.status === "loading"; + const canSubmit = + supportsUsage && + isValidEmail && + apiKey.length > 0 && + teamToken.length > 0; + + return ( +
+
+
+ + setEmail(e.target.value)} + placeholder="you@company.com" + required + disabled={isLoading} + /> +
+
+ +
+ {PROVIDERS.map(([value, cfg]) => ( + + ))} +
+
+ {supportsUsage && config.hasUsageApi ? ( + <> +
+ + setApiKey(e.target.value)} + placeholder={config.placeholder} + required + disabled={isLoading} + /> +
+
+ + setTeamToken(e.target.value)} + placeholder="Provided by your team admin" + required + disabled={isLoading} + /> +

+ Ask your team admin for the team token. It authorizes usage + submissions to this instance. +

+
+ + + ) : ( +

+ {"noUsageApiMessage" in config && config.noUsageApiMessage} +

+ )} +
+ + {state.status === "error" && ( +

{state.message}

+ )} + + {state.status === "success" && ( + + )} +
+ ); +} diff --git a/src/token-tracker/components/model-table.tsx b/src/token-tracker/components/model-table.tsx new file mode 100644 index 0000000..00b4d56 --- /dev/null +++ b/src/token-tracker/components/model-table.tsx @@ -0,0 +1,50 @@ +import { formatTokens } from "@/token-tracker/utils"; +import type { ModelUsage, Provider } from "@/token-tracker/types"; + +interface ModelTableProps { + models: ModelUsage[]; + provider: Provider; + compact?: boolean; +} + +export function ModelTable({ models, provider, compact }: ModelTableProps) { + const showCacheWrite = provider === "anthropic"; + const cell = compact ? "py-1" : "py-2"; + + return ( + + + + + + + + {showCacheWrite && ( + + )} + + + + {models.map((m) => ( + + + + + + {showCacheWrite && ( + + )} + + ))} + +
ModelInputOutputCache readCache write
{m.model} + {formatTokens(m.inputTokens)} + + {formatTokens(m.outputTokens)} + + {formatTokens(m.cacheReadTokens)} + + {formatTokens(m.cacheWriteTokens)} +
+ ); +} diff --git a/src/token-tracker/components/password-form.tsx b/src/token-tracker/components/password-form.tsx new file mode 100644 index 0000000..d8125c3 --- /dev/null +++ b/src/token-tracker/components/password-form.tsx @@ -0,0 +1,33 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { verifyDashboardPassword } from "@/token-tracker/actions"; + +interface PasswordFormProps { + hasError: boolean; +} + +export function PasswordForm({ hasError }: PasswordFormProps) { + return ( +
+
+ + +
+ {hasError && ( +

Incorrect password.

+ )} + +
+ ); +} diff --git a/src/token-tracker/components/refresh-button.tsx b/src/token-tracker/components/refresh-button.tsx new file mode 100644 index 0000000..d8681a4 --- /dev/null +++ b/src/token-tracker/components/refresh-button.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { refreshAllAction } from "@/token-tracker/actions"; +import { useState, useTransition } from "react"; + +export function RefreshButton() { + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function handleRefresh() { + setError(null); + startTransition(async () => { + try { + await refreshAllAction(); + } catch { + setError("Refresh failed. Please try again."); + } + }); + } + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/src/token-tracker/components/setup-required.tsx b/src/token-tracker/components/setup-required.tsx new file mode 100644 index 0000000..1c2e75d --- /dev/null +++ b/src/token-tracker/components/setup-required.tsx @@ -0,0 +1,67 @@ +import { Container } from "@/components/container"; +import { Heading } from "@/components/heading"; +import { Spacer } from "@/components/spacer"; +import { Button } from "@/components/ui/button"; + +const DEPLOY_URL = + "https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fopen-silver%2Fopen-silver"; + +const DOCS_URL = + "https://github.com/open-silver/open-silver/blob/main/src/token-tracker/README.md"; + +interface SetupRequiredProps { + missingVars?: string[]; +} + +export function SetupRequired({ missingVars }: SetupRequiredProps) { + return ( + + + Self-Hosted Setup Required + + +
+
+

+ Token Tracker is an internal team utility designed to be + self-hosted. Each organization deploys their own isolated instance + — usage data never leaves your Vercel project. +

+

+ The public Open Silver deployment is a reference implementation + only. To use Token Tracker, deploy your own instance and configure + the required environment variables. +

+
+ + {missingVars && missingVars.length > 0 && ( +
+

Missing environment variables

+
    + {missingVars.map((v) => ( +
  • + {v} +
  • + ))} +
+

+ Set these in your Vercel project settings or local{" "} + .env file, then redeploy. +

+
+ )} + + +
+
+ ); +} diff --git a/src/token-tracker/components/usage-summary.tsx b/src/token-tracker/components/usage-summary.tsx new file mode 100644 index 0000000..2916881 --- /dev/null +++ b/src/token-tracker/components/usage-summary.tsx @@ -0,0 +1,48 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { PROVIDER_CONFIG } from "@/token-tracker/constants"; +import { ModelTable } from "@/token-tracker/components/model-table"; +import { formatTokens } from "@/token-tracker/utils"; +import type { UsageResult } from "@/token-tracker/types"; + +interface UsageSummaryProps { + email: string; + result: UsageResult; +} + +export function UsageSummary({ email, result }: UsageSummaryProps) { + const providerLabel = PROVIDER_CONFIG[result.provider].label; + const totalInput = result.models.reduce((s, m) => s + m.inputTokens, 0); + const totalOutput = result.models.reduce((s, m) => s + m.outputTokens, 0); + + return ( +
+

+ {providerLabel} usage for{" "} + {email} + {" — "} + + {formatTokens(totalInput + totalOutput)} tokens this month + +

+ {result.models.length === 0 ? ( +

+ No usage found for this month. +

+ ) : ( + + + {providerLabel} + + + + + + )} +
+ ); +} diff --git a/src/token-tracker/config.ts b/src/token-tracker/config.ts new file mode 100644 index 0000000..bfd5407 --- /dev/null +++ b/src/token-tracker/config.ts @@ -0,0 +1,22 @@ +const REQUIRED_VARS = [ + "SUBMIT_TOKEN", + "DASHBOARD_PASSWORD", + "ENCRYPTION_KEY", + "BLOB_READ_WRITE_TOKEN", +] as const; + +export type RequiredVar = (typeof REQUIRED_VARS)[number]; + +export function getTokenTrackerConfig(): { + isConfigured: boolean; + missingVars: RequiredVar[]; +} { + const missingVars = REQUIRED_VARS.filter( + (key) => !process.env[key], + ) as RequiredVar[]; + + return { + isConfigured: missingVars.length === 0, + missingVars, + }; +} diff --git a/src/token-tracker/constants.ts b/src/token-tracker/constants.ts new file mode 100644 index 0000000..92176ce --- /dev/null +++ b/src/token-tracker/constants.ts @@ -0,0 +1,51 @@ +import type { Provider } from "@/token-tracker/types"; + +export const METADATA = { + title: "Token Tracker", + description: + "Submit your work email and API key to record this month's usage for your team. Keys are encrypted and refreshed automatically every few hours.", +}; + +export const BLOB_PREFIX = "token-tracker"; + +export const REQUIRED_ENV_VARS = [ + "ENCRYPTION_KEY", + "DASHBOARD_PASSWORD", + "SUBMIT_TOKEN", + "CRON_SECRET", +] as const; + +type ProviderConfig = + | { label: string; hasUsageApi: true; placeholder: string } + | { label: string; hasUsageApi: false; noUsageApiMessage: string }; + +export const PROVIDER_CONFIG: Record = { + anthropic: { + label: "Anthropic", + hasUsageApi: true, + placeholder: "sk-ant-...", + }, + openai: { + label: "OpenAI", + hasUsageApi: true, + placeholder: "sk-...", + }, + gemini: { + label: "Gemini", + hasUsageApi: false, + noUsageApiMessage: + "Google Gemini doesn't expose a usage API. Token counts are only available per-request inside the response metadata — there's no endpoint to query historical usage with an API key.", + }, + grok: { + label: "Grok", + hasUsageApi: false, + noUsageApiMessage: + "xAI Grok doesn't expose a usage API. Token counts are only available per-request inside the response metadata — there's no endpoint to query historical usage with an API key.", + }, +}; + +export const PROVIDERS_WITH_USAGE_API = new Set( + (Object.entries(PROVIDER_CONFIG) as [Provider, ProviderConfig][]) + .filter(([, cfg]) => cfg.hasUsageApi) + .map(([provider]) => provider), +); diff --git a/src/token-tracker/fetchers.ts b/src/token-tracker/fetchers.ts new file mode 100644 index 0000000..7d0ff6b --- /dev/null +++ b/src/token-tracker/fetchers.ts @@ -0,0 +1,158 @@ +import type { ModelUsage, Provider, UsageResult } from "@/token-tracker/types"; + +type AnthropicBucket = { + results: { + model: string | null; + uncached_input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation: { + ephemeral_1h_input_tokens: number; + ephemeral_5m_input_tokens: number; + }; + }[]; +}; + +type AnthropicUsageResponse = { + data: AnthropicBucket[]; + has_more: boolean; +}; + +type OpenAIBucket = { + results: { + model: string | null; + input_tokens: number; + output_tokens: number; + input_cached_tokens: number; + }[]; +}; + +type OpenAIUsageResponse = { + data: OpenAIBucket[]; + has_more: boolean; +}; + +function aggregateModels(map: Map): ModelUsage[] { + return Array.from(map.values()).filter((m) => m.model); +} + +async function fetchAnthropicUsage(apiKey: string): Promise { + const startOfMonth = new Date(); + startOfMonth.setUTCDate(1); + startOfMonth.setUTCHours(0, 0, 0, 0); + + const url = new URL( + "https://api.anthropic.com/v1/organizations/usage_report/messages", + ); + url.searchParams.set("starting_at", startOfMonth.toISOString()); + url.searchParams.set("bucket_width", "1d"); + url.searchParams.append("group_by[]", "model"); + + const res = await fetch(url.toString(), { + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Anthropic API ${res.status}: ${body}`); + } + + const data: AnthropicUsageResponse = await res.json(); + const modelMap = new Map(); + + for (const bucket of data.data) { + for (const r of bucket.results) { + if (!r.model) continue; + const existing = modelMap.get(r.model) ?? { + model: r.model, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }; + existing.inputTokens += r.uncached_input_tokens; + existing.outputTokens += r.output_tokens; + existing.cacheReadTokens += r.cache_read_input_tokens; + existing.cacheWriteTokens += + r.cache_creation.ephemeral_1h_input_tokens + + r.cache_creation.ephemeral_5m_input_tokens; + modelMap.set(r.model, existing); + } + } + + return { + provider: "anthropic", + models: aggregateModels(modelMap), + fetchedAt: new Date().toISOString(), + }; +} + +async function fetchOpenAIUsage(apiKey: string): Promise { + const startOfMonth = new Date(); + startOfMonth.setUTCDate(1); + startOfMonth.setUTCHours(0, 0, 0, 0); + const startTime = Math.floor(startOfMonth.getTime() / 1000); + + const url = new URL( + "https://api.openai.com/v1/organization/usage/completions", + ); + url.searchParams.set("start_time", String(startTime)); + url.searchParams.set("bucket_width", "1d"); + url.searchParams.append("group_by", "model"); + + const res = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`OpenAI API ${res.status}: ${body}`); + } + + const data: OpenAIUsageResponse = await res.json(); + const modelMap = new Map(); + + for (const bucket of data.data) { + for (const r of bucket.results) { + if (!r.model) continue; + const existing = modelMap.get(r.model) ?? { + model: r.model, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }; + existing.inputTokens += r.input_tokens; + existing.outputTokens += r.output_tokens; + existing.cacheReadTokens += r.input_cached_tokens; + modelMap.set(r.model, existing); + } + } + + return { + provider: "openai", + models: aggregateModels(modelMap), + fetchedAt: new Date().toISOString(), + }; +} + +const FETCHERS: Partial Promise>> = { + anthropic: fetchAnthropicUsage, + openai: fetchOpenAIUsage, +}; + +export function fetchProviderUsage( + provider: Provider, + apiKey: string, +): Promise { + const fetcher = FETCHERS[provider]; + if (!fetcher) { + throw new Error(`No usage API implemented for ${provider}.`); + } + return fetcher(apiKey); +} diff --git a/src/token-tracker/pages/index.tsx b/src/token-tracker/pages/index.tsx new file mode 100644 index 0000000..e7c5725 --- /dev/null +++ b/src/token-tracker/pages/index.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Container } from "@/components/container"; +import { Heading, Subheading } from "@/components/heading"; +import { Spacer } from "@/components/spacer"; +import { KeyForm } from "@/token-tracker/components/key-form"; + +interface HomeProps { + initialToken?: string; +} + +export function Home({ initialToken }: HomeProps) { + return ( + + + Token Tracker + + + + Submit your work email and a read-only API key to record this + month's usage for your team. Your key is encrypted securely and + refreshed automatically every few hours. + + + + + ); +} diff --git a/src/token-tracker/pages/landing.tsx b/src/token-tracker/pages/landing.tsx new file mode 100644 index 0000000..3ea67d7 --- /dev/null +++ b/src/token-tracker/pages/landing.tsx @@ -0,0 +1,150 @@ +import { Container } from "@/components/container"; +import { Description } from "@/components/description"; +import { Heading, Subheading } from "@/components/heading"; +import { Spacer } from "@/components/spacer"; +import { Button } from "@/components/ui/button"; +import { PROVIDER_CONFIG } from "@/token-tracker/constants"; + +const DEPLOY_URL = + "https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsilver-dev-org%2Fopen-silver"; + +const GITHUB_URL = "https://github.com/silver-dev-org/open-silver"; + +const steps = [ + { + number: "01", + title: "Deploy your own instance", + description: + "Clone and deploy this repo to Vercel in one click. Configure the required environment variables in your project settings.", + }, + { + number: "02", + title: "Share the submit link", + description: + "Send team members a link with a submit token. They enter their work email and a read-only API key for each provider they use.", + }, + { + number: "03", + title: "Keys are encrypted and stored", + description: + "API keys are encrypted with AES-256 and stored in Vercel Blob. Usage data never leaves your own Vercel project.", + }, + { + number: "04", + title: "Usage refreshes automatically", + description: + "A cron job polls provider APIs every few hours and updates each team member's token consumption. View totals in the dashboard.", + }, +]; + +export function LandingPage() { + return ( + +
+ + Token Tracker + + + + A self-hosted internal tool for teams to track AI token usage across + Anthropic, OpenAI, Gemini, and Grok. + + +

+ The public Open Silver deployment is a reference implementation only + — not a shared hosted service. Deploy your own instance to use it + with your team. +

+ + +
+ + + +
+ + How it works + +
+ {steps.map((step) => ( +
+ + {step.number} + +
+

{step.title}

+

+ {step.description} +

+
+
+ ))} +
+
+ + + +
+ + Supported providers + +
+ {( + Object.entries(PROVIDER_CONFIG) as [ + string, + (typeof PROVIDER_CONFIG)[keyof typeof PROVIDER_CONFIG], + ][] + ).map(([key, config]) => ( +
+ {config.label} + + {config.hasUsageApi ? "Usage API" : "Display only"} + +
+ ))} +
+

+ Providers marked “Display only” do not expose a historical + usage API — token counts are only available per-request in their + response metadata. +

+
+ + + +
+ + Self-hosted + + Why does each organization deploy their own? +
+

+ Token Tracker is designed to be deployed independently by each + organization. Your team's API keys and usage data live entirely + within your own Vercel project — we never see it. +

+

+ Each instance is isolated. There is no central database, no shared + accounts, and no usage data crossing organizational boundaries. You + own the encryption key, the storage, and the dashboard password. +

+
+
+
+ ); +} diff --git a/src/token-tracker/refresh.ts b/src/token-tracker/refresh.ts new file mode 100644 index 0000000..53f4c3c --- /dev/null +++ b/src/token-tracker/refresh.ts @@ -0,0 +1,95 @@ +import { getTokenTrackerConfig } from "@/token-tracker/config"; +import { PROVIDER_CONFIG } from "@/token-tracker/constants"; +import { fetchProviderUsage } from "@/token-tracker/fetchers"; +import { listAllReports, writeReport } from "@/token-tracker/storage"; +import type { Provider, ProviderData, UserReport } from "@/token-tracker/types"; +import crypto from "crypto"; + +export type RefreshOutcome = { + email: string; + provider: Provider; + status: "ok" | "error"; + error?: string; +}; + +function decryptKey(encryptedKey: string): string { + const keyBuffer = Buffer.from(process.env.ENCRYPTION_KEY!, "hex"); + const combined = Buffer.from(encryptedKey, "base64"); + const iv = combined.subarray(0, 12); + const authTag = combined.subarray(12, 28); + const ciphertext = combined.subarray(28); + const decipher = crypto.createDecipheriv("aes-256-gcm", keyBuffer, iv); + decipher.setAuthTag(authTag); + return decipher.update(ciphertext, undefined, "utf8") + decipher.final("utf8"); +} + +async function refreshReport( + report: UserReport, + blobPathname: string, +): Promise { + const outcomes: RefreshOutcome[] = []; + const updatedProviders: UserReport["providers"] = { ...report.providers }; + + await Promise.allSettled( + (Object.entries(report.providers) as [Provider, ProviderData][]).map( + async ([provider, data]) => { + if (!PROVIDER_CONFIG[provider].hasUsageApi) return; + + try { + const apiKey = decryptKey(data.encryptedKey); + const result = await fetchProviderUsage(provider, apiKey); + + const { lastError: _cleared, ...rest } = data; + updatedProviders[provider] = { + ...rest, + models: result.models, + fetchedAt: result.fetchedAt, + lastSuccessfulFetchAt: result.fetchedAt, + }; + outcomes.push({ email: report.email, provider, status: "ok" }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + updatedProviders[provider] = { ...data, lastError: message }; + outcomes.push({ + email: report.email, + provider, + status: "error", + error: message, + }); + } + }, + ), + ); + + const anySuccess = outcomes.some((o) => o.status === "ok"); + + const updatedReport: UserReport = { + ...report, + providers: updatedProviders, + updatedAt: anySuccess ? new Date().toISOString() : report.updatedAt, + }; + + await writeReport(blobPathname, updatedReport); + + return outcomes; +} + +export async function runRefreshAll(): Promise { + const { isConfigured } = getTokenTrackerConfig(); + if (!isConfigured) return []; + + const reportEntries = await listAllReports(); + + const results = await Promise.allSettled( + reportEntries.map(({ report, pathname }) => + refreshReport(report, pathname), + ), + ); + + return results + .filter( + (r): r is PromiseFulfilledResult => + r.status === "fulfilled", + ) + .flatMap((r) => r.value); +} diff --git a/src/token-tracker/storage.ts b/src/token-tracker/storage.ts new file mode 100644 index 0000000..434f422 --- /dev/null +++ b/src/token-tracker/storage.ts @@ -0,0 +1,67 @@ +import { BLOB_PREFIX } from "@/token-tracker/constants"; +import type { UserReport } from "@/token-tracker/types"; +import { get, list, put } from "@vercel/blob"; + +function isValidUserReport(value: unknown): value is UserReport { + return ( + typeof value === "object" && + value !== null && + typeof (value as UserReport).email === "string" && + (value as UserReport).email.includes("@") && + typeof (value as UserReport).providers === "object" && + typeof (value as UserReport).updatedAt === "string" + ); +} + +async function getReportByPathname(pathname: string): Promise { + const result = await get(pathname, { access: "private" }); + if (!result || result.statusCode !== 200) return null; + const text = await new Response(result.stream).text(); + const data: unknown = JSON.parse(text); + return isValidUserReport(data) ? data : null; +} + +export async function readReport(hashedId: string): Promise { + const pathname = `${BLOB_PREFIX}/${hashedId}.json`; + return getReportByPathname(pathname); +} + +export async function writeReport( + pathname: string, + report: UserReport, +): Promise { + await put(pathname, JSON.stringify(report), { + access: "private", + contentType: "application/json", + addRandomSuffix: false, + }); +} + +export async function listAllReports(): Promise< + { report: UserReport; pathname: string }[] +> { + const { blobs } = await list({ prefix: `${BLOB_PREFIX}/` }); + + const results = await Promise.allSettled( + blobs.map(async (blob) => { + const data = await getReportByPathname(blob.pathname); + if (!data) { + console.warn(`[token-tracker] Skipping invalid blob: ${blob.pathname}`); + return null; + } + return { report: data, pathname: blob.pathname }; + }), + ); + + return results + .filter( + ( + r, + ): r is PromiseFulfilledResult<{ + report: UserReport; + pathname: string; + } | null> => r.status === "fulfilled", + ) + .map((r) => r.value) + .filter((r): r is { report: UserReport; pathname: string } => r !== null); +} diff --git a/src/token-tracker/types.ts b/src/token-tracker/types.ts new file mode 100644 index 0000000..a92da95 --- /dev/null +++ b/src/token-tracker/types.ts @@ -0,0 +1,47 @@ +export type Provider = "anthropic" | "openai" | "gemini" | "grok"; + +export type ModelUsage = { + model: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; +}; + +export type ProviderData = { + encryptedKey: string; + models: ModelUsage[]; + fetchedAt: string; + lastSuccessfulFetchAt?: string; + lastError?: string; +}; + +export type UserReport = { + email: string; + providers: Partial>; + updatedAt: string; +}; + +export type UsageResult = { + provider: Provider; + models: ModelUsage[]; + fetchedAt: string; +}; + +export type SubmitRequest = { + email: string; + provider: Provider; + apiKey: string; + teamToken: string; +}; + +export type SubmitResponse = { + email: string; + result: UsageResult; +}; + +export type SubmitState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; email: string; result: UsageResult } + | { status: "error"; message: string }; diff --git a/src/token-tracker/utils.ts b/src/token-tracker/utils.ts new file mode 100644 index 0000000..564f6d4 --- /dev/null +++ b/src/token-tracker/utils.ts @@ -0,0 +1,29 @@ +import crypto from "crypto"; + +export function dashboardCookieValue(): string { + const password = process.env.DASHBOARD_PASSWORD; + if (!password) return ""; + return crypto.createHash("sha256").update(password).digest("hex"); +} + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function validateEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +export function hashEmail(email: string): string { + return crypto.createHash("sha256").update(email).digest("hex"); +} + +export function formatTokens(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}K`; + } + return String(value); +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..bcfc610 --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/token-tracker/api/refresh", + "schedule": "0 */6 * * *" + } + ] +} From b0f71e7085dc081f69a221974fae9d947ea6fd11 Mon Sep 17 00:00:00 2001 From: Facundo Taboada Date: Tue, 5 May 2026 11:33:56 -0300 Subject: [PATCH 2/5] feat: add token-tracker tool with local collectors and MCP server Self-hosted AI token usage tracker. Backend in Next.js with password-protected dashboard and Vercel Blob storage. Local collectors (CLI and MCP server) read usage from Claude Code, Codex CLI, and Gemini CLI logs and report to the backend. Published to npm as @ftaboadac/silver-tracker and @ftaboadac/silver-tracker-mcp. --- .gitignore | 9 +- cli/bun.lock | 102 +++++++ cli/package.json | 36 +++ cli/src/collector.ts | 36 +++ cli/src/config.ts | 47 +++ cli/src/index.ts | 45 +++ cli/src/parsers/claude-code.ts | 111 +++++++ cli/src/parsers/codex.ts | 219 ++++++++++++++ cli/src/parsers/cursor.ts | 174 +++++++++++ cli/src/parsers/gemini.ts | 187 ++++++++++++ cli/src/pricing.ts | 111 +++++++ cli/src/reporter.ts | 31 ++ cli/src/types.ts | 27 ++ cli/tsconfig.json | 14 + example.env | 8 +- mcp/bun.lock | 282 ++++++++++++++++++ mcp/package.json | 40 +++ mcp/src/index.ts | 114 +++++++ mcp/tsconfig.json | 14 + src/app/token-tracker/api/refresh/route.ts | 32 -- src/app/token-tracker/api/report/route.ts | 92 ++++++ src/app/token-tracker/api/submit/route.ts | 151 ---------- src/app/token-tracker/dashboard/page.tsx | 10 +- src/app/token-tracker/page.tsx | 17 +- src/token-tracker/README.md | 236 +++++++++++---- src/token-tracker/actions.ts | 12 - .../components/dashboard-table.tsx | 114 +++---- src/token-tracker/components/key-form.tsx | 173 ----------- src/token-tracker/components/model-table.tsx | 26 +- .../components/refresh-button.tsx | 35 --- .../components/usage-summary.tsx | 48 --- src/token-tracker/config.ts | 23 +- src/token-tracker/constants.ts | 46 +-- src/token-tracker/fetchers.ts | 158 ---------- src/token-tracker/pages/index.tsx | 28 -- src/token-tracker/pages/landing.tsx | 182 ++++++----- src/token-tracker/refresh.ts | 95 ------ src/token-tracker/storage.ts | 73 ++++- src/token-tracker/types.ts | 42 +-- tsconfig.json | 2 +- vercel.json | 9 +- 41 files changed, 2111 insertions(+), 1100 deletions(-) create mode 100644 cli/bun.lock create mode 100644 cli/package.json create mode 100644 cli/src/collector.ts create mode 100644 cli/src/config.ts create mode 100644 cli/src/index.ts create mode 100644 cli/src/parsers/claude-code.ts create mode 100644 cli/src/parsers/codex.ts create mode 100644 cli/src/parsers/cursor.ts create mode 100644 cli/src/parsers/gemini.ts create mode 100644 cli/src/pricing.ts create mode 100644 cli/src/reporter.ts create mode 100644 cli/src/types.ts create mode 100644 cli/tsconfig.json create mode 100644 mcp/bun.lock create mode 100644 mcp/package.json create mode 100644 mcp/src/index.ts create mode 100644 mcp/tsconfig.json delete mode 100644 src/app/token-tracker/api/refresh/route.ts create mode 100644 src/app/token-tracker/api/report/route.ts delete mode 100644 src/app/token-tracker/api/submit/route.ts delete mode 100644 src/token-tracker/components/key-form.tsx delete mode 100644 src/token-tracker/components/refresh-button.tsx delete mode 100644 src/token-tracker/components/usage-summary.tsx delete mode 100644 src/token-tracker/fetchers.ts delete mode 100644 src/token-tracker/pages/index.tsx delete mode 100644 src/token-tracker/refresh.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..bba4a1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules +**/node_modules/ /.pnp .pnp.* .yarn/* @@ -36,6 +36,13 @@ yarn-error.log* # vercel .vercel +# token-tracker local blob mock +/.token-tracker-local/ + # typescript *.tsbuildinfo next-env.d.ts + +# compiled output for standalone packages +/cli/dist +/mcp/dist diff --git a/cli/bun.lock b/cli/bun.lock new file mode 100644 index 0000000..03f374a --- /dev/null +++ b/cli/bun.lock @@ -0,0 +1,102 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "silver-tracker", + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22", + "typescript": "^5.4.0", + }, + "optionalDependencies": { + "better-sqlite3": "^12.9.0", + }, + }, + }, + "packages": { + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], + + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "better-sqlite3": ["better-sqlite3@12.9.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "node-abi": ["node-abi@3.90.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..eecefc1 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,36 @@ +{ + "name": "@ftaboadac/silver-tracker", + "version": "0.1.0", + "description": "CLI to sync Claude Code token usage to a Silver dashboard", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ftaboadac/open-silver.git", + "directory": "cli" + }, + "type": "module", + "bin": { + "silver-tracker": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18.17.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22", + "typescript": "^5.4.0" + }, + "optionalDependencies": { + "better-sqlite3": "^12.9.0" + } +} diff --git a/cli/src/collector.ts b/cli/src/collector.ts new file mode 100644 index 0000000..bee0191 --- /dev/null +++ b/cli/src/collector.ts @@ -0,0 +1,36 @@ +import { parseClaudeCodeUsage } from "./parsers/claude-code.js"; +import { parseCodexUsage } from "./parsers/codex.js"; +import { parseCursorUsage } from "./parsers/cursor.js"; +import { parseGeminiUsage } from "./parsers/gemini.js"; +import type { ModelUsage, SourceReport, UsageSource } from "./types.js"; + +const PARSERS: Array<{ source: UsageSource; parse: () => Promise }> = [ + { source: "claude-code", parse: parseClaudeCodeUsage }, + { source: "cursor", parse: parseCursorUsage }, + { source: "codex", parse: parseCodexUsage }, + { source: "gemini-cli", parse: parseGeminiUsage }, +]; + +export async function collectUsage(): Promise { + const now = new Date().toISOString(); + const results = await Promise.allSettled(PARSERS.map(({ parse }) => parse())); + const sources: SourceReport[] = []; + + for (let i = 0; i < PARSERS.length; i++) { + const { source } = PARSERS[i]; + const result = results[i]; + + if (result.status === "rejected") { + const message = result.reason instanceof Error ? result.reason.message : String(result.reason); + console.error(`Warning (${source}): ${message}`); + continue; + } + + const models = result.value; + if (models.length === 0) continue; + + sources.push({ source, models, lastSyncedAt: now }); + } + + return sources; +} diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 0000000..ec32c65 --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,47 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { createInterface } from "readline/promises"; + +export type Config = { + email: string; + backendUrl: string; + submitToken: string; +}; + +const CONFIG_DIR = path.join(os.homedir(), ".silver-tracker"); +const CONFIG_PATH = path.join(CONFIG_DIR, "config.json"); + +export async function readConfig(): Promise { + try { + const text = await fs.readFile(CONFIG_PATH, "utf8"); + return JSON.parse(text) as Config; + } catch { + return null; + } +} + +export async function writeConfig(config: Config): Promise { + await fs.mkdir(CONFIG_DIR, { recursive: true }); + await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), "utf8"); +} + +async function promptForConfig(): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const email = (await rl.question("Email: ")).trim(); + const backendUrl = (await rl.question("Backend URL (e.g. https://yourapp.vercel.app): ")).trim().replace(/\/$/, ""); + const submitToken = (await rl.question("Submit token: ")).trim(); + rl.close(); + return { email, backendUrl, submitToken }; +} + +export async function loadOrInitConfig(): Promise { + const existing = await readConfig(); + if (existing) return existing; + + console.log("First run — please provide your Silver Tracker credentials.\n"); + const config = await promptForConfig(); + await writeConfig(config); + console.log(`\nConfig saved to ${CONFIG_PATH}\n`); + return config; +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..1c8b99e --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { loadOrInitConfig } from "./config.js"; +import { collectUsage } from "./collector.js"; +import { postReport } from "./reporter.js"; +import type { UserReport } from "./types.js"; + +const args = process.argv.slice(2); +const command = args.find((a) => !a.startsWith("-")) ?? "sync"; +const dryRun = args.includes("--dry-run"); + +if (command !== "sync") { + console.error(`Unknown command: ${command}`); + console.error("Usage: silver-tracker [sync] [--dry-run]"); + process.exit(1); +} + +async function main() { + const config = await loadOrInitConfig(); + const sources = await collectUsage(); + + if (sources.length === 0) { + console.log("No usage data found. Make sure you've used Claude Code, Cursor, Codex CLI, or Gemini CLI on this machine."); + process.exit(1); + } + + const report: UserReport = { + email: config.email, + sources, + updatedAt: new Date().toISOString(), + }; + + if (dryRun) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + await postReport(config, report); + const sourceNames = sources.map((s) => s.source).join(", "); + console.log(`Reported usage from ${sources.length} source(s): ${sourceNames}`); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/cli/src/parsers/claude-code.ts b/cli/src/parsers/claude-code.ts new file mode 100644 index 0000000..1fabb30 --- /dev/null +++ b/cli/src/parsers/claude-code.ts @@ -0,0 +1,111 @@ +import type { Dirent } from "fs"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { aggregateToModelUsage } from "../pricing.js"; +import type { ModelUsage } from "../types.js"; + +const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); +const SKIP_IF_MODIFIED_WITHIN_MS = 30_000; + +type MessageUsage = { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; +}; + +type AssistantMessage = { + id: string; + model: string; + usage: MessageUsage; +}; + +type AssistantEntry = { + type: "assistant"; + message: AssistantMessage; +}; + +function isAssistantEntry(value: unknown): value is AssistantEntry { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + if (v["type"] !== "assistant") return false; + const msg = v["message"]; + if (typeof msg !== "object" || msg === null) return false; + const m = msg as Record; + if (typeof m["id"] !== "string" || typeof m["model"] !== "string") return false; + const usage = m["usage"]; + return typeof usage === "object" && usage !== null; +} + +async function findJsonlFiles(dir: string): Promise { + const files: string[] = []; + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return files; + } + await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nested = await findJsonlFiles(fullPath); + files.push(...nested); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + files.push(fullPath); + } + }), + ); + return files; +} + +async function parseFile( + filePath: string, + seen: Map, +): Promise { + try { + const stat = await fs.stat(filePath); + if (Date.now() - stat.mtimeMs < SKIP_IF_MODIFIED_WITHIN_MS) return; + } catch { + return; + } + + let text: string; + try { + text = await fs.readFile(filePath, "utf8"); + } catch { + return; + } + + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + if (!isAssistantEntry(parsed)) continue; + // Overwrite on duplicate id — last occurrence has the most complete usage snapshot + seen.set(parsed.message.id, parsed.message); + } +} + +export async function parseClaudeCodeUsage(): Promise { + const files = await findJsonlFiles(CLAUDE_PROJECTS_DIR); + const seen = new Map(); + + await Promise.all(files.map((f) => parseFile(f, seen))); + + const rawUsages = Array.from(seen.values()).map((msg) => ({ + model: msg.model, + inputTokens: msg.usage.input_tokens, + outputTokens: msg.usage.output_tokens, + cacheWriteTokens: msg.usage.cache_creation_input_tokens ?? 0, + cacheReadTokens: msg.usage.cache_read_input_tokens ?? 0, + })); + + return aggregateToModelUsage(rawUsages); +} diff --git a/cli/src/parsers/codex.ts b/cli/src/parsers/codex.ts new file mode 100644 index 0000000..964edeb --- /dev/null +++ b/cli/src/parsers/codex.ts @@ -0,0 +1,219 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { aggregateToModelUsage } from "../pricing.js"; +import type { ModelUsage } from "../types.js"; + +type CodexTokenUsage = { + input_tokens?: number; + cached_input_tokens?: number; + output_tokens?: number; + reasoning_output_tokens?: number; + total_tokens?: number; +}; + +type CodexEntry = { + type: string; + timestamp?: string; + payload?: { + type?: string; + model?: string; + session_id?: string; + originator?: string; + info?: { + model?: string; + model_name?: string; + last_token_usage?: CodexTokenUsage; + total_token_usage?: CodexTokenUsage; + }; + }; +}; + +function getCodexSessionsDir(): string { + const codexHome = + process.env["CODEX_HOME"] ?? path.join(os.homedir(), ".codex"); + return path.join(codexHome, "sessions"); +} + +async function findRollupFiles(sessionsDir: string): Promise { + const files: string[] = []; + let years: string[]; + try { + years = await fs.readdir(sessionsDir); + } catch { + return files; + } + for (const year of years) { + if (!/^\d{4}$/.test(year)) continue; + const yearDir = path.join(sessionsDir, year); + let months: string[]; + try { + months = await fs.readdir(yearDir); + } catch { + continue; + } + for (const month of months) { + if (!/^\d{2}$/.test(month)) continue; + const monthDir = path.join(yearDir, month); + let days: string[]; + try { + days = await fs.readdir(monthDir); + } catch { + continue; + } + for (const day of days) { + if (!/^\d{2}$/.test(day)) continue; + const dayDir = path.join(monthDir, day); + let dayFiles: string[]; + try { + dayFiles = await fs.readdir(dayDir); + } catch { + continue; + } + for (const file of dayFiles) { + if (file.startsWith("rollup-") && file.endsWith(".jsonl")) { + files.push(path.join(dayDir, file)); + } + } + } + } + } + return files; +} + +type RawUsage = { + model: string; + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; +}; + +async function parseFile(filePath: string): Promise { + let text: string; + try { + text = await fs.readFile(filePath, "utf8"); + } catch { + return []; + } + + const entries: CodexEntry[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + entries.push(JSON.parse(trimmed) as CodexEntry); + } catch { + continue; + } + } + + // Pass 1: track the active model at each entry index. + // Model info flows from session_meta and turn_context entries + // (turn_context carries model at payload.model or payload.info.model). + let currentModel: string | undefined; + const modelAtIndex: (string | undefined)[] = new Array(entries.length); + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (e.type === "session_meta" && e.payload?.model) { + currentModel = e.payload.model; + } else if (e.type === "turn_context") { + const m = + e.payload?.model ?? + e.payload?.info?.model ?? + e.payload?.info?.model_name; + if (m) currentModel = m; + } + modelAtIndex[i] = currentModel; + } + + // Pass 2: extract token counts from event_msg/token_count entries and + // correlate them with the model context captured in pass 1. + const usages: RawUsage[] = []; + let prevCumulativeTotal = 0; + let prevInput = 0; + let prevCached = 0; + let prevOutput = 0; + let prevReasoning = 0; + + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (e.type !== "event_msg" || e.payload?.type !== "token_count") continue; + + const info = e.payload.info; + if (!info) continue; + + // Skip intermediate duplicates — same cumulative total means no new tokens + const cumulativeTotal = info.total_token_usage?.total_tokens ?? 0; + if (cumulativeTotal > 0 && cumulativeTotal === prevCumulativeTotal) continue; + prevCumulativeTotal = cumulativeTotal; + + const last = info.last_token_usage; + let inputTokens = 0; + let cachedInputTokens = 0; + let outputTokens = 0; + let reasoningTokens = 0; + + if (last) { + // Per-turn delta is directly available + inputTokens = last.input_tokens ?? 0; + cachedInputTokens = last.cached_input_tokens ?? 0; + outputTokens = last.output_tokens ?? 0; + reasoningTokens = last.reasoning_output_tokens ?? 0; + } else if (cumulativeTotal > 0) { + // Derive delta from cumulative totals + const total = info.total_token_usage; + if (!total) continue; + inputTokens = (total.input_tokens ?? 0) - prevInput; + cachedInputTokens = (total.cached_input_tokens ?? 0) - prevCached; + outputTokens = (total.output_tokens ?? 0) - prevOutput; + reasoningTokens = (total.reasoning_output_tokens ?? 0) - prevReasoning; + } + + // Advance cumulative baselines only when using the total_token_usage path + if (!last) { + const total = info.total_token_usage; + if (total) { + prevInput = total.input_tokens ?? 0; + prevCached = total.cached_input_tokens ?? 0; + prevOutput = total.output_tokens ?? 0; + prevReasoning = total.reasoning_output_tokens ?? 0; + } + } + + if (inputTokens + cachedInputTokens + outputTokens + reasoningTokens === 0) + continue; + + // Resolve model: try the token_count entry's own payload fields first, + // then fall back to the context captured in pass 1 + const model = + e.payload.model ?? + info.model ?? + info.model_name ?? + modelAtIndex[i] ?? + "gpt-5"; + + // cached_input_tokens is a subset of input_tokens (same as Gemini) + const freshInput = Math.max(0, inputTokens - cachedInputTokens); + + usages.push({ + model, + inputTokens: freshInput, + // reasoning tokens are billed at the output rate + outputTokens: outputTokens + reasoningTokens, + cacheWriteTokens: 0, + cacheReadTokens: cachedInputTokens, + }); + } + + return usages; +} + +export async function parseCodexUsage(): Promise { + const sessionsDir = getCodexSessionsDir(); + const files = await findRollupFiles(sessionsDir); + if (files.length === 0) return []; + + const allResults = await Promise.all(files.map(parseFile)); + return aggregateToModelUsage(allResults.flat()); +} diff --git a/cli/src/parsers/cursor.ts b/cli/src/parsers/cursor.ts new file mode 100644 index 0000000..4e23526 --- /dev/null +++ b/cli/src/parsers/cursor.ts @@ -0,0 +1,174 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { aggregateToModelUsage } from "../pricing.js"; +import type { ModelUsage } from "../types.js"; + +// Cursor 3.1+ schema change — why this parser returns no data for modern installs: +// +// In Cursor ≤2.x, each assistant turn wrote real token counts to cursorDiskKV: +// bubbleId:: → { tokenCount: { inputTokens, outputTokens }, +// modelInfo: { modelName }, ... } +// The BUBBLE_QUERY below reads exactly those fields and still works for ≤2.x users. +// +// Starting in Cursor 3.1, conversation data was moved to per-composer AES-256-GCM +// encrypted blobs stored as agentKv:blob: entries in the same table. The +// bubbleId rows are still written but tokenCount is always {inputTokens:0, outputTokens:0} +// and modelInfo is null. The plain-JSON agentKv entries (role/content pairs) only carry a +// requestId in providerOptions — no model name, no token counts. The encryption key lives +// in composerData.blobEncryptionKey but the key→blob mapping is not documented. +// +// Content-length estimation (~4 chars/token) was evaluated and rejected: ±30–50% error, +// no model attribution, and no cache read/write data would produce misleading figures when +// displayed alongside precise Claude Code numbers on the dashboard. +// +// When the DB is found but returns zero token rows (the 3.1+ case), this function throws +// so the CLI can emit a clear diagnostic rather than silently omitting Cursor from the report. +// The source remains in the UsageSource enum so support can be re-enabled if Cursor exposes +// local token data again. + +function getDbPath(): string { + const home = os.homedir(); + if (process.platform === "darwin") { + return path.join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb"); + } + if (process.platform === "win32") { + return path.join(home, "AppData", "Roaming", "Cursor", "User", "globalStorage", "state.vscdb"); + } + return path.join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb"); +} + +interface SqliteStatement { + all(...params: (string | number | null)[]): Record[]; +} + +interface SqliteDb { + prepare(sql: string): SqliteStatement; + close(): void; +} + +async function openDb(dbPath: string): Promise { + try { + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(dbPath, { readOnly: true }); + return { + prepare(sql: string): SqliteStatement { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stmt = db.prepare(sql) as any; + return { + all(...params) { + return stmt.all(...params) as Record[]; + }, + }; + }, + close() { + db.close(); + }, + }; + } catch {} + + const mod = await import("better-sqlite3"); + const BetterSqlite3 = mod.default; + const db = new BetterSqlite3(dbPath, { readonly: true, fileMustExist: true }); + return { + prepare(sql: string): SqliteStatement { + const stmt = db.prepare(sql); + return { + all(...params) { + return stmt.all(...params) as Record[]; + }, + }; + }, + close() { + db.close(); + }, + }; +} + +type KvRow = { + input_tokens: number | null; + output_tokens: number | null; + model: string | null; + created_at: string | null; + conversation_id: string | null; +}; + +const BUBBLE_QUERY = ` + SELECT + json_extract(value, '$.tokenCount.inputTokens') AS input_tokens, + json_extract(value, '$.tokenCount.outputTokens') AS output_tokens, + json_extract(value, '$.modelInfo.modelName') AS model, + json_extract(value, '$.createdAt') AS created_at, + json_extract(value, '$.conversationId') AS conversation_id + FROM cursorDiskKV + WHERE key LIKE 'bubbleId:%' + AND json_valid(value) +`; + +function toNumber(v: unknown): number { + if (typeof v === "number") return v; + if (typeof v === "bigint") return Number(v); + return 0; +} + +function processRows(rows: Record[], seen: Set): { + model: string; + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; +}[] { + const usages = []; + for (const row of rows) { + const r = row as unknown as KvRow; + const inputTokens = toNumber(r.input_tokens); + const outputTokens = toNumber(r.output_tokens); + + if (inputTokens === 0 && outputTokens === 0) continue; + + const conversationId = r.conversation_id ?? "unknown"; + const createdAt = r.created_at ?? ""; + const dedupKey = `${conversationId}:${createdAt}:${inputTokens}:${outputTokens}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + + const model = r.model ?? "cursor-auto"; + usages.push({ model, inputTokens, outputTokens, cacheWriteTokens: 0, cacheReadTokens: 0 }); + } + return usages; +} + +export async function parseCursorUsage(): Promise { + const dbPath = getDbPath(); + + try { + await fs.access(dbPath); + } catch { + return []; + } + + let db: SqliteDb; + try { + db = await openDb(dbPath); + } catch { + return []; + } + + try { + const seen = new Set(); + const bubbleRows = db.prepare(BUBBLE_QUERY).all(); + const result = aggregateToModelUsage(processRows(bubbleRows, seen)); + + if (result.length === 0) { + throw new Error( + "Cursor 3.1+ no longer exposes accurate token data locally — skipping. " + + "Token counts in state.vscdb are zeroed out in this version; " + + "usage lives server-side only at cursor.com/dashboard/usage." + ); + } + + return result; + } finally { + db.close(); + } +} diff --git a/cli/src/parsers/gemini.ts b/cli/src/parsers/gemini.ts new file mode 100644 index 0000000..e73e73e --- /dev/null +++ b/cli/src/parsers/gemini.ts @@ -0,0 +1,187 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { aggregateToModelUsage } from "../pricing.js"; +import type { ModelUsage } from "../types.js"; + +const GEMINI_TMP_DIR = path.join(os.homedir(), ".gemini", "tmp"); + +type GeminiTokens = { + input?: number; + output?: number; + cached?: number; + thoughts?: number; +}; + +type GeminiMessage = { + type: string; + tokens?: GeminiTokens; + model?: string; +}; + +type GeminiSession = { + sessionId: string; + messages: GeminiMessage[]; +}; + +type SessionResult = { + sessionId: string; + model: string; + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; +}; + +function extractSessionUsage(session: GeminiSession): SessionResult | null { + const geminiMsgs = session.messages.filter( + (m) => m.type === "gemini" && m.tokens != null && m.model, + ); + if (geminiMsgs.length === 0) return null; + + let model = ""; + let totalInput = 0; + let totalOutput = 0; + let totalCached = 0; + + for (const msg of geminiMsgs) { + const t = msg.tokens!; + if (!model && msg.model) model = msg.model; + totalInput += t.input ?? 0; + // thoughts tokens are billed as output + totalOutput += (t.output ?? 0) + (t.thoughts ?? 0); + totalCached += t.cached ?? 0; + } + + if (totalInput === 0 && totalOutput === 0) return null; + + // tokens.input includes cached tokens as a subset — subtract to avoid double-charging + const freshInput = Math.max(0, totalInput - totalCached); + + return { + sessionId: session.sessionId, + model, + inputTokens: freshInput, + outputTokens: totalOutput, + cacheWriteTokens: 0, + cacheReadTokens: totalCached, + }; +} + +function tryParseAsJson(raw: string): GeminiSession | null { + try { + const parsed = JSON.parse(raw) as Record; + if ( + typeof parsed["sessionId"] === "string" && + Array.isArray(parsed["messages"]) + ) { + return parsed as unknown as GeminiSession; + } + } catch { + // not valid JSON or wrong shape + } + return null; +} + +function tryParseAsJsonl(raw: string): GeminiSession | null { + const lines = raw.split("\n").filter((l) => l.trim()); + if (lines.length === 0) return null; + + let sessionId = ""; + const messages: GeminiMessage[] = []; + + for (const line of lines) { + let obj: Record; + try { + obj = JSON.parse(line) as Record; + } catch { + continue; + } + // skip MongoDB-style update operations that Gemini CLI may write + if (obj["$set"] !== undefined) continue; + if (typeof obj["sessionId"] === "string" && !sessionId) { + sessionId = obj["sessionId"] as string; + } else if (typeof obj["type"] === "string") { + messages.push(obj as unknown as GeminiMessage); + } + } + + if (!sessionId) return null; + return { sessionId, messages }; +} + +async function parseFile(filePath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, "utf8"); + } catch { + return null; + } + + // Try single JSON first (Gemini CLI <= 0.38), then JSONL (>= 0.39) + const session = tryParseAsJson(raw) ?? tryParseAsJsonl(raw); + if (!session) return null; + + return extractSessionUsage(session); +} + +async function findSessionFiles(): Promise { + const files: string[] = []; + let projectDirs: string[]; + try { + const entries = await fs.readdir(GEMINI_TMP_DIR, { withFileTypes: true }); + projectDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return files; + } + + for (const project of projectDirs) { + const chatsDir = path.join(GEMINI_TMP_DIR, project, "chats"); + let chatFiles: string[]; + try { + const entries = await fs.readdir(chatsDir); + chatFiles = entries.filter( + (f) => + f.startsWith("session-") && + (f.endsWith(".json") || f.endsWith(".jsonl")), + ); + } catch { + continue; + } + for (const file of chatFiles) { + files.push(path.join(chatsDir, file)); + } + } + return files; +} + +export async function parseGeminiUsage(): Promise { + const files = await findSessionFiles(); + if (files.length === 0) return []; + + const allResults = await Promise.all(files.map(parseFile)); + + // Deduplicate by sessionId in case both .json and .jsonl exist for the same session + const seen = new Set(); + const rawUsages: { + model: string; + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; + }[] = []; + + for (const result of allResults) { + if (!result || seen.has(result.sessionId)) continue; + seen.add(result.sessionId); + rawUsages.push({ + model: result.model, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + cacheWriteTokens: result.cacheWriteTokens, + cacheReadTokens: result.cacheReadTokens, + }); + } + + return aggregateToModelUsage(rawUsages); +} diff --git a/cli/src/pricing.ts b/cli/src/pricing.ts new file mode 100644 index 0000000..ae6b269 --- /dev/null +++ b/cli/src/pricing.ts @@ -0,0 +1,111 @@ +import type { ModelUsage } from "./types.js"; + +type ModelPrice = { + inputPerMTok: number; + outputPerMTok: number; +}; + +// USD per million tokens — update when Anthropic/Google changes API pricing +const MODEL_PRICES: Record = { + "claude-opus-4": { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + "claude-sonnet-4": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-haiku-4": { inputPerMTok: 0.8, outputPerMTok: 4.0 }, + "claude-3-5-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-3-5-haiku": { inputPerMTok: 0.8, outputPerMTok: 4.0 }, + "claude-3-opus": { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + "claude-3-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-3-haiku": { inputPerMTok: 0.25, outputPerMTok: 1.25 }, + // OpenAI (API pricing, May 2026) + "gpt-5": { inputPerMTok: 1.25, outputPerMTok: 10.0 }, + "gpt-5-mini": { inputPerMTok: 0.25, outputPerMTok: 2.0 }, + "gpt-4.1": { inputPerMTok: 2.0, outputPerMTok: 8.0 }, + "gpt-4.1-mini": { inputPerMTok: 0.40, outputPerMTok: 1.60 }, + "gpt-4.1-nano": { inputPerMTok: 0.10, outputPerMTok: 0.40 }, + "gpt-4o": { inputPerMTok: 2.50, outputPerMTok: 10.0 }, + "gpt-4o-mini": { inputPerMTok: 0.15, outputPerMTok: 0.60 }, + "o3": { inputPerMTok: 2.0, outputPerMTok: 8.0 }, + "o4-mini": { inputPerMTok: 1.10, outputPerMTok: 4.40 }, + // Gemini (Google AI API pricing, May 2026) + "gemini-2.5-pro": { inputPerMTok: 1.25, outputPerMTok: 10.0 }, + "gemini-2.5-flash": { inputPerMTok: 0.30, outputPerMTok: 2.50 }, + "gemini-2.0-flash": { inputPerMTok: 0.10, outputPerMTok: 0.40 }, + "gemini-1.5-pro": { inputPerMTok: 1.25, outputPerMTok: 5.0 }, + "gemini-1.5-flash": { inputPerMTok: 0.075, outputPerMTok: 0.30 }, + "gemini-1.0-pro": { inputPerMTok: 0.50, outputPerMTok: 1.50 }, + // Cursor-specific model names — dot-notation Claude variants and alternate families + // Claude 3.x (Cursor uses dot notation; the existing entries use dash notation) + "claude-3.5-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-3.5-haiku": { inputPerMTok: 0.80, outputPerMTok: 4.0 }, + "claude-3.7-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + // Claude 4 family (Cursor uses "claude-4-*" while Anthropic API uses "claude-*-4") + "claude-4-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-4-opus": { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + "claude-4.5-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-4.5-opus": { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + // Cursor-native models (pricing not publicly disclosed; conservative estimates) + "cursor-small": { inputPerMTok: 0.20, outputPerMTok: 0.80 }, + "cursor-fast": { inputPerMTok: 0.20, outputPerMTok: 0.80 }, + "cursor-auto": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + // Legacy GPT (may appear in older Cursor history) + "gpt-4-turbo": { inputPerMTok: 10.0, outputPerMTok: 30.0 }, + "gpt-4": { inputPerMTok: 30.0, outputPerMTok: 60.0 }, +}; + +const FALLBACK_PRICE: ModelPrice = { inputPerMTok: 3.0, outputPerMTok: 15.0 }; + +function priceForModel(model: string): ModelPrice { + let best: ModelPrice | null = null; + let bestLen = 0; + for (const [prefix, price] of Object.entries(MODEL_PRICES)) { + if (model.startsWith(prefix) && prefix.length > bestLen) { + best = price; + bestLen = prefix.length; + } + } + return best ?? FALLBACK_PRICE; +} + +export function calculateCost( + model: string, + inputTokens: number, + outputTokens: number, + cacheWriteTokens: number, + cacheReadTokens: number, +): number { + const { inputPerMTok, outputPerMTok } = priceForModel(model); + return ( + (inputTokens / 1_000_000) * inputPerMTok + + (outputTokens / 1_000_000) * outputPerMTok + + (cacheWriteTokens / 1_000_000) * inputPerMTok * 1.25 + + (cacheReadTokens / 1_000_000) * inputPerMTok * 0.1 + ); +} + +type RawUsage = { + model: string; + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; +}; + +export function aggregateToModelUsage(usages: RawUsage[]): ModelUsage[] { + const map = new Map(); + for (const u of usages) { + const existing = map.get(u.model); + if (existing) { + existing.inputTokens += u.inputTokens; + existing.outputTokens += u.outputTokens; + existing.cacheWriteTokens += u.cacheWriteTokens; + existing.cacheReadTokens += u.cacheReadTokens; + } else { + map.set(u.model, { ...u }); + } + } + return Array.from(map.values()) + .filter((u) => u.inputTokens + u.outputTokens + u.cacheWriteTokens + u.cacheReadTokens > 0) + .map((u) => ({ + ...u, + costUsd: calculateCost(u.model, u.inputTokens, u.outputTokens, u.cacheWriteTokens, u.cacheReadTokens), + })); +} diff --git a/cli/src/reporter.ts b/cli/src/reporter.ts new file mode 100644 index 0000000..89da97e --- /dev/null +++ b/cli/src/reporter.ts @@ -0,0 +1,31 @@ +import type { Config } from "./config.js"; +import type { UserReport } from "./types.js"; + +export async function postReport(config: Config, report: UserReport): Promise { + const url = `${config.backendUrl}/token-tracker/api/report`; + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.submitToken}`, + }, + body: JSON.stringify(report), + }); + } catch (err) { + const cause = + err instanceof Error && err.cause instanceof Error + ? ((err.cause as { code?: string }).code ?? err.cause.message) || err.message + : err instanceof Error + ? err.message + : String(err); + throw new Error(`Failed to POST to ${url}: ${cause}`); + } + + if (!response.ok) { + const body = await response.text(); + throw new Error(`POST ${url} → ${response.status}: ${body}`); + } +} diff --git a/cli/src/types.ts b/cli/src/types.ts new file mode 100644 index 0000000..2f4a99f --- /dev/null +++ b/cli/src/types.ts @@ -0,0 +1,27 @@ +export type UsageSource = + | "claude-code" + | "cursor" + | "codex" + | "gemini-cli" + | "aider"; + +export type ModelUsage = { + model: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + costUsd: number; +}; + +export type SourceReport = { + source: UsageSource; + models: ModelUsage[]; + lastSyncedAt: string; +}; + +export type UserReport = { + email: string; + sources: SourceReport[]; + updatedAt: string; +}; diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..6e27728 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/example.env b/example.env index b7d15ee..146d071 100644 --- a/example.env +++ b/example.env @@ -2,14 +2,16 @@ BLOB_READ_WRITE_TOKEN="vercel_blob_rw_" # Token Tracker -# 32-byte hex key for AES-256-GCM: openssl rand -hex 32 -ENCRYPTION_KEY= # Plain-text password for the /token-tracker/dashboard gate DASHBOARD_PASSWORD= -# Shared secret required on the submit form; share via /token-tracker?token= +# Shared secret that collectors use to authenticate report submissions; share via /token-tracker?token= SUBMIT_TOKEN= # Injected automatically by Vercel for cron auth; set manually in local dev CRON_SECRET= +# Local dev only: when set, src/token-tracker/storage.ts writes blobs to this +# directory instead of calling @vercel/blob. Lets you run Token Tracker +# locally without a real Vercel Blob store. +TOKEN_TRACKER_LOCAL_BLOB_DIR=./.token-tracker-local # Resend RESEND_KEY=re_ diff --git a/mcp/bun.lock b/mcp/bun.lock new file mode 100644 index 0000000..da95d43 --- /dev/null +++ b/mcp/bun.lock @@ -0,0 +1,282 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "silver-tracker-mcp", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.23.0", + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22", + "typescript": "^5.4.0", + }, + "optionalDependencies": { + "better-sqlite3": "^12.9.0", + }, + }, + }, + "packages": { + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], + + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "better-sqlite3": ["better-sqlite3@12.9.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.0", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "hono": ["hono@4.12.17", "", {}, "sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-abi": ["node-abi@3.90.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + } +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..5d83a77 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,40 @@ +{ + "name": "@ftaboadac/silver-tracker-mcp", + "version": "0.1.0", + "description": "MCP server for Silver Tracker — reports AI token usage from local tools to your dashboard", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ftaboadac/open-silver.git", + "directory": "mcp" + }, + "type": "module", + "bin": { + "silver-tracker-mcp": "dist/mcp/src/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18.17.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22", + "typescript": "^5.4.0" + }, + "optionalDependencies": { + "better-sqlite3": "^12.9.0" + } +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts new file mode 100644 index 0000000..59e0579 --- /dev/null +++ b/mcp/src/index.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { readConfig } from "../../cli/src/config.js"; +import { collectUsage } from "../../cli/src/collector.js"; +import { postReport } from "../../cli/src/reporter.js"; +import type { UserReport } from "../../cli/src/types.js"; + +const InputSchema = z.object({}); + +const server = new Server( + { name: "silver-tracker", version: "0.1.0" }, + { capabilities: { tools: {} } } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "report_token_usage", + description: + "Collect AI token usage from local tools (Claude Code, Cursor, Codex, Gemini CLI) and report it to the Silver Tracker dashboard.", + inputSchema: { + type: "object", + properties: {}, + required: [], + }, + }, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name !== "report_token_usage") { + throw new Error(`Unknown tool: ${request.params.name}`); + } + + InputSchema.parse(request.params.arguments ?? {}); + + const config = await readConfig(); + if (!config) { + return { + content: [ + { + type: "text" as const, + text: "No Silver Tracker config found at ~/.silver-tracker/config.json. Run `silver-tracker sync` once to set up credentials.", + }, + ], + }; + } + + const sources = await collectUsage(); + + if (sources.length === 0) { + return { + content: [ + { + type: "text" as const, + text: "No usage data detected. Make sure you have used Claude Code, Cursor, Codex CLI, or Gemini CLI on this machine.", + }, + ], + }; + } + + const report: UserReport = { + email: config.email, + sources, + updatedAt: new Date().toISOString(), + }; + + try { + await postReport(config, report); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [ + { + type: "text" as const, + text: `Failed to report token usage: ${message}`, + }, + ], + }; + } + + const sourceSummaries = sources.map((s) => { + const totalTokens = s.models.reduce( + (sum, m) => sum + m.inputTokens + m.outputTokens + m.cacheReadTokens + m.cacheWriteTokens, + 0 + ); + const totalCost = s.models.reduce((sum, m) => sum + m.costUsd, 0); + const tokensK = Math.round(totalTokens / 1000); + return `${s.source} (${tokensK}K tokens, $${totalCost.toFixed(2)})`; + }); + + const totalCost = sources.reduce( + (sum, s) => sum + s.models.reduce((ms, m) => ms + m.costUsd, 0), + 0 + ); + + return { + content: [ + { + type: "text" as const, + text: `Reported token usage to Silver Tracker. Sources: ${sourceSummaries.join(", ")}. Total: $${totalCost.toFixed(2)}`, + }, + ], + }; +}); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 0000000..af8fdb6 --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "..", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/src/app/token-tracker/api/refresh/route.ts b/src/app/token-tracker/api/refresh/route.ts deleted file mode 100644 index 5d8722b..0000000 --- a/src/app/token-tracker/api/refresh/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getTokenTrackerConfig } from "@/token-tracker/config"; -import { runRefreshAll } from "@/token-tracker/refresh"; -import { NextRequest, NextResponse } from "next/server"; - -export async function GET(req: NextRequest) { - const { isConfigured } = getTokenTrackerConfig(); - if (!isConfigured) { - return NextResponse.json( - { - error: - "Token Tracker is not configured on this instance. Deploy your own instance to use this feature.", - }, - { status: 503 }, - ); - } - - const authHeader = req.headers.get("authorization"); - if ( - !process.env.CRON_SECRET || - authHeader !== `Bearer ${process.env.CRON_SECRET}` - ) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const outcomes = await runRefreshAll(); - - return NextResponse.json({ - refreshed: outcomes.filter((o) => o.status === "ok").length, - failed: outcomes.filter((o) => o.status === "error").length, - outcomes, - }); -} diff --git a/src/app/token-tracker/api/report/route.ts b/src/app/token-tracker/api/report/route.ts new file mode 100644 index 0000000..e36915c --- /dev/null +++ b/src/app/token-tracker/api/report/route.ts @@ -0,0 +1,92 @@ +import { readReport, writeReport } from "@/token-tracker/storage"; +import { hashEmail, normalizeEmail } from "@/token-tracker/utils"; +import { timingSafeEqual } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const SourceReportSchema = z.object({ + source: z.enum(["claude-code", "cursor", "codex", "gemini-cli", "aider"]), + models: z.array( + z.object({ + model: z.string(), + inputTokens: z.number(), + outputTokens: z.number(), + cacheReadTokens: z.number(), + cacheWriteTokens: z.number(), + costUsd: z.number(), + }), + ), + lastSyncedAt: z.string().datetime(), +}); + +const UserReportSchema = z.object({ + email: z.string().email(), + sources: z.array(SourceReportSchema), + updatedAt: z.string().datetime(), +}); + +function checkAuth(request: NextRequest): boolean { + const submitToken = process.env.SUBMIT_TOKEN; + if (!submitToken) return false; + + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) return false; + + const token = authHeader.slice(7); + try { + const a = Buffer.from(token); + const b = Buffer.from(submitToken); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); + } catch { + return false; + } +} + +export async function POST(request: NextRequest) { + if (!checkAuth(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = UserReportSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const incoming = parsed.data; + const email = normalizeEmail(incoming.email); + const hashedEmail = hashEmail(email); + const pathname = `token-tracker/${hashedEmail}.json`; + + const existing = await readReport(hashedEmail); + + const mergedSourcesMap = new Map( + (existing?.sources ?? []).map((s) => [s.source, s]), + ); + const sourcesUpdated: string[] = []; + + for (const source of incoming.sources) { + mergedSourcesMap.set(source.source, source); + sourcesUpdated.push(source.source); + } + + const updatedReport = { + email, + sources: Array.from(mergedSourcesMap.values()), + updatedAt: new Date().toISOString(), + }; + + await writeReport(pathname, updatedReport); + + return NextResponse.json({ ok: true, email, sourcesUpdated }); +} diff --git a/src/app/token-tracker/api/submit/route.ts b/src/app/token-tracker/api/submit/route.ts deleted file mode 100644 index 695c712..0000000 --- a/src/app/token-tracker/api/submit/route.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { BLOB_PREFIX, PROVIDER_CONFIG } from "@/token-tracker/constants"; -import { getTokenTrackerConfig } from "@/token-tracker/config"; -import { fetchProviderUsage } from "@/token-tracker/fetchers"; -import { readReport, writeReport } from "@/token-tracker/storage"; -import type { - ProviderData, - SubmitRequest, - SubmitResponse, - UserReport, -} from "@/token-tracker/types"; -import { - hashEmail, - normalizeEmail, - validateEmail, -} from "@/token-tracker/utils"; -import crypto from "crypto"; -import { NextRequest, NextResponse } from "next/server"; - -const MAX_REQUESTS_PER_WINDOW = 10; -const WINDOW_MS = 60_000; - -const rateLimitMap = new Map(); - -function isRateLimited(ip: string): boolean { - const now = Date.now(); - const entry = rateLimitMap.get(ip); - - if (!entry || now >= entry.resetAt) { - rateLimitMap.set(ip, { count: 1, resetAt: now + WINDOW_MS }); - return false; - } - - if (entry.count >= MAX_REQUESTS_PER_WINDOW) { - return true; - } - - entry.count += 1; - return false; -} - -function encryptKey(apiKey: string): string { - const keyBuffer = Buffer.from(process.env.ENCRYPTION_KEY!, "hex"); - const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv("aes-256-gcm", keyBuffer, iv); - const encrypted = Buffer.concat([ - cipher.update(apiKey, "utf8"), - cipher.final(), - ]); - const authTag = cipher.getAuthTag(); - return Buffer.concat([iv, authTag, encrypted]).toString("base64"); -} - -export async function POST(req: NextRequest) { - const { isConfigured } = getTokenTrackerConfig(); - if (!isConfigured) { - return NextResponse.json( - { - error: - "Token Tracker is not configured on this instance. Deploy your own instance to use this feature.", - }, - { status: 503 }, - ); - } - - const ip = - req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; - - if (isRateLimited(ip)) { - return NextResponse.json( - { error: "Too many requests. Please wait a moment and try again." }, - { status: 429 }, - ); - } - - const { email, provider, apiKey, teamToken }: SubmitRequest = await req.json(); - - if (!teamToken || teamToken !== process.env.SUBMIT_TOKEN) { - return NextResponse.json( - { error: "Invalid or missing team token." }, - { status: 401 }, - ); - } - - const normalizedEmail = normalizeEmail(email ?? ""); - if (!validateEmail(normalizedEmail)) { - return NextResponse.json( - { error: "A valid work email is required." }, - { status: 400 }, - ); - } - - if (!apiKey) { - return NextResponse.json({ error: "apiKey is required." }, { status: 400 }); - } - - if (!provider || !(provider in PROVIDER_CONFIG)) { - return NextResponse.json( - { error: "Invalid or unsupported provider." }, - { status: 400 }, - ); - } - - const providerConfig = PROVIDER_CONFIG[provider]; - - if (!providerConfig.hasUsageApi) { - return NextResponse.json( - { - status: "not_supported", - error: - "noUsageApiMessage" in providerConfig - ? providerConfig.noUsageApiMessage - : "This provider does not support usage API.", - }, - { status: 422 }, - ); - } - - try { - const result = await fetchProviderUsage(provider, apiKey); - const hashedId = hashEmail(normalizedEmail); - - const providerData: ProviderData = { - encryptedKey: encryptKey(apiKey), - models: result.models, - fetchedAt: result.fetchedAt, - lastSuccessfulFetchAt: result.fetchedAt, - }; - - const existing = await readReport(hashedId); - - const updatedReport: UserReport = { - email: normalizedEmail, - providers: { - ...existing?.providers, - [provider]: providerData, - }, - updatedAt: new Date().toISOString(), - }; - - await writeReport(`${BLOB_PREFIX}/${hashedId}.json`, updatedReport); - - return NextResponse.json({ - email: normalizedEmail, - result, - } satisfies SubmitResponse); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to fetch usage data."; - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/token-tracker/dashboard/page.tsx b/src/app/token-tracker/dashboard/page.tsx index bbafd01..2713531 100644 --- a/src/app/token-tracker/dashboard/page.tsx +++ b/src/app/token-tracker/dashboard/page.tsx @@ -5,7 +5,6 @@ import { getTokenTrackerConfig } from "@/token-tracker/config"; import { isAuthenticated } from "@/token-tracker/actions"; import { DashboardTable } from "@/token-tracker/components/dashboard-table"; import { PasswordForm } from "@/token-tracker/components/password-form"; -import { RefreshButton } from "@/token-tracker/components/refresh-button"; import { SetupRequired } from "@/token-tracker/components/setup-required"; import { listAllReports } from "@/token-tracker/storage"; import type { UserReport } from "@/token-tracker/types"; @@ -53,12 +52,9 @@ export default async function DashboardPage({ searchParams }: Props) { return ( -
- - Token Usage Dashboard - - -
+ + Token Usage Dashboard +
diff --git a/src/app/token-tracker/page.tsx b/src/app/token-tracker/page.tsx index 2cf0341..477b0d4 100644 --- a/src/app/token-tracker/page.tsx +++ b/src/app/token-tracker/page.tsx @@ -1,6 +1,4 @@ import { METADATA } from "@/token-tracker/constants"; -import { getTokenTrackerConfig } from "@/token-tracker/config"; -import { Home } from "@/token-tracker/pages/index"; import { LandingPage } from "@/token-tracker/pages/landing"; import type { Metadata } from "next"; @@ -14,17 +12,6 @@ export const metadata: Metadata = { }, }; -type Props = { - searchParams: Promise<{ token?: string }>; -}; - -export default async function TokenTrackerPage({ searchParams }: Props) { - const { isConfigured } = getTokenTrackerConfig(); - - if (!isConfigured) { - return ; - } - - const { token } = await searchParams; - return ; +export default function TokenTrackerPage() { + return ; } diff --git a/src/token-tracker/README.md b/src/token-tracker/README.md index 108c4e7..524d0db 100644 --- a/src/token-tracker/README.md +++ b/src/token-tracker/README.md @@ -1,88 +1,210 @@ # Token Tracker -Token Tracker is a self-hosted internal team utility that collects LLM API usage (input/output tokens) from each team member and consolidates it into a shared dashboard. Each organization deploys their own isolated instance — no data leaves your Vercel project. +Token Tracker — Self-hosted AI token usage tracking for teams. -## Self-hosted only +## Overview -The public Open Silver deployment at [open.silver](https://open.silver) is a **reference implementation only**. It is intentionally not configured and will display a setup-required screen. +Token Tracker gives engineering managers and team leads visibility into how much AI tooling their team is actually using, across Claude Code, Codex CLI, and Gemini CLI. Each developer runs a small local collector (CLI or MCP server) that reads the usage logs already written to their machine by those tools. No provider API keys or admin credentials are involved at any point. -To use Token Tracker, you must deploy your own instance. See [How to deploy](#how-to-deploy) below. +Each organization deploys their own isolated instance to Vercel. Reports from team members are sent directly to that instance and stored in the project's own Vercel Blob store. The backend never processes data from other organizations, and Anthropic never sees it. -## What it does +The key design tradeoff is that precision requires local log access. Token counts come from the actual log files written by each tool, not from provider usage APIs, which means numbers are exact (including cache tokens) and available immediately after a session ends — but they depend on those log files being present on the developer's machine. -- Team members submit their work email and provider API key through a simple form. -- The server fetches this month's token usage from the provider's usage API (Anthropic, OpenAI). -- API keys are **AES-256-GCM encrypted** before being written to private blob storage. -- A scheduled cron job (every 6 hours) re-fetches usage for all stored keys so the dashboard stays fresh without requiring manual re-submission. -- The dashboard is protected by a password and shows per-user, per-provider usage with aggregate team metrics. +## Architecture -## Self-hosted architecture +``` +[Developer's machine] + local collector (CLI or MCP) + → reads ~/.claude/, ~/.codex/, ~/.gemini/ session logs + → POST /token-tracker/api/report (Authorization: Bearer SUBMIT_TOKEN) + +[Vercel — your project] + /token-tracker/api/report + → validates SUBMIT_TOKEN + → writes token-tracker/{sha256(email)}.json to Vercel Blob + +[Dashboard — password-protected] + /token-tracker/dashboard + → reads all blobs from Vercel Blob + → renders per-user, per-model usage with aggregate team metrics +``` + +Each user's data is stored as a single JSON file keyed by a SHA-256 hash of their email. Submitting a new report overwrites the previous one. + +## Supported sources + +| Source | Status | Notes | +|---|---|---| +| Claude Code | ✅ Full support | Reads `~/.claude/projects/**/*.jsonl`; real token counts and cache data | +| Codex CLI | ✅ Full support | Reads `~/.codex/` session files | +| Gemini CLI | ✅ Full support | Reads `~/.gemini/` session files | +| Cursor | ⚠️ ≤2.x only | See below | + +### Cursor 3.1+ limitation + +Cursor **≤2.x** writes per-turn token counts and model names to `state.vscdb` (the `cursorDiskKV` table, `bubbleId:*` keys). The collector reads these correctly. + +Cursor **3.1+** stopped writing token counts to local storage. Conversation data is now stored as AES-256-GCM encrypted blobs (`agentKv:blob:*` entries). The `bubbleId` rows remain but `tokenCount` is always `{inputTokens:0, outputTokens:0}` and `modelInfo` is null. As a result, the collector prints a warning and skips Cursor on 3.1+ installs: ``` -[Team member browser] - → POST /token-tracker/api/submit (requires SUBMIT_TOKEN) - → fetches usage from provider API - → encrypts API key - → writes private blob: token-tracker/{sha256(email)}.json - -[Vercel Cron, every 6h] - → GET /token-tracker/api/refresh (requires CRON_SECRET) - → lists all blobs - → decrypts keys, re-fetches usage - → writes updated blobs - -[Dashboard admin browser] - → GET /token-tracker/dashboard (requires DASHBOARD_PASSWORD) - → lists all private blobs - → renders per-user cards + aggregate metrics +Warning (cursor): Cursor 3.1+ no longer exposes accurate token data locally — skipping. ``` -## Required environment variables +Content-length estimation was considered and rejected: ±30–50% error, no model attribution, and no cache data would produce misleading numbers when mixed with precise Claude Code figures on the same dashboard. Cursor 3.1+ users should check their usage at [cursor.com/dashboard/usage](https://cursor.com/dashboard/usage) directly. If Cursor re-exposes local token data in a future version, the `cursor` source will be re-enabled without dashboard schema changes. + +## Deploying your own instance + +The public deployment at [open.silver](https://open.silver) is a reference implementation only — it is intentionally unconfigured and will display a setup-required screen. + +### 1. Deploy to Vercel + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsilver-dev-org%2Fopen-silver) + +Click the button above, or import `https://github.com/silver-dev-org/open-silver` manually from the Vercel dashboard. + +### 2. Add a Vercel Blob store + +In your Vercel project: **Storage → Create → Blob**. This automatically sets the `BLOB_READ_WRITE_TOKEN` environment variable. See [Vercel Blob docs](https://vercel.com/docs/storage/vercel-blob) for details. + +### 3. Set environment variables + +In **Settings → Environment Variables**, add: | Variable | Description | |---|---| -| `ENCRYPTION_KEY` | 32-byte hex string used for AES-256-GCM encryption of API keys | | `DASHBOARD_PASSWORD` | Password to unlock the `/token-tracker/dashboard` page | -| `SUBMIT_TOKEN` | Shared token required to authorize usage submissions | -| `BLOB_READ_WRITE_TOKEN` | Vercel Blob storage token (set automatically by Vercel when you add a Blob store) | -| `CRON_SECRET` | Secret used by Vercel to authenticate scheduled refresh calls | +| `SUBMIT_TOKEN` | Shared secret that team members use to authenticate report submissions | +| `BLOB_READ_WRITE_TOKEN` | Set automatically by Vercel when you add a Blob store | + +Redeploy after setting all variables. If any required variable is missing, Token Tracker pages will display a setup-required screen and API routes will return `503`. -### How to generate ENCRYPTION_KEY +### 4. Share the submit token with your team -```bash -node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +`SUBMIT_TOKEN` is a shared secret — it authorizes anyone who has it to submit usage. The recommended way to distribute it is as a URL parameter on the landing page: + +``` +https://yourapp.vercel.app/token-tracker?token=YOUR_SUBMIT_TOKEN ``` -## How to deploy +The install flow pre-fills the token field from the URL, so team members only need to paste their email and confirm. -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fopen-silver%2Fopen-silver) +## Installing for team members -1. Click the button above or import the repository manually in Vercel. -2. Add a **Vercel Blob** store to your project (Storage → Create → Blob). This automatically sets `BLOB_READ_WRITE_TOKEN`. -3. Set the remaining environment variables in your project's Settings → Environment Variables: - - `ENCRYPTION_KEY` - - `DASHBOARD_PASSWORD` - - `SUBMIT_TOKEN` - - `CRON_SECRET` -4. Redeploy after setting all variables. +Each developer installs the collector once. The MCP path is preferred because their AI agent can trigger a sync automatically; the CLI fallback works in any environment. -If any required variable is missing, Token Tracker pages will display a setup-required screen and API routes will return a `503` response instead of failing or exposing broken behavior. +**Claude Code** +```sh +claude mcp add silver-tracker -- npx -y @ftaboadac/silver-tracker-mcp +``` + +**Codex CLI** +```sh +codex mcp add silver-tracker -- npx -y @ftaboadac/silver-tracker-mcp +``` + +**Cursor** — edit `~/.cursor/mcp.json`: +```json +{ + "mcpServers": { + "silver-tracker": { + "command": "npx", + "args": ["-y", "@ftaboadac/silver-tracker-mcp"] + } + } +} +``` + +**Gemini CLI** — edit `~/.gemini/settings.json`: +```json +{ + "mcpServers": { + "silver-tracker": { + "command": "npx", + "args": ["-y", "@ftaboadac/silver-tracker-mcp"] + } + } +} +``` -## Dashboard auth +**CLI fallback** (any environment without MCP support): +```sh +npx -y @ftaboadac/silver-tracker +``` + +### First-run setup + +On first invocation, the CLI or MCP server will prompt for three values and save them to `~/.silver-tracker/config.json`: + +- **Email** — the developer's work email (used as a unique identifier on the dashboard) +- **Backend URL** — your Vercel deployment URL, e.g. `https://yourapp.vercel.app` +- **Submit token** — the `SUBMIT_TOKEN` from your deployment + +Subsequent runs use the saved config without prompting. + +## Usage + +### Via MCP + +Tell your AI agent: + +> Report my token usage + +The MCP server will collect local usage data and submit it to the configured backend. + +### Via CLI + +```sh +# Collect and submit usage +silver-tracker + +# or explicitly +silver-tracker sync + +# Preview what would be submitted without sending +silver-tracker sync --dry-run +``` + +`--dry-run` prints the collected report as JSON to stdout and exits without making a network request. Useful for verifying what data will be sent before your first real submission. + +## Local development + +### Backend + +```sh +# Set up a local blob store (skips Vercel Blob entirely) +export TOKEN_TRACKER_LOCAL_BLOB_DIR=/tmp/silver-tracker-blobs + +bun dev +``` + +`TOKEN_TRACKER_LOCAL_BLOB_DIR` redirects all blob reads and writes to the local filesystem at the given path. This eliminates the need for a Vercel Blob token during development — set it alongside `DASHBOARD_PASSWORD` and you have a fully functional local backend. + +### CLI / MCP against a local backend + +Set the backend URL to your local server during first-run setup, or edit `~/.silver-tracker/config.json` directly: + +```json +{ + "email": "you@example.com", + "backendUrl": "http://localhost:3000", + "submitToken": "any-local-value" +} +``` -The dashboard uses a session cookie (`dashboard-auth`) that stores an SHA-256 hash of `DASHBOARD_PASSWORD`. The cookie is: +Then run `silver-tracker sync --dry-run` to verify collection without hitting the network, or drop `--dry-run` to test a full round-trip. -- `httpOnly` — not accessible to JavaScript -- `sameSite: strict` — CSRF-safe -- `secure: true` in production — HTTPS only -- Valid for 30 days +## Privacy and security -Password comparisons use `crypto.timingSafeEqual` to prevent timing attacks. +- **No provider API keys.** The collector reads local log files only — it never calls Anthropic, OpenAI, or Google APIs. +- **Data isolation.** Each organization's data lives in their own Vercel Blob store. There is no shared backend. +- **Authorized writes only.** The `/token-tracker/api/report` endpoint requires `Authorization: Bearer SUBMIT_TOKEN`. Without it, submissions are rejected with `401`. +- **Protected dashboard.** The dashboard requires `DASHBOARD_PASSWORD`. The session cookie is `httpOnly`, `sameSite: strict`, and `secure` in production. Password comparisons use `crypto.timingSafeEqual` to prevent timing attacks. +- **Local-only collection.** Collectors only read files that the developer's AI tools have already written to their own machine. Nothing is installed that intercepts or proxies tool traffic. -## Submit token flow +## Limitations and known issues -`SUBMIT_TOKEN` is a shared secret that authorizes team members to submit usage. Share it with your team (e.g., embed it as a URL parameter: `/token-tracker?token=YOUR_TOKEN`). The form auto-fills the team token field from the URL. +**Cursor 3.1+** — covered in [Supported sources](#supported-sources) above. -## Isolation +**Pricing data is hardcoded.** Cost estimates are calculated from a static table in [`cli/src/pricing.ts`](../../../cli/src/pricing.ts). If a provider changes their pricing or releases a new model that isn't in the table, cost figures will be wrong. Update the `MODEL_PRICES` object in that file when prices change. Unknown models fall back to a Sonnet-equivalent estimate. -Each Vercel project is a fully isolated instance. Blobs are stored in that project's Blob store and cannot be accessed from other deployments. Deploy one instance per organization. +**Token counts require local logs.** If a developer clears their tool's local history, or uses a tool on a machine other than the one running the collector, those sessions won't appear in their report. diff --git a/src/token-tracker/actions.ts b/src/token-tracker/actions.ts index 556ba62..276579a 100644 --- a/src/token-tracker/actions.ts +++ b/src/token-tracker/actions.ts @@ -1,11 +1,9 @@ "use server"; -import { runRefreshAll } from "@/token-tracker/refresh"; import { dashboardCookieValue } from "@/token-tracker/utils"; import crypto from "crypto"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { revalidatePath } from "next/cache"; function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; @@ -41,13 +39,3 @@ export async function verifyDashboardPassword(formData: FormData) { redirect("/token-tracker/dashboard?error=1"); } - -export async function refreshAllAction(): Promise { - const authed = await isAuthenticated(); - if (!authed) { - redirect("/token-tracker/dashboard?error=1"); - } - - await runRefreshAll(); - revalidatePath("/token-tracker/dashboard"); -} diff --git a/src/token-tracker/components/dashboard-table.tsx b/src/token-tracker/components/dashboard-table.tsx index fb08325..d243eb8 100644 --- a/src/token-tracker/components/dashboard-table.tsx +++ b/src/token-tracker/components/dashboard-table.tsx @@ -1,10 +1,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { PROVIDER_CONFIG } from "@/token-tracker/constants"; import { ModelTable } from "@/token-tracker/components/model-table"; import { formatTokens } from "@/token-tracker/utils"; -import type { Provider, UserReport } from "@/token-tracker/types"; - -const STALE_HOURS = 12; +import type { SourceReport, UserReport } from "@/token-tracker/types"; interface DashboardTableProps { reports: UserReport[]; @@ -14,40 +11,36 @@ interface AggregateMetrics { totalInputTokens: number; totalOutputTokens: number; activeUsers: number; - activeProviders: number; } +const SOURCE_LABELS: Record = { + "claude-code": "Claude Code", + cursor: "Cursor", + codex: "Codex", + "gemini-cli": "Gemini CLI", + aider: "Aider", +}; + function computeAggregates(reports: UserReport[]): AggregateMetrics { let totalInputTokens = 0; let totalOutputTokens = 0; - let activeProviders = 0; - const userSet = new Set(); for (const report of reports) { - for (const [, data] of Object.entries(report.providers)) { - if (!data) continue; - for (const m of data.models) { + for (const source of report.sources) { + for (const m of source.models) { totalInputTokens += m.inputTokens; totalOutputTokens += m.outputTokens; } - activeProviders += 1; - userSet.add(report.email); } } return { totalInputTokens, totalOutputTokens, - activeUsers: userSet.size, - activeProviders, + activeUsers: reports.length, }; } -function isStale(fetchedAt: string): boolean { - const ageMs = Date.now() - new Date(fetchedAt).getTime(); - return ageMs > STALE_HOURS * 60 * 60 * 1000; -} - export function DashboardTable({ reports }: DashboardTableProps) { if (reports.length === 0) { return ( @@ -61,7 +54,7 @@ export function DashboardTable({ reports }: DashboardTableProps) { return (
-
+
-
{reports.map((report) => ( @@ -93,10 +82,12 @@ function MetricCard({ label, value }: { label: string; value: string }) { } function UserCard({ report }: { report: UserReport }) { - const providers = Object.entries(report.providers) as [ - Provider, - NonNullable, - ][]; + const total = report.sources.reduce( + (s, src) => + s + + src.models.reduce((ms, m) => ms + m.inputTokens + m.outputTokens, 0), + 0, + ); return ( @@ -104,62 +95,41 @@ function UserCard({ report }: { report: UserReport }) { {report.email} - Updated {new Date(report.updatedAt).toLocaleString()} + {formatTokens(total)} tokens · updated{" "} + {new Date(report.updatedAt).toLocaleString()} - - {providers.map(([provider, data]) => ( - - ))} + + {report.sources.length === 0 ? ( +

No usage recorded.

+ ) : ( + report.sources.map((src) => ( + + )) + )}
); } -interface ProviderSectionProps { - provider: Provider; - data: NonNullable; -} - -function ProviderSection({ provider, data }: ProviderSectionProps) { - const label = PROVIDER_CONFIG[provider].label; - const totalInput = data.models.reduce((s, m) => s + m.inputTokens, 0); - const totalOutput = data.models.reduce((s, m) => s + m.outputTokens, 0); - const stale = isStale(data.fetchedAt); +function SourceSection({ source }: { source: SourceReport }) { + const label = SOURCE_LABELS[source.source] ?? source.source; return ( -
-
- {label} -
- {stale && ( - - Stale - - )} - - {formatTokens(totalInput + totalOutput)} tokens · fetched{" "} - {new Date(data.fetchedAt).toLocaleString()} - -
+
+
+ + {label} + + + synced {new Date(source.lastSyncedAt).toLocaleString()} +
- {data.lastError && ( -
- Last refresh failed:{" "} - {data.lastError} - {data.lastSuccessfulFetchAt && ( - - · last success{" "} - {new Date(data.lastSuccessfulFetchAt).toLocaleString()} - - )} -
- )} - {data.models.length === 0 ? ( -

No usage this month.

+ {source.models.length === 0 ? ( +

No usage recorded.

) : ( - + )}
); diff --git a/src/token-tracker/components/key-form.tsx b/src/token-tracker/components/key-form.tsx deleted file mode 100644 index f8dea1e..0000000 --- a/src/token-tracker/components/key-form.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { cn } from "@/lib/utils"; -import { PROVIDER_CONFIG } from "@/token-tracker/constants"; -import { UsageSummary } from "@/token-tracker/components/usage-summary"; -import type { Provider, SubmitRequest, SubmitState } from "@/token-tracker/types"; -import { useState } from "react"; - -const PROVIDERS = Object.entries(PROVIDER_CONFIG) as [ - Provider, - (typeof PROVIDER_CONFIG)[Provider], -][]; - -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -interface KeyFormProps { - initialToken?: string; -} - -export function KeyForm({ initialToken }: KeyFormProps) { - const [email, setEmail] = useState(""); - const [provider, setProvider] = useState("anthropic"); - const [apiKey, setApiKey] = useState(""); - const [teamToken, setTeamToken] = useState(initialToken ?? ""); - const [state, setState] = useState({ status: "idle" }); - - const config = PROVIDER_CONFIG[provider]; - const supportsUsage = config.hasUsageApi; - const isValidEmail = EMAIL_RE.test(email.trim().toLowerCase()); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setState({ status: "loading" }); - - const body: SubmitRequest = { - email: email.trim().toLowerCase(), - provider, - apiKey, - teamToken, - }; - - try { - const res = await fetch("/token-tracker/api/submit", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const { error } = await res.json(); - setState({ - status: "error", - message: error ?? "Something went wrong.", - }); - return; - } - - const { email: returnedEmail, result } = await res.json(); - setApiKey(""); - setState({ status: "success", email: returnedEmail, result }); - } catch { - setState({ - status: "error", - message: "Network error. Please try again.", - }); - } - } - - const isLoading = state.status === "loading"; - const canSubmit = - supportsUsage && - isValidEmail && - apiKey.length > 0 && - teamToken.length > 0; - - return ( -
-
-
- - setEmail(e.target.value)} - placeholder="you@company.com" - required - disabled={isLoading} - /> -
-
- -
- {PROVIDERS.map(([value, cfg]) => ( - - ))} -
-
- {supportsUsage && config.hasUsageApi ? ( - <> -
- - setApiKey(e.target.value)} - placeholder={config.placeholder} - required - disabled={isLoading} - /> -
-
- - setTeamToken(e.target.value)} - placeholder="Provided by your team admin" - required - disabled={isLoading} - /> -

- Ask your team admin for the team token. It authorizes usage - submissions to this instance. -

-
- - - ) : ( -

- {"noUsageApiMessage" in config && config.noUsageApiMessage} -

- )} -
- - {state.status === "error" && ( -

{state.message}

- )} - - {state.status === "success" && ( - - )} -
- ); -} diff --git a/src/token-tracker/components/model-table.tsx b/src/token-tracker/components/model-table.tsx index 00b4d56..9a14c9b 100644 --- a/src/token-tracker/components/model-table.tsx +++ b/src/token-tracker/components/model-table.tsx @@ -1,14 +1,16 @@ import { formatTokens } from "@/token-tracker/utils"; -import type { ModelUsage, Provider } from "@/token-tracker/types"; +import type { ModelUsage } from "@/token-tracker/types"; interface ModelTableProps { models: ModelUsage[]; - provider: Provider; compact?: boolean; } -export function ModelTable({ models, provider, compact }: ModelTableProps) { - const showCacheWrite = provider === "anthropic"; +function formatCost(usd: number): string { + return `$${usd.toFixed(4)}`; +} + +export function ModelTable({ models, compact }: ModelTableProps) { const cell = compact ? "py-1" : "py-2"; return ( @@ -19,9 +21,8 @@ export function ModelTable({ models, provider, compact }: ModelTableProps) { Input Output Cache read - {showCacheWrite && ( - Cache write - )} + Cache write + Cost @@ -37,11 +38,12 @@ export function ModelTable({ models, provider, compact }: ModelTableProps) { {formatTokens(m.cacheReadTokens)} - {showCacheWrite && ( - - {formatTokens(m.cacheWriteTokens)} - - )} + + {formatTokens(m.cacheWriteTokens)} + + + {formatCost(m.costUsd)} + ))} diff --git a/src/token-tracker/components/refresh-button.tsx b/src/token-tracker/components/refresh-button.tsx deleted file mode 100644 index d8681a4..0000000 --- a/src/token-tracker/components/refresh-button.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { refreshAllAction } from "@/token-tracker/actions"; -import { useState, useTransition } from "react"; - -export function RefreshButton() { - const [isPending, startTransition] = useTransition(); - const [error, setError] = useState(null); - - function handleRefresh() { - setError(null); - startTransition(async () => { - try { - await refreshAllAction(); - } catch { - setError("Refresh failed. Please try again."); - } - }); - } - - return ( -
- - {error &&

{error}

} -
- ); -} diff --git a/src/token-tracker/components/usage-summary.tsx b/src/token-tracker/components/usage-summary.tsx deleted file mode 100644 index 2916881..0000000 --- a/src/token-tracker/components/usage-summary.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { PROVIDER_CONFIG } from "@/token-tracker/constants"; -import { ModelTable } from "@/token-tracker/components/model-table"; -import { formatTokens } from "@/token-tracker/utils"; -import type { UsageResult } from "@/token-tracker/types"; - -interface UsageSummaryProps { - email: string; - result: UsageResult; -} - -export function UsageSummary({ email, result }: UsageSummaryProps) { - const providerLabel = PROVIDER_CONFIG[result.provider].label; - const totalInput = result.models.reduce((s, m) => s + m.inputTokens, 0); - const totalOutput = result.models.reduce((s, m) => s + m.outputTokens, 0); - - return ( -
-

- {providerLabel} usage for{" "} - {email} - {" — "} - - {formatTokens(totalInput + totalOutput)} tokens this month - -

- {result.models.length === 0 ? ( -

- No usage found for this month. -

- ) : ( - - - {providerLabel} - - - - - - )} -
- ); -} diff --git a/src/token-tracker/config.ts b/src/token-tracker/config.ts index bfd5407..96d62d7 100644 --- a/src/token-tracker/config.ts +++ b/src/token-tracker/config.ts @@ -1,19 +1,24 @@ -const REQUIRED_VARS = [ - "SUBMIT_TOKEN", - "DASHBOARD_PASSWORD", - "ENCRYPTION_KEY", - "BLOB_READ_WRITE_TOKEN", -] as const; +const REQUIRED_VARS = ["DASHBOARD_PASSWORD"] as const; -export type RequiredVar = (typeof REQUIRED_VARS)[number]; +export type RequiredVar = + | (typeof REQUIRED_VARS)[number] + | "BLOB_READ_WRITE_TOKEN"; export function getTokenTrackerConfig(): { isConfigured: boolean; missingVars: RequiredVar[]; } { - const missingVars = REQUIRED_VARS.filter( + const missingVars: RequiredVar[] = REQUIRED_VARS.filter( (key) => !process.env[key], - ) as RequiredVar[]; + ); + + const hasStorage = + !!process.env.BLOB_READ_WRITE_TOKEN || + !!process.env.TOKEN_TRACKER_LOCAL_BLOB_DIR; + + if (!hasStorage) { + missingVars.push("BLOB_READ_WRITE_TOKEN"); + } return { isConfigured: missingVars.length === 0, diff --git a/src/token-tracker/constants.ts b/src/token-tracker/constants.ts index 92176ce..24cbaf9 100644 --- a/src/token-tracker/constants.ts +++ b/src/token-tracker/constants.ts @@ -1,51 +1,7 @@ -import type { Provider } from "@/token-tracker/types"; - export const METADATA = { title: "Token Tracker", description: - "Submit your work email and API key to record this month's usage for your team. Keys are encrypted and refreshed automatically every few hours.", + "A local collector that reads AI token usage from Claude Code logs and reports it to your team dashboard.", }; export const BLOB_PREFIX = "token-tracker"; - -export const REQUIRED_ENV_VARS = [ - "ENCRYPTION_KEY", - "DASHBOARD_PASSWORD", - "SUBMIT_TOKEN", - "CRON_SECRET", -] as const; - -type ProviderConfig = - | { label: string; hasUsageApi: true; placeholder: string } - | { label: string; hasUsageApi: false; noUsageApiMessage: string }; - -export const PROVIDER_CONFIG: Record = { - anthropic: { - label: "Anthropic", - hasUsageApi: true, - placeholder: "sk-ant-...", - }, - openai: { - label: "OpenAI", - hasUsageApi: true, - placeholder: "sk-...", - }, - gemini: { - label: "Gemini", - hasUsageApi: false, - noUsageApiMessage: - "Google Gemini doesn't expose a usage API. Token counts are only available per-request inside the response metadata — there's no endpoint to query historical usage with an API key.", - }, - grok: { - label: "Grok", - hasUsageApi: false, - noUsageApiMessage: - "xAI Grok doesn't expose a usage API. Token counts are only available per-request inside the response metadata — there's no endpoint to query historical usage with an API key.", - }, -}; - -export const PROVIDERS_WITH_USAGE_API = new Set( - (Object.entries(PROVIDER_CONFIG) as [Provider, ProviderConfig][]) - .filter(([, cfg]) => cfg.hasUsageApi) - .map(([provider]) => provider), -); diff --git a/src/token-tracker/fetchers.ts b/src/token-tracker/fetchers.ts deleted file mode 100644 index 7d0ff6b..0000000 --- a/src/token-tracker/fetchers.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { ModelUsage, Provider, UsageResult } from "@/token-tracker/types"; - -type AnthropicBucket = { - results: { - model: string | null; - uncached_input_tokens: number; - output_tokens: number; - cache_read_input_tokens: number; - cache_creation: { - ephemeral_1h_input_tokens: number; - ephemeral_5m_input_tokens: number; - }; - }[]; -}; - -type AnthropicUsageResponse = { - data: AnthropicBucket[]; - has_more: boolean; -}; - -type OpenAIBucket = { - results: { - model: string | null; - input_tokens: number; - output_tokens: number; - input_cached_tokens: number; - }[]; -}; - -type OpenAIUsageResponse = { - data: OpenAIBucket[]; - has_more: boolean; -}; - -function aggregateModels(map: Map): ModelUsage[] { - return Array.from(map.values()).filter((m) => m.model); -} - -async function fetchAnthropicUsage(apiKey: string): Promise { - const startOfMonth = new Date(); - startOfMonth.setUTCDate(1); - startOfMonth.setUTCHours(0, 0, 0, 0); - - const url = new URL( - "https://api.anthropic.com/v1/organizations/usage_report/messages", - ); - url.searchParams.set("starting_at", startOfMonth.toISOString()); - url.searchParams.set("bucket_width", "1d"); - url.searchParams.append("group_by[]", "model"); - - const res = await fetch(url.toString(), { - headers: { - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - }, - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`Anthropic API ${res.status}: ${body}`); - } - - const data: AnthropicUsageResponse = await res.json(); - const modelMap = new Map(); - - for (const bucket of data.data) { - for (const r of bucket.results) { - if (!r.model) continue; - const existing = modelMap.get(r.model) ?? { - model: r.model, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }; - existing.inputTokens += r.uncached_input_tokens; - existing.outputTokens += r.output_tokens; - existing.cacheReadTokens += r.cache_read_input_tokens; - existing.cacheWriteTokens += - r.cache_creation.ephemeral_1h_input_tokens + - r.cache_creation.ephemeral_5m_input_tokens; - modelMap.set(r.model, existing); - } - } - - return { - provider: "anthropic", - models: aggregateModels(modelMap), - fetchedAt: new Date().toISOString(), - }; -} - -async function fetchOpenAIUsage(apiKey: string): Promise { - const startOfMonth = new Date(); - startOfMonth.setUTCDate(1); - startOfMonth.setUTCHours(0, 0, 0, 0); - const startTime = Math.floor(startOfMonth.getTime() / 1000); - - const url = new URL( - "https://api.openai.com/v1/organization/usage/completions", - ); - url.searchParams.set("start_time", String(startTime)); - url.searchParams.set("bucket_width", "1d"); - url.searchParams.append("group_by", "model"); - - const res = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`OpenAI API ${res.status}: ${body}`); - } - - const data: OpenAIUsageResponse = await res.json(); - const modelMap = new Map(); - - for (const bucket of data.data) { - for (const r of bucket.results) { - if (!r.model) continue; - const existing = modelMap.get(r.model) ?? { - model: r.model, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }; - existing.inputTokens += r.input_tokens; - existing.outputTokens += r.output_tokens; - existing.cacheReadTokens += r.input_cached_tokens; - modelMap.set(r.model, existing); - } - } - - return { - provider: "openai", - models: aggregateModels(modelMap), - fetchedAt: new Date().toISOString(), - }; -} - -const FETCHERS: Partial Promise>> = { - anthropic: fetchAnthropicUsage, - openai: fetchOpenAIUsage, -}; - -export function fetchProviderUsage( - provider: Provider, - apiKey: string, -): Promise { - const fetcher = FETCHERS[provider]; - if (!fetcher) { - throw new Error(`No usage API implemented for ${provider}.`); - } - return fetcher(apiKey); -} diff --git a/src/token-tracker/pages/index.tsx b/src/token-tracker/pages/index.tsx deleted file mode 100644 index e7c5725..0000000 --- a/src/token-tracker/pages/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import { Container } from "@/components/container"; -import { Heading, Subheading } from "@/components/heading"; -import { Spacer } from "@/components/spacer"; -import { KeyForm } from "@/token-tracker/components/key-form"; - -interface HomeProps { - initialToken?: string; -} - -export function Home({ initialToken }: HomeProps) { - return ( - - - Token Tracker - - - - Submit your work email and a read-only API key to record this - month's usage for your team. Your key is encrypted securely and - refreshed automatically every few hours. - - - - - ); -} diff --git a/src/token-tracker/pages/landing.tsx b/src/token-tracker/pages/landing.tsx index 3ea67d7..06804ae 100644 --- a/src/token-tracker/pages/landing.tsx +++ b/src/token-tracker/pages/landing.tsx @@ -1,61 +1,67 @@ import { Container } from "@/components/container"; import { Description } from "@/components/description"; -import { Heading, Subheading } from "@/components/heading"; +import { Heading } from "@/components/heading"; import { Spacer } from "@/components/spacer"; import { Button } from "@/components/ui/button"; -import { PROVIDER_CONFIG } from "@/token-tracker/constants"; const DEPLOY_URL = "https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsilver-dev-org%2Fopen-silver"; const GITHUB_URL = "https://github.com/silver-dev-org/open-silver"; -const steps = [ +const configJson = `{ + "mcpServers": { + "silver-tracker": { + "command": "npx", + "args": ["-y", "@ftaboadac/silver-tracker-mcp"] + } + } +}`; + +const installCards = [ { - number: "01", - title: "Deploy your own instance", - description: - "Clone and deploy this repo to Vercel in one click. Configure the required environment variables in your project settings.", + label: "Claude Code", + sublabel: null, + code: "claude mcp add silver-tracker --\nnpx -y @ftaboadac/silver-tracker-mcp", }, { - number: "02", - title: "Share the submit link", - description: - "Send team members a link with a submit token. They enter their work email and a read-only API key for each provider they use.", + label: "Codex CLI", + sublabel: null, + code: "codex mcp add silver-tracker --\nnpx -y @ftaboadac/silver-tracker-mcp", }, { - number: "03", - title: "Keys are encrypted and stored", - description: - "API keys are encrypted with AES-256 and stored in Vercel Blob. Usage data never leaves your own Vercel project.", + label: "Cursor / Gemini CLI", + sublabel: "~/.cursor/mcp.json or ~/.gemini/settings.json", + code: configJson, }, { - number: "04", - title: "Usage refreshes automatically", - description: - "A cron job polls provider APIs every few hours and updates each team member's token consumption. View totals in the dashboard.", + label: "CLI fallback", + sublabel: "Any environment without MCP support", + code: "npx -y @ftaboadac/silver-tracker", }, ]; +function CodeBlock({ children }: { children: string }) { + return ( +
+      {children}
+    
+ ); +} + export function LandingPage() { return ( -
+
Token Tracker - A self-hosted internal tool for teams to track AI token usage across - Anthropic, OpenAI, Gemini, and Grok. + Track AI token usage across your team's coding tools — + self-hosted, no API keys required. -

- The public Open Silver deployment is a reference implementation only - — not a shared hosted service. Deploy your own instance to use it - with your team. -

- -
+ -
- - How it works - -
- {steps.map((step) => ( +
+

+ Install +

+
+ {installCards.map((card) => (
- - {step.number} - -
-

{step.title}

-

- {step.description} -

+
+

{card.label}

+ {card.sublabel && ( +

{card.sublabel}

+ )}
+ {card.code}
))}
-
+
-
- - Supported providers - -
- {( - Object.entries(PROVIDER_CONFIG) as [ - string, - (typeof PROVIDER_CONFIG)[keyof typeof PROVIDER_CONFIG], - ][] - ).map(([key, config]) => ( -
- {config.label} - - {config.hasUsageApi ? "Usage API" : "Display only"} - +
+
+
+ + Self-hosted + +
+

+ Each organization deploys their own instance. Usage data lives + entirely within your own Vercel project — we never see it. +

+

+ Collectors read local log files from Claude Code, Codex CLI, + and Gemini CLI. No provider admin keys required. Reports are + stored in your own Vercel Blob store, behind a + password-protected dashboard. +

- ))} -
-

- Providers marked “Display only” do not expose a historical - usage API — token counts are only available per-request in their - response metadata. -

-
- - + +
-
- - Self-hosted - - Why does each organization deploy their own? -
-

- Token Tracker is designed to be deployed independently by each - organization. Your team's API keys and usage data live entirely - within your own Vercel project — we never see it. -

-

- Each instance is isolated. There is no central database, no shared - accounts, and no usage data crossing organizational boundaries. You - own the encryption key, the storage, and the dashboard password. -

+
+

+ Supported sources +

+
    +
  • Claude Code
  • +
  • Codex CLI
  • +
  • Gemini CLI
  • +
  • + Cursor 3.1+ no longer exposes local token data — see README +
  • +
+
-
+ ); } diff --git a/src/token-tracker/refresh.ts b/src/token-tracker/refresh.ts deleted file mode 100644 index 53f4c3c..0000000 --- a/src/token-tracker/refresh.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { getTokenTrackerConfig } from "@/token-tracker/config"; -import { PROVIDER_CONFIG } from "@/token-tracker/constants"; -import { fetchProviderUsage } from "@/token-tracker/fetchers"; -import { listAllReports, writeReport } from "@/token-tracker/storage"; -import type { Provider, ProviderData, UserReport } from "@/token-tracker/types"; -import crypto from "crypto"; - -export type RefreshOutcome = { - email: string; - provider: Provider; - status: "ok" | "error"; - error?: string; -}; - -function decryptKey(encryptedKey: string): string { - const keyBuffer = Buffer.from(process.env.ENCRYPTION_KEY!, "hex"); - const combined = Buffer.from(encryptedKey, "base64"); - const iv = combined.subarray(0, 12); - const authTag = combined.subarray(12, 28); - const ciphertext = combined.subarray(28); - const decipher = crypto.createDecipheriv("aes-256-gcm", keyBuffer, iv); - decipher.setAuthTag(authTag); - return decipher.update(ciphertext, undefined, "utf8") + decipher.final("utf8"); -} - -async function refreshReport( - report: UserReport, - blobPathname: string, -): Promise { - const outcomes: RefreshOutcome[] = []; - const updatedProviders: UserReport["providers"] = { ...report.providers }; - - await Promise.allSettled( - (Object.entries(report.providers) as [Provider, ProviderData][]).map( - async ([provider, data]) => { - if (!PROVIDER_CONFIG[provider].hasUsageApi) return; - - try { - const apiKey = decryptKey(data.encryptedKey); - const result = await fetchProviderUsage(provider, apiKey); - - const { lastError: _cleared, ...rest } = data; - updatedProviders[provider] = { - ...rest, - models: result.models, - fetchedAt: result.fetchedAt, - lastSuccessfulFetchAt: result.fetchedAt, - }; - outcomes.push({ email: report.email, provider, status: "ok" }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - updatedProviders[provider] = { ...data, lastError: message }; - outcomes.push({ - email: report.email, - provider, - status: "error", - error: message, - }); - } - }, - ), - ); - - const anySuccess = outcomes.some((o) => o.status === "ok"); - - const updatedReport: UserReport = { - ...report, - providers: updatedProviders, - updatedAt: anySuccess ? new Date().toISOString() : report.updatedAt, - }; - - await writeReport(blobPathname, updatedReport); - - return outcomes; -} - -export async function runRefreshAll(): Promise { - const { isConfigured } = getTokenTrackerConfig(); - if (!isConfigured) return []; - - const reportEntries = await listAllReports(); - - const results = await Promise.allSettled( - reportEntries.map(({ report, pathname }) => - refreshReport(report, pathname), - ), - ); - - return results - .filter( - (r): r is PromiseFulfilledResult => - r.status === "fulfilled", - ) - .flatMap((r) => r.value); -} diff --git a/src/token-tracker/storage.ts b/src/token-tracker/storage.ts index 434f422..9312051 100644 --- a/src/token-tracker/storage.ts +++ b/src/token-tracker/storage.ts @@ -1,6 +1,10 @@ import { BLOB_PREFIX } from "@/token-tracker/constants"; import type { UserReport } from "@/token-tracker/types"; import { get, list, put } from "@vercel/blob"; +import fs from "fs/promises"; +import path from "path"; + +const localDir = process.env.TOKEN_TRACKER_LOCAL_BLOB_DIR; function isValidUserReport(value: unknown): value is UserReport { return ( @@ -8,48 +12,87 @@ function isValidUserReport(value: unknown): value is UserReport { value !== null && typeof (value as UserReport).email === "string" && (value as UserReport).email.includes("@") && - typeof (value as UserReport).providers === "object" && + Array.isArray((value as UserReport).sources) && typeof (value as UserReport).updatedAt === "string" ); } -async function getReportByPathname(pathname: string): Promise { +async function readBlobText(pathname: string): Promise { + if (localDir) { + try { + return await fs.readFile(path.join(localDir, pathname), "utf8"); + } catch { + return null; + } + } const result = await get(pathname, { access: "private" }); if (!result || result.statusCode !== 200) return null; - const text = await new Response(result.stream).text(); + return new Response(result.stream).text(); +} + +async function writeBlobText(pathname: string, body: string): Promise { + if (localDir) { + const filePath = path.join(localDir, pathname); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, body, "utf8"); + return; + } + await put(pathname, body, { + access: "private", + contentType: "application/json", + addRandomSuffix: false, + }); +} + +async function listBlobPathnames(prefix: string): Promise { + if (localDir) { + const dir = path.join(localDir, prefix); + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries + .filter((e) => e.isFile()) + .map((e) => path.posix.join(prefix.replace(/\/$/, ""), e.name)); + } catch { + return []; + } + } + const { blobs } = await list({ prefix }); + return blobs.map((b) => b.pathname); +} + +async function getReportByPathname( + pathname: string, +): Promise { + const text = await readBlobText(pathname); + if (!text) return null; const data: unknown = JSON.parse(text); return isValidUserReport(data) ? data : null; } export async function readReport(hashedId: string): Promise { - const pathname = `${BLOB_PREFIX}/${hashedId}.json`; - return getReportByPathname(pathname); + return getReportByPathname(`${BLOB_PREFIX}/${hashedId}.json`); } export async function writeReport( pathname: string, report: UserReport, ): Promise { - await put(pathname, JSON.stringify(report), { - access: "private", - contentType: "application/json", - addRandomSuffix: false, - }); + await writeBlobText(pathname, JSON.stringify(report)); } export async function listAllReports(): Promise< { report: UserReport; pathname: string }[] > { - const { blobs } = await list({ prefix: `${BLOB_PREFIX}/` }); + const pathnames = await listBlobPathnames(`${BLOB_PREFIX}/`); const results = await Promise.allSettled( - blobs.map(async (blob) => { - const data = await getReportByPathname(blob.pathname); + pathnames.map(async (pathname) => { + const data = await getReportByPathname(pathname); if (!data) { - console.warn(`[token-tracker] Skipping invalid blob: ${blob.pathname}`); + console.warn(`[token-tracker] Skipping invalid blob: ${pathname}`); return null; } - return { report: data, pathname: blob.pathname }; + return { report: data, pathname }; }), ); diff --git a/src/token-tracker/types.ts b/src/token-tracker/types.ts index a92da95..2f4a99f 100644 --- a/src/token-tracker/types.ts +++ b/src/token-tracker/types.ts @@ -1,4 +1,9 @@ -export type Provider = "anthropic" | "openai" | "gemini" | "grok"; +export type UsageSource = + | "claude-code" + | "cursor" + | "codex" + | "gemini-cli" + | "aider"; export type ModelUsage = { model: string; @@ -6,42 +11,17 @@ export type ModelUsage = { outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; + costUsd: number; }; -export type ProviderData = { - encryptedKey: string; +export type SourceReport = { + source: UsageSource; models: ModelUsage[]; - fetchedAt: string; - lastSuccessfulFetchAt?: string; - lastError?: string; + lastSyncedAt: string; }; export type UserReport = { email: string; - providers: Partial>; + sources: SourceReport[]; updatedAt: string; }; - -export type UsageResult = { - provider: Provider; - models: ModelUsage[]; - fetchedAt: string; -}; - -export type SubmitRequest = { - email: string; - provider: Provider; - apiKey: string; - teamToken: string; -}; - -export type SubmitResponse = { - email: string; - result: UsageResult; -}; - -export type SubmitState = - | { status: "idle" } - | { status: "loading" } - | { status: "success"; email: string; result: UsageResult } - | { status: "error"; message: string }; diff --git a/tsconfig.json b/tsconfig.json index c133409..449976f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "cli", "mcp"] } diff --git a/vercel.json b/vercel.json index bcfc610..0967ef4 100644 --- a/vercel.json +++ b/vercel.json @@ -1,8 +1 @@ -{ - "crons": [ - { - "path": "/token-tracker/api/refresh", - "schedule": "0 */6 * * *" - } - ] -} +{} From 54d5e8bb24787fec84ce0526c2c471c3329859c6 Mon Sep 17 00:00:00 2001 From: Facundo Taboada Date: Tue, 5 May 2026 11:55:02 -0300 Subject: [PATCH 3/5] cleanup --- .gitignore | 1 + .vscode/settings.json | 1 - bun.lock | 9 +++++---- package.json | 2 +- vercel.json | 1 - 5 files changed, 7 insertions(+), 7 deletions(-) delete mode 100644 .vscode/settings.json delete mode 100644 vercel.json diff --git a/.gitignore b/.gitignore index bba4a1f..43ab50f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # misc .DS_Store *.pem +.vscode/ # debug npm-debug.log* diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 9e26dfe..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/bun.lock b/bun.lock index c1bb5f6..130e116 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "open-silver", @@ -31,7 +30,7 @@ "@tanstack/react-query": "^5.90.2", "@types/formidable": "^3.4.5", "@types/pdf-parse": "^1.1.5", - "@vercel/blob": "^2.3.0", + "@vercel/blob": "^2.0.0", "adm-zip": "^0.5.16", "ai": "^5.0.61", "autoprefixer": "^10.4.21", @@ -244,6 +243,8 @@ "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], @@ -858,7 +859,7 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-XBWpUP0mHya6yGBwNefhyEa6V7HgYKCxEAY4qhTm/PcAQyBPNmjj97VZJOJkVdUsyuuii7xmq0pXWX/c2aToHQ=="], - "@vercel/blob": ["@vercel/blob@2.3.3", "", { "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^6.23.0" } }, "sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg=="], + "@vercel/blob": ["@vercel/blob@2.0.0", "", { "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^5.28.4" } }, "sha512-oAj7Pdy83YKSwIaMFoM7zFeLYWRc+qUpW3PiDSblxQMnGFb43qs4bmfq7dr/+JIfwhs6PTwe1o2YBwKhyjWxXw=="], "@vercel/oidc": ["@vercel/oidc@3.0.2", "", {}, "sha512-JekxQ0RApo4gS4un/iMGsIL1/k4KUBe3HmnGcDvzHuFBdQdudEJgTqcsJC7y6Ul4Yw5CeykgvQbX2XeEJd0+DA=="], @@ -2234,7 +2235,7 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/package.json b/package.json index cf6cc75..b9986a2 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@tanstack/react-query": "^5.90.2", "@types/formidable": "^3.4.5", "@types/pdf-parse": "^1.1.5", - "@vercel/blob": "^2.3.0", + "@vercel/blob": "^2.0.0", "adm-zip": "^0.5.16", "ai": "^5.0.61", "autoprefixer": "^10.4.21", diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 0967ef4..0000000 --- a/vercel.json +++ /dev/null @@ -1 +0,0 @@ -{} From 6d7ff4290813d514e37cf04f7b7d837413181476 Mon Sep 17 00:00:00 2001 From: Facundo Taboada Date: Tue, 5 May 2026 16:14:41 -0300 Subject: [PATCH 4/5] refactor: simplify token-tracker to local-only CLI per review feedback Removed backend infrastructure (Vercel app, Blob store, dashboard, MCP server) per project owner feedback. The tool is now a pure local CLI that reads usage logs and prints a terminal visual. No network calls, no config, no aggregation. Published as @ftaboadac/silver-token-tracker for now; the official version will be published by silver-dev-org with attribution. --- cli/README.md | 68 +++++ cli/bun.lock | 20 ++ cli/package.json | 12 +- cli/src/collector.ts | 2 - cli/src/config.ts | 47 --- cli/src/index.ts | 134 +++++++-- cli/src/parsers/cursor.ts | 174 ----------- cli/src/reporter.ts | 31 -- example.env | 15 - mcp/bun.lock | 282 ------------------ mcp/package.json | 40 --- mcp/src/index.ts | 114 ------- mcp/tsconfig.json | 14 - src/app/page.tsx | 6 - src/app/token-tracker/api/report/route.ts | 92 ------ src/app/token-tracker/dashboard/page.tsx | 62 ---- src/app/token-tracker/page.tsx | 17 -- src/token-tracker/README.md | 210 ------------- src/token-tracker/actions.ts | 41 --- .../components/dashboard-table.tsx | 136 --------- src/token-tracker/components/model-table.tsx | 52 ---- .../components/password-form.tsx | 33 -- .../components/setup-required.tsx | 67 ----- src/token-tracker/config.ts | 27 -- src/token-tracker/constants.ts | 7 - src/token-tracker/pages/landing.tsx | 148 --------- src/token-tracker/storage.ts | 110 ------- src/token-tracker/types.ts | 27 -- src/token-tracker/utils.ts | 29 -- tsconfig.json | 2 +- 30 files changed, 208 insertions(+), 1811 deletions(-) create mode 100644 cli/README.md delete mode 100644 cli/src/config.ts delete mode 100644 cli/src/parsers/cursor.ts delete mode 100644 cli/src/reporter.ts delete mode 100644 mcp/bun.lock delete mode 100644 mcp/package.json delete mode 100644 mcp/src/index.ts delete mode 100644 mcp/tsconfig.json delete mode 100644 src/app/token-tracker/api/report/route.ts delete mode 100644 src/app/token-tracker/dashboard/page.tsx delete mode 100644 src/app/token-tracker/page.tsx delete mode 100644 src/token-tracker/README.md delete mode 100644 src/token-tracker/actions.ts delete mode 100644 src/token-tracker/components/dashboard-table.tsx delete mode 100644 src/token-tracker/components/model-table.tsx delete mode 100644 src/token-tracker/components/password-form.tsx delete mode 100644 src/token-tracker/components/setup-required.tsx delete mode 100644 src/token-tracker/config.ts delete mode 100644 src/token-tracker/constants.ts delete mode 100644 src/token-tracker/pages/landing.tsx delete mode 100644 src/token-tracker/storage.ts delete mode 100644 src/token-tracker/types.ts delete mode 100644 src/token-tracker/utils.ts diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..4c91515 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,68 @@ +# silver-token-tracker + +Reads local logs from Claude Code, Codex CLI, and Gemini CLI and prints a terminal table showing token usage and estimated cost per model. + +No account, no server, no network calls — everything runs locally. + +## Install + +```sh +npm install -g @ftaboadac/silver-token-tracker +``` + +Or run without installing: + +```sh +npx -y @ftaboadac/silver-token-tracker run +``` + +## Usage + +```sh +silver-token-tracker run # show the visual table (default when run with no args) +silver-token-tracker run --json # output raw JSON (useful for piping into other tools) +silver-token-tracker --help # show help and supported sources +``` + +## What it looks like + +``` + Claude Code + ──────────────────────────────────────────────────────────────────────── + ┌────────────────────────────┬──────────┬──────────┬────────────┬─────────────┬────────────┐ + │ Model │ Input │ Output │ Cache Read │ Cache Write │ Cost (USD) │ + ├────────────────────────────┼──────────┼──────────┼────────────┼─────────────┼────────────┤ + │ claude-sonnet-4-6 │ 124.5K │ 18.2K │ 890.1K │ 45.3K │ $1.26 │ + │ claude-opus-4 │ 12.1K │ 2.3K │ 5.0K │ 1.1K │ $0.89 │ + └────────────────────────────┴──────────┴──────────┴────────────┴─────────────┴────────────┘ + + Codex CLI + ──────────────────────────────────────────────────────────────────────── + ┌────────────────────────────┬──────────┬──────────┬────────────┬─────────────┬────────────┐ + │ Model │ Input │ Output │ Cache Read │ Cache Write │ Cost (USD) │ + ├────────────────────────────┼──────────┼──────────┼────────────┼─────────────┼────────────┤ + │ gpt-4.1 │ 45.2K │ 6.8K │ 0 │ 0 │ $0.15 │ + └────────────────────────────┴──────────┴──────────┴────────────┴─────────────┴────────────┘ + + ──────────────────────────────────────────────────────────────────────── + TOTAL 2 sources · 1.1M tokens · $2.30 + ──────────────────────────────────────────────────────────────────────── +``` + +Source headers and the cost column are color-highlighted in the terminal. + +## Supported sources + +| Source | Log location | +|--------|-------------| +| Claude Code | `~/.claude/projects/**/*.jsonl` | +| Codex CLI | `~/.codex/history/*.json` | +| Gemini CLI | `~/.gemini/logs/*.json` | + +**Cursor is not supported.** The usage schema changed in Cursor v3.1+ and the new format does not expose per-model token counts in a stable way. + +## Notes + +- Token counts are aggregated across all sessions found on disk (all-time, not filtered by date). +- Costs are estimated using public API pricing — they may differ from what you are actually billed if you are on a subscription plan. +- This package is a reference implementation published under the author's personal scope (`@ftaboadac`). The maintainers of [silver-dev-org](https://github.com/silver-dev-org) will publish the official version under their repo with attribution. When that happens, this package will be deprecated in favor of the official one. diff --git a/cli/bun.lock b/cli/bun.lock index 03f374a..0ce1bf7 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "silver-tracker", + "dependencies": { + "cli-table3": "^0.6.5", + "picocolors": "^1.1.1", + }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^22", @@ -15,10 +19,14 @@ }, }, "packages": { + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "better-sqlite3": ["better-sqlite3@12.9.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ=="], @@ -31,12 +39,16 @@ "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], @@ -53,6 +65,8 @@ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -65,6 +79,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], @@ -81,8 +97,12 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], diff --git a/cli/package.json b/cli/package.json index eecefc1..7c16e9d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { - "name": "@ftaboadac/silver-tracker", - "version": "0.1.0", - "description": "CLI to sync Claude Code token usage to a Silver dashboard", + "name": "@ftaboadac/silver-token-tracker", + "version": "0.2.0", + "description": "Read local Claude Code, Codex CLI, and Gemini CLI logs and print token usage + cost in the terminal", "license": "MIT", "repository": { "type": "git", @@ -10,7 +10,7 @@ }, "type": "module", "bin": { - "silver-tracker": "dist/index.js" + "silver-token-tracker": "dist/index.js" }, "scripts": { "build": "tsc", @@ -25,6 +25,10 @@ "engines": { "node": ">=18.17.0" }, + "dependencies": { + "cli-table3": "^0.6.5", + "picocolors": "^1.1.1" + }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^22", diff --git a/cli/src/collector.ts b/cli/src/collector.ts index bee0191..c94120a 100644 --- a/cli/src/collector.ts +++ b/cli/src/collector.ts @@ -1,12 +1,10 @@ import { parseClaudeCodeUsage } from "./parsers/claude-code.js"; import { parseCodexUsage } from "./parsers/codex.js"; -import { parseCursorUsage } from "./parsers/cursor.js"; import { parseGeminiUsage } from "./parsers/gemini.js"; import type { ModelUsage, SourceReport, UsageSource } from "./types.js"; const PARSERS: Array<{ source: UsageSource; parse: () => Promise }> = [ { source: "claude-code", parse: parseClaudeCodeUsage }, - { source: "cursor", parse: parseCursorUsage }, { source: "codex", parse: parseCodexUsage }, { source: "gemini-cli", parse: parseGeminiUsage }, ]; diff --git a/cli/src/config.ts b/cli/src/config.ts deleted file mode 100644 index ec32c65..0000000 --- a/cli/src/config.ts +++ /dev/null @@ -1,47 +0,0 @@ -import fs from "fs/promises"; -import os from "os"; -import path from "path"; -import { createInterface } from "readline/promises"; - -export type Config = { - email: string; - backendUrl: string; - submitToken: string; -}; - -const CONFIG_DIR = path.join(os.homedir(), ".silver-tracker"); -const CONFIG_PATH = path.join(CONFIG_DIR, "config.json"); - -export async function readConfig(): Promise { - try { - const text = await fs.readFile(CONFIG_PATH, "utf8"); - return JSON.parse(text) as Config; - } catch { - return null; - } -} - -export async function writeConfig(config: Config): Promise { - await fs.mkdir(CONFIG_DIR, { recursive: true }); - await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), "utf8"); -} - -async function promptForConfig(): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const email = (await rl.question("Email: ")).trim(); - const backendUrl = (await rl.question("Backend URL (e.g. https://yourapp.vercel.app): ")).trim().replace(/\/$/, ""); - const submitToken = (await rl.question("Submit token: ")).trim(); - rl.close(); - return { email, backendUrl, submitToken }; -} - -export async function loadOrInitConfig(): Promise { - const existing = await readConfig(); - if (existing) return existing; - - console.log("First run — please provide your Silver Tracker credentials.\n"); - const config = await promptForConfig(); - await writeConfig(config); - console.log(`\nConfig saved to ${CONFIG_PATH}\n`); - return config; -} diff --git a/cli/src/index.ts b/cli/src/index.ts index 1c8b99e..28ccfdf 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,42 +1,130 @@ #!/usr/bin/env node -import { loadOrInitConfig } from "./config.js"; +import pc from "picocolors"; +import Table from "cli-table3"; import { collectUsage } from "./collector.js"; -import { postReport } from "./reporter.js"; -import type { UserReport } from "./types.js"; +import type { SourceReport } from "./types.js"; const args = process.argv.slice(2); -const command = args.find((a) => !a.startsWith("-")) ?? "sync"; -const dryRun = args.includes("--dry-run"); +const jsonMode = args.includes("--json"); +const helpMode = args.includes("--help") || args.includes("-h"); +const command = args.find((a) => !a.startsWith("-")) ?? "run"; -if (command !== "sync") { - console.error(`Unknown command: ${command}`); - console.error("Usage: silver-tracker [sync] [--dry-run]"); - process.exit(1); +const SOURCE_LABELS: Record = { + "claude-code": "Claude Code", + codex: "Codex CLI", + "gemini-cli": "Gemini CLI", +}; + +const HELP = ` +${pc.bold("silver-token-tracker")} — local AI token usage reporter + +${pc.bold("Usage:")} + silver-token-tracker [run] [--json] [--help] + +${pc.bold("Commands:")} + run Read local logs and display token usage (default) + +${pc.bold("Flags:")} + --json Output raw JSON instead of the visual table + --help, -h Show this help message + +${pc.bold("Supported sources:")} + • Claude Code (~/.claude/projects/**/*.jsonl) + • Codex CLI (~/.codex/history/*.json) + • Gemini CLI (~/.gemini/logs/*.json) + + Cursor is not supported (usage schema changed in v3.1+). +`.trimStart(); + +function fmtTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +function fmtCost(n: number): string { + if (n >= 0.01) return `$${n.toFixed(2)}`; + return `$${n.toFixed(4)}`; +} + +function renderVisual(sources: SourceReport[]): void { + const rule = pc.dim("─".repeat(72)); + + let totalTokens = 0; + let totalCost = 0; + + for (const { source, models } of sources) { + const label = SOURCE_LABELS[source] ?? source; + console.log(); + console.log(pc.cyan(pc.bold(` ${label}`))); + console.log(rule); + + const table = new Table({ + head: [ + pc.bold("Model"), + pc.bold("Input"), + pc.bold("Output"), + pc.bold("Cache Read"), + pc.bold("Cache Write"), + pc.bold("Cost (USD)"), + ], + colAligns: ["left", "right", "right", "right", "right", "right"], + style: { head: [], border: [], compact: true }, + }); + + for (const m of models) { + totalTokens += m.inputTokens + m.outputTokens + m.cacheReadTokens + m.cacheWriteTokens; + totalCost += m.costUsd; + table.push([ + m.model, + fmtTokens(m.inputTokens), + fmtTokens(m.outputTokens), + fmtTokens(m.cacheReadTokens), + fmtTokens(m.cacheWriteTokens), + pc.yellow(fmtCost(m.costUsd)), + ]); + } + + console.log(table.toString()); + } + + console.log(); + console.log(rule); + const sourcePart = `${sources.length} source${sources.length !== 1 ? "s" : ""}`; + console.log( + ` ${pc.bold("TOTAL")} ${sourcePart} · ${pc.bold(fmtTokens(totalTokens) + " tokens")} · ${pc.yellow(pc.bold(fmtCost(totalCost)))}`, + ); + console.log(rule); + console.log(); } async function main() { - const config = await loadOrInitConfig(); - const sources = await collectUsage(); + if (helpMode) { + process.stdout.write(HELP + "\n"); + return; + } - if (sources.length === 0) { - console.log("No usage data found. Make sure you've used Claude Code, Cursor, Codex CLI, or Gemini CLI on this machine."); + if (command !== "run") { + console.error(`Unknown command: ${command}`); + console.error("Run silver-token-tracker --help for usage."); process.exit(1); } - const report: UserReport = { - email: config.email, - sources, - updatedAt: new Date().toISOString(), - }; + const sources = await collectUsage(); + + if (sources.length === 0) { + console.log( + "No usage data found. Make sure you have used Claude Code, Codex CLI, or Gemini CLI on this machine.", + ); + return; + } - if (dryRun) { - console.log(JSON.stringify(report, null, 2)); + if (jsonMode) { + console.log(JSON.stringify(sources, null, 2)); return; } - await postReport(config, report); - const sourceNames = sources.map((s) => s.source).join(", "); - console.log(`Reported usage from ${sources.length} source(s): ${sourceNames}`); + renderVisual(sources); } main().catch((err) => { diff --git a/cli/src/parsers/cursor.ts b/cli/src/parsers/cursor.ts deleted file mode 100644 index 4e23526..0000000 --- a/cli/src/parsers/cursor.ts +++ /dev/null @@ -1,174 +0,0 @@ -import fs from "fs/promises"; -import os from "os"; -import path from "path"; -import { aggregateToModelUsage } from "../pricing.js"; -import type { ModelUsage } from "../types.js"; - -// Cursor 3.1+ schema change — why this parser returns no data for modern installs: -// -// In Cursor ≤2.x, each assistant turn wrote real token counts to cursorDiskKV: -// bubbleId:: → { tokenCount: { inputTokens, outputTokens }, -// modelInfo: { modelName }, ... } -// The BUBBLE_QUERY below reads exactly those fields and still works for ≤2.x users. -// -// Starting in Cursor 3.1, conversation data was moved to per-composer AES-256-GCM -// encrypted blobs stored as agentKv:blob: entries in the same table. The -// bubbleId rows are still written but tokenCount is always {inputTokens:0, outputTokens:0} -// and modelInfo is null. The plain-JSON agentKv entries (role/content pairs) only carry a -// requestId in providerOptions — no model name, no token counts. The encryption key lives -// in composerData.blobEncryptionKey but the key→blob mapping is not documented. -// -// Content-length estimation (~4 chars/token) was evaluated and rejected: ±30–50% error, -// no model attribution, and no cache read/write data would produce misleading figures when -// displayed alongside precise Claude Code numbers on the dashboard. -// -// When the DB is found but returns zero token rows (the 3.1+ case), this function throws -// so the CLI can emit a clear diagnostic rather than silently omitting Cursor from the report. -// The source remains in the UsageSource enum so support can be re-enabled if Cursor exposes -// local token data again. - -function getDbPath(): string { - const home = os.homedir(); - if (process.platform === "darwin") { - return path.join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb"); - } - if (process.platform === "win32") { - return path.join(home, "AppData", "Roaming", "Cursor", "User", "globalStorage", "state.vscdb"); - } - return path.join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb"); -} - -interface SqliteStatement { - all(...params: (string | number | null)[]): Record[]; -} - -interface SqliteDb { - prepare(sql: string): SqliteStatement; - close(): void; -} - -async function openDb(dbPath: string): Promise { - try { - const { DatabaseSync } = await import("node:sqlite"); - const db = new DatabaseSync(dbPath, { readOnly: true }); - return { - prepare(sql: string): SqliteStatement { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const stmt = db.prepare(sql) as any; - return { - all(...params) { - return stmt.all(...params) as Record[]; - }, - }; - }, - close() { - db.close(); - }, - }; - } catch {} - - const mod = await import("better-sqlite3"); - const BetterSqlite3 = mod.default; - const db = new BetterSqlite3(dbPath, { readonly: true, fileMustExist: true }); - return { - prepare(sql: string): SqliteStatement { - const stmt = db.prepare(sql); - return { - all(...params) { - return stmt.all(...params) as Record[]; - }, - }; - }, - close() { - db.close(); - }, - }; -} - -type KvRow = { - input_tokens: number | null; - output_tokens: number | null; - model: string | null; - created_at: string | null; - conversation_id: string | null; -}; - -const BUBBLE_QUERY = ` - SELECT - json_extract(value, '$.tokenCount.inputTokens') AS input_tokens, - json_extract(value, '$.tokenCount.outputTokens') AS output_tokens, - json_extract(value, '$.modelInfo.modelName') AS model, - json_extract(value, '$.createdAt') AS created_at, - json_extract(value, '$.conversationId') AS conversation_id - FROM cursorDiskKV - WHERE key LIKE 'bubbleId:%' - AND json_valid(value) -`; - -function toNumber(v: unknown): number { - if (typeof v === "number") return v; - if (typeof v === "bigint") return Number(v); - return 0; -} - -function processRows(rows: Record[], seen: Set): { - model: string; - inputTokens: number; - outputTokens: number; - cacheWriteTokens: number; - cacheReadTokens: number; -}[] { - const usages = []; - for (const row of rows) { - const r = row as unknown as KvRow; - const inputTokens = toNumber(r.input_tokens); - const outputTokens = toNumber(r.output_tokens); - - if (inputTokens === 0 && outputTokens === 0) continue; - - const conversationId = r.conversation_id ?? "unknown"; - const createdAt = r.created_at ?? ""; - const dedupKey = `${conversationId}:${createdAt}:${inputTokens}:${outputTokens}`; - if (seen.has(dedupKey)) continue; - seen.add(dedupKey); - - const model = r.model ?? "cursor-auto"; - usages.push({ model, inputTokens, outputTokens, cacheWriteTokens: 0, cacheReadTokens: 0 }); - } - return usages; -} - -export async function parseCursorUsage(): Promise { - const dbPath = getDbPath(); - - try { - await fs.access(dbPath); - } catch { - return []; - } - - let db: SqliteDb; - try { - db = await openDb(dbPath); - } catch { - return []; - } - - try { - const seen = new Set(); - const bubbleRows = db.prepare(BUBBLE_QUERY).all(); - const result = aggregateToModelUsage(processRows(bubbleRows, seen)); - - if (result.length === 0) { - throw new Error( - "Cursor 3.1+ no longer exposes accurate token data locally — skipping. " + - "Token counts in state.vscdb are zeroed out in this version; " + - "usage lives server-side only at cursor.com/dashboard/usage." - ); - } - - return result; - } finally { - db.close(); - } -} diff --git a/cli/src/reporter.ts b/cli/src/reporter.ts deleted file mode 100644 index 89da97e..0000000 --- a/cli/src/reporter.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Config } from "./config.js"; -import type { UserReport } from "./types.js"; - -export async function postReport(config: Config, report: UserReport): Promise { - const url = `${config.backendUrl}/token-tracker/api/report`; - - let response: Response; - try { - response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.submitToken}`, - }, - body: JSON.stringify(report), - }); - } catch (err) { - const cause = - err instanceof Error && err.cause instanceof Error - ? ((err.cause as { code?: string }).code ?? err.cause.message) || err.message - : err instanceof Error - ? err.message - : String(err); - throw new Error(`Failed to POST to ${url}: ${cause}`); - } - - if (!response.ok) { - const body = await response.text(); - throw new Error(`POST ${url} → ${response.status}: ${body}`); - } -} diff --git a/example.env b/example.env index 146d071..41b076f 100644 --- a/example.env +++ b/example.env @@ -1,18 +1,3 @@ -# Vercel -BLOB_READ_WRITE_TOKEN="vercel_blob_rw_" - -# Token Tracker -# Plain-text password for the /token-tracker/dashboard gate -DASHBOARD_PASSWORD= -# Shared secret that collectors use to authenticate report submissions; share via /token-tracker?token= -SUBMIT_TOKEN= -# Injected automatically by Vercel for cron auth; set manually in local dev -CRON_SECRET= -# Local dev only: when set, src/token-tracker/storage.ts writes blobs to this -# directory instead of calling @vercel/blob. Lets you run Token Tracker -# locally without a real Vercel Blob store. -TOKEN_TRACKER_LOCAL_BLOB_DIR=./.token-tracker-local - # Resend RESEND_KEY=re_ diff --git a/mcp/bun.lock b/mcp/bun.lock deleted file mode 100644 index da95d43..0000000 --- a/mcp/bun.lock +++ /dev/null @@ -1,282 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "silver-tracker-mcp", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.0", - "zod": "^3.23.0", - }, - "devDependencies": { - "@types/better-sqlite3": "^7.6.13", - "@types/node": "^22", - "typescript": "^5.4.0", - }, - "optionalDependencies": { - "better-sqlite3": "^12.9.0", - }, - }, - }, - "packages": { - "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], - - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - - "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], - - "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], - - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "better-sqlite3": ["better-sqlite3@12.9.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ=="], - - "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], - - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], - - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - - "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], - - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - - "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], - - "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], - - "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "express-rate-limit": ["express-rate-limit@8.5.0", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - - "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], - - "hono": ["hono@4.12.17", "", {}, "sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ=="], - - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], - - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "node-abi": ["node-abi@3.90.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA=="], - - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - - "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - - "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], - - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - - "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], - - "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - - "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - - "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], - - "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], - - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - - "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - } -} diff --git a/mcp/package.json b/mcp/package.json deleted file mode 100644 index 5d83a77..0000000 --- a/mcp/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@ftaboadac/silver-tracker-mcp", - "version": "0.1.0", - "description": "MCP server for Silver Tracker — reports AI token usage from local tools to your dashboard", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/ftaboadac/open-silver.git", - "directory": "mcp" - }, - "type": "module", - "bin": { - "silver-tracker-mcp": "dist/mcp/src/index.js" - }, - "scripts": { - "build": "tsc", - "dev": "tsc --watch" - }, - "files": [ - "dist" - ], - "publishConfig": { - "access": "public" - }, - "engines": { - "node": ">=18.17.0" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.0", - "zod": "^3.23.0" - }, - "devDependencies": { - "@types/better-sqlite3": "^7.6.13", - "@types/node": "^22", - "typescript": "^5.4.0" - }, - "optionalDependencies": { - "better-sqlite3": "^12.9.0" - } -} diff --git a/mcp/src/index.ts b/mcp/src/index.ts deleted file mode 100644 index 59e0579..0000000 --- a/mcp/src/index.ts +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env node -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; -import { readConfig } from "../../cli/src/config.js"; -import { collectUsage } from "../../cli/src/collector.js"; -import { postReport } from "../../cli/src/reporter.js"; -import type { UserReport } from "../../cli/src/types.js"; - -const InputSchema = z.object({}); - -const server = new Server( - { name: "silver-tracker", version: "0.1.0" }, - { capabilities: { tools: {} } } -); - -server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: "report_token_usage", - description: - "Collect AI token usage from local tools (Claude Code, Cursor, Codex, Gemini CLI) and report it to the Silver Tracker dashboard.", - inputSchema: { - type: "object", - properties: {}, - required: [], - }, - }, - ], -})); - -server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name !== "report_token_usage") { - throw new Error(`Unknown tool: ${request.params.name}`); - } - - InputSchema.parse(request.params.arguments ?? {}); - - const config = await readConfig(); - if (!config) { - return { - content: [ - { - type: "text" as const, - text: "No Silver Tracker config found at ~/.silver-tracker/config.json. Run `silver-tracker sync` once to set up credentials.", - }, - ], - }; - } - - const sources = await collectUsage(); - - if (sources.length === 0) { - return { - content: [ - { - type: "text" as const, - text: "No usage data detected. Make sure you have used Claude Code, Cursor, Codex CLI, or Gemini CLI on this machine.", - }, - ], - }; - } - - const report: UserReport = { - email: config.email, - sources, - updatedAt: new Date().toISOString(), - }; - - try { - await postReport(config, report); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - content: [ - { - type: "text" as const, - text: `Failed to report token usage: ${message}`, - }, - ], - }; - } - - const sourceSummaries = sources.map((s) => { - const totalTokens = s.models.reduce( - (sum, m) => sum + m.inputTokens + m.outputTokens + m.cacheReadTokens + m.cacheWriteTokens, - 0 - ); - const totalCost = s.models.reduce((sum, m) => sum + m.costUsd, 0); - const tokensK = Math.round(totalTokens / 1000); - return `${s.source} (${tokensK}K tokens, $${totalCost.toFixed(2)})`; - }); - - const totalCost = sources.reduce( - (sum, s) => sum + s.models.reduce((ms, m) => ms + m.costUsd, 0), - 0 - ); - - return { - content: [ - { - type: "text" as const, - text: `Reported token usage to Silver Tracker. Sources: ${sourceSummaries.join(", ")}. Total: $${totalCost.toFixed(2)}`, - }, - ], - }; -}); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json deleted file mode 100644 index af8fdb6..0000000 --- a/mcp/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "..", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "resolveJsonModule": true - }, - "include": ["src/**/*"] -} diff --git a/src/app/page.tsx b/src/app/page.tsx index 4e44a83..4c514aa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -102,12 +102,6 @@ const tools: { "Generate invoices for SilverEd or as a Silver.dev Interviewer.", href: "/invoice-generator", }, - { - title: "Token Tracker", - description: - "Submit your API key to fetch token usage and costs across Anthropic and OpenAI.", - href: "/token-tracker", - }, ], }, ]; diff --git a/src/app/token-tracker/api/report/route.ts b/src/app/token-tracker/api/report/route.ts deleted file mode 100644 index e36915c..0000000 --- a/src/app/token-tracker/api/report/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { readReport, writeReport } from "@/token-tracker/storage"; -import { hashEmail, normalizeEmail } from "@/token-tracker/utils"; -import { timingSafeEqual } from "crypto"; -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; - -const SourceReportSchema = z.object({ - source: z.enum(["claude-code", "cursor", "codex", "gemini-cli", "aider"]), - models: z.array( - z.object({ - model: z.string(), - inputTokens: z.number(), - outputTokens: z.number(), - cacheReadTokens: z.number(), - cacheWriteTokens: z.number(), - costUsd: z.number(), - }), - ), - lastSyncedAt: z.string().datetime(), -}); - -const UserReportSchema = z.object({ - email: z.string().email(), - sources: z.array(SourceReportSchema), - updatedAt: z.string().datetime(), -}); - -function checkAuth(request: NextRequest): boolean { - const submitToken = process.env.SUBMIT_TOKEN; - if (!submitToken) return false; - - const authHeader = request.headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) return false; - - const token = authHeader.slice(7); - try { - const a = Buffer.from(token); - const b = Buffer.from(submitToken); - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); - } catch { - return false; - } -} - -export async function POST(request: NextRequest) { - if (!checkAuth(request)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - const parsed = UserReportSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.flatten() }, - { status: 400 }, - ); - } - - const incoming = parsed.data; - const email = normalizeEmail(incoming.email); - const hashedEmail = hashEmail(email); - const pathname = `token-tracker/${hashedEmail}.json`; - - const existing = await readReport(hashedEmail); - - const mergedSourcesMap = new Map( - (existing?.sources ?? []).map((s) => [s.source, s]), - ); - const sourcesUpdated: string[] = []; - - for (const source of incoming.sources) { - mergedSourcesMap.set(source.source, source); - sourcesUpdated.push(source.source); - } - - const updatedReport = { - email, - sources: Array.from(mergedSourcesMap.values()), - updatedAt: new Date().toISOString(), - }; - - await writeReport(pathname, updatedReport); - - return NextResponse.json({ ok: true, email, sourcesUpdated }); -} diff --git a/src/app/token-tracker/dashboard/page.tsx b/src/app/token-tracker/dashboard/page.tsx deleted file mode 100644 index 2713531..0000000 --- a/src/app/token-tracker/dashboard/page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Container } from "@/components/container"; -import { Heading } from "@/components/heading"; -import { Spacer } from "@/components/spacer"; -import { getTokenTrackerConfig } from "@/token-tracker/config"; -import { isAuthenticated } from "@/token-tracker/actions"; -import { DashboardTable } from "@/token-tracker/components/dashboard-table"; -import { PasswordForm } from "@/token-tracker/components/password-form"; -import { SetupRequired } from "@/token-tracker/components/setup-required"; -import { listAllReports } from "@/token-tracker/storage"; -import type { UserReport } from "@/token-tracker/types"; - -type Props = { - searchParams: Promise<{ error?: string }>; -}; - -async function getReports(): Promise { - try { - const entries = await listAllReports(); - return entries - .map((e) => e.report) - .sort( - (a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), - ); - } catch { - return []; - } -} - -export default async function DashboardPage({ searchParams }: Props) { - const { isConfigured, missingVars } = getTokenTrackerConfig(); - if (!isConfigured) { - return ; - } - - const authed = await isAuthenticated(); - - if (!authed) { - const { error } = await searchParams; - return ( - - - Token Usage Dashboard - - - - - ); - } - - const reports = await getReports(); - - return ( - - - Token Usage Dashboard - - - - - ); -} diff --git a/src/app/token-tracker/page.tsx b/src/app/token-tracker/page.tsx deleted file mode 100644 index 477b0d4..0000000 --- a/src/app/token-tracker/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { METADATA } from "@/token-tracker/constants"; -import { LandingPage } from "@/token-tracker/pages/landing"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: METADATA.title, - description: METADATA.description, - openGraph: { - title: `${METADATA.title} • Open Silver`, - description: METADATA.description, - type: "website", - }, -}; - -export default function TokenTrackerPage() { - return ; -} diff --git a/src/token-tracker/README.md b/src/token-tracker/README.md deleted file mode 100644 index 524d0db..0000000 --- a/src/token-tracker/README.md +++ /dev/null @@ -1,210 +0,0 @@ -# Token Tracker - -Token Tracker — Self-hosted AI token usage tracking for teams. - -## Overview - -Token Tracker gives engineering managers and team leads visibility into how much AI tooling their team is actually using, across Claude Code, Codex CLI, and Gemini CLI. Each developer runs a small local collector (CLI or MCP server) that reads the usage logs already written to their machine by those tools. No provider API keys or admin credentials are involved at any point. - -Each organization deploys their own isolated instance to Vercel. Reports from team members are sent directly to that instance and stored in the project's own Vercel Blob store. The backend never processes data from other organizations, and Anthropic never sees it. - -The key design tradeoff is that precision requires local log access. Token counts come from the actual log files written by each tool, not from provider usage APIs, which means numbers are exact (including cache tokens) and available immediately after a session ends — but they depend on those log files being present on the developer's machine. - -## Architecture - -``` -[Developer's machine] - local collector (CLI or MCP) - → reads ~/.claude/, ~/.codex/, ~/.gemini/ session logs - → POST /token-tracker/api/report (Authorization: Bearer SUBMIT_TOKEN) - -[Vercel — your project] - /token-tracker/api/report - → validates SUBMIT_TOKEN - → writes token-tracker/{sha256(email)}.json to Vercel Blob - -[Dashboard — password-protected] - /token-tracker/dashboard - → reads all blobs from Vercel Blob - → renders per-user, per-model usage with aggregate team metrics -``` - -Each user's data is stored as a single JSON file keyed by a SHA-256 hash of their email. Submitting a new report overwrites the previous one. - -## Supported sources - -| Source | Status | Notes | -|---|---|---| -| Claude Code | ✅ Full support | Reads `~/.claude/projects/**/*.jsonl`; real token counts and cache data | -| Codex CLI | ✅ Full support | Reads `~/.codex/` session files | -| Gemini CLI | ✅ Full support | Reads `~/.gemini/` session files | -| Cursor | ⚠️ ≤2.x only | See below | - -### Cursor 3.1+ limitation - -Cursor **≤2.x** writes per-turn token counts and model names to `state.vscdb` (the `cursorDiskKV` table, `bubbleId:*` keys). The collector reads these correctly. - -Cursor **3.1+** stopped writing token counts to local storage. Conversation data is now stored as AES-256-GCM encrypted blobs (`agentKv:blob:*` entries). The `bubbleId` rows remain but `tokenCount` is always `{inputTokens:0, outputTokens:0}` and `modelInfo` is null. As a result, the collector prints a warning and skips Cursor on 3.1+ installs: - -``` -Warning (cursor): Cursor 3.1+ no longer exposes accurate token data locally — skipping. -``` - -Content-length estimation was considered and rejected: ±30–50% error, no model attribution, and no cache data would produce misleading numbers when mixed with precise Claude Code figures on the same dashboard. Cursor 3.1+ users should check their usage at [cursor.com/dashboard/usage](https://cursor.com/dashboard/usage) directly. If Cursor re-exposes local token data in a future version, the `cursor` source will be re-enabled without dashboard schema changes. - -## Deploying your own instance - -The public deployment at [open.silver](https://open.silver) is a reference implementation only — it is intentionally unconfigured and will display a setup-required screen. - -### 1. Deploy to Vercel - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsilver-dev-org%2Fopen-silver) - -Click the button above, or import `https://github.com/silver-dev-org/open-silver` manually from the Vercel dashboard. - -### 2. Add a Vercel Blob store - -In your Vercel project: **Storage → Create → Blob**. This automatically sets the `BLOB_READ_WRITE_TOKEN` environment variable. See [Vercel Blob docs](https://vercel.com/docs/storage/vercel-blob) for details. - -### 3. Set environment variables - -In **Settings → Environment Variables**, add: - -| Variable | Description | -|---|---| -| `DASHBOARD_PASSWORD` | Password to unlock the `/token-tracker/dashboard` page | -| `SUBMIT_TOKEN` | Shared secret that team members use to authenticate report submissions | -| `BLOB_READ_WRITE_TOKEN` | Set automatically by Vercel when you add a Blob store | - -Redeploy after setting all variables. If any required variable is missing, Token Tracker pages will display a setup-required screen and API routes will return `503`. - -### 4. Share the submit token with your team - -`SUBMIT_TOKEN` is a shared secret — it authorizes anyone who has it to submit usage. The recommended way to distribute it is as a URL parameter on the landing page: - -``` -https://yourapp.vercel.app/token-tracker?token=YOUR_SUBMIT_TOKEN -``` - -The install flow pre-fills the token field from the URL, so team members only need to paste their email and confirm. - -## Installing for team members - -Each developer installs the collector once. The MCP path is preferred because their AI agent can trigger a sync automatically; the CLI fallback works in any environment. - -**Claude Code** -```sh -claude mcp add silver-tracker -- npx -y @ftaboadac/silver-tracker-mcp -``` - -**Codex CLI** -```sh -codex mcp add silver-tracker -- npx -y @ftaboadac/silver-tracker-mcp -``` - -**Cursor** — edit `~/.cursor/mcp.json`: -```json -{ - "mcpServers": { - "silver-tracker": { - "command": "npx", - "args": ["-y", "@ftaboadac/silver-tracker-mcp"] - } - } -} -``` - -**Gemini CLI** — edit `~/.gemini/settings.json`: -```json -{ - "mcpServers": { - "silver-tracker": { - "command": "npx", - "args": ["-y", "@ftaboadac/silver-tracker-mcp"] - } - } -} -``` - -**CLI fallback** (any environment without MCP support): -```sh -npx -y @ftaboadac/silver-tracker -``` - -### First-run setup - -On first invocation, the CLI or MCP server will prompt for three values and save them to `~/.silver-tracker/config.json`: - -- **Email** — the developer's work email (used as a unique identifier on the dashboard) -- **Backend URL** — your Vercel deployment URL, e.g. `https://yourapp.vercel.app` -- **Submit token** — the `SUBMIT_TOKEN` from your deployment - -Subsequent runs use the saved config without prompting. - -## Usage - -### Via MCP - -Tell your AI agent: - -> Report my token usage - -The MCP server will collect local usage data and submit it to the configured backend. - -### Via CLI - -```sh -# Collect and submit usage -silver-tracker - -# or explicitly -silver-tracker sync - -# Preview what would be submitted without sending -silver-tracker sync --dry-run -``` - -`--dry-run` prints the collected report as JSON to stdout and exits without making a network request. Useful for verifying what data will be sent before your first real submission. - -## Local development - -### Backend - -```sh -# Set up a local blob store (skips Vercel Blob entirely) -export TOKEN_TRACKER_LOCAL_BLOB_DIR=/tmp/silver-tracker-blobs - -bun dev -``` - -`TOKEN_TRACKER_LOCAL_BLOB_DIR` redirects all blob reads and writes to the local filesystem at the given path. This eliminates the need for a Vercel Blob token during development — set it alongside `DASHBOARD_PASSWORD` and you have a fully functional local backend. - -### CLI / MCP against a local backend - -Set the backend URL to your local server during first-run setup, or edit `~/.silver-tracker/config.json` directly: - -```json -{ - "email": "you@example.com", - "backendUrl": "http://localhost:3000", - "submitToken": "any-local-value" -} -``` - -Then run `silver-tracker sync --dry-run` to verify collection without hitting the network, or drop `--dry-run` to test a full round-trip. - -## Privacy and security - -- **No provider API keys.** The collector reads local log files only — it never calls Anthropic, OpenAI, or Google APIs. -- **Data isolation.** Each organization's data lives in their own Vercel Blob store. There is no shared backend. -- **Authorized writes only.** The `/token-tracker/api/report` endpoint requires `Authorization: Bearer SUBMIT_TOKEN`. Without it, submissions are rejected with `401`. -- **Protected dashboard.** The dashboard requires `DASHBOARD_PASSWORD`. The session cookie is `httpOnly`, `sameSite: strict`, and `secure` in production. Password comparisons use `crypto.timingSafeEqual` to prevent timing attacks. -- **Local-only collection.** Collectors only read files that the developer's AI tools have already written to their own machine. Nothing is installed that intercepts or proxies tool traffic. - -## Limitations and known issues - -**Cursor 3.1+** — covered in [Supported sources](#supported-sources) above. - -**Pricing data is hardcoded.** Cost estimates are calculated from a static table in [`cli/src/pricing.ts`](../../../cli/src/pricing.ts). If a provider changes their pricing or releases a new model that isn't in the table, cost figures will be wrong. Update the `MODEL_PRICES` object in that file when prices change. Unknown models fall back to a Sonnet-equivalent estimate. - -**Token counts require local logs.** If a developer clears their tool's local history, or uses a tool on a machine other than the one running the collector, those sessions won't appear in their report. diff --git a/src/token-tracker/actions.ts b/src/token-tracker/actions.ts deleted file mode 100644 index 276579a..0000000 --- a/src/token-tracker/actions.ts +++ /dev/null @@ -1,41 +0,0 @@ -"use server"; - -import { dashboardCookieValue } from "@/token-tracker/utils"; -import crypto from "crypto"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; - -function timingSafeEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false; - return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); -} - -export async function isAuthenticated(): Promise { - const expected = dashboardCookieValue(); - if (!expected) return false; - const cookieStore = await cookies(); - const value = cookieStore.get("dashboard-auth")?.value ?? ""; - return timingSafeEqual(value, expected); -} - -export async function verifyDashboardPassword(formData: FormData) { - const submitted = (formData.get("password") as string | null) ?? ""; - const expected = process.env.DASHBOARD_PASSWORD ?? ""; - - const isMatch = - expected.length > 0 && timingSafeEqual(submitted, expected); - - if (isMatch) { - const cookieStore = await cookies(); - cookieStore.set("dashboard-auth", dashboardCookieValue(), { - httpOnly: true, - sameSite: "strict", - path: "/token-tracker/dashboard", - maxAge: 60 * 60 * 24 * 30, - secure: process.env.NODE_ENV === "production", - }); - redirect("/token-tracker/dashboard"); - } - - redirect("/token-tracker/dashboard?error=1"); -} diff --git a/src/token-tracker/components/dashboard-table.tsx b/src/token-tracker/components/dashboard-table.tsx deleted file mode 100644 index d243eb8..0000000 --- a/src/token-tracker/components/dashboard-table.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { ModelTable } from "@/token-tracker/components/model-table"; -import { formatTokens } from "@/token-tracker/utils"; -import type { SourceReport, UserReport } from "@/token-tracker/types"; - -interface DashboardTableProps { - reports: UserReport[]; -} - -interface AggregateMetrics { - totalInputTokens: number; - totalOutputTokens: number; - activeUsers: number; -} - -const SOURCE_LABELS: Record = { - "claude-code": "Claude Code", - cursor: "Cursor", - codex: "Codex", - "gemini-cli": "Gemini CLI", - aider: "Aider", -}; - -function computeAggregates(reports: UserReport[]): AggregateMetrics { - let totalInputTokens = 0; - let totalOutputTokens = 0; - - for (const report of reports) { - for (const source of report.sources) { - for (const m of source.models) { - totalInputTokens += m.inputTokens; - totalOutputTokens += m.outputTokens; - } - } - } - - return { - totalInputTokens, - totalOutputTokens, - activeUsers: reports.length, - }; -} - -export function DashboardTable({ reports }: DashboardTableProps) { - if (reports.length === 0) { - return ( -

- No reports submitted yet. -

- ); - } - - const metrics = computeAggregates(reports); - - return ( -
-
- - - -
- {reports.map((report) => ( - - ))} -
- ); -} - -function MetricCard({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function UserCard({ report }: { report: UserReport }) { - const total = report.sources.reduce( - (s, src) => - s + - src.models.reduce((ms, m) => ms + m.inputTokens + m.outputTokens, 0), - 0, - ); - - return ( - - - - {report.email} - - {formatTokens(total)} tokens · updated{" "} - {new Date(report.updatedAt).toLocaleString()} - - - - - {report.sources.length === 0 ? ( -

No usage recorded.

- ) : ( - report.sources.map((src) => ( - - )) - )} -
-
- ); -} - -function SourceSection({ source }: { source: SourceReport }) { - const label = SOURCE_LABELS[source.source] ?? source.source; - - return ( -
-
- - {label} - - - synced {new Date(source.lastSyncedAt).toLocaleString()} - -
- {source.models.length === 0 ? ( -

No usage recorded.

- ) : ( - - )} -
- ); -} diff --git a/src/token-tracker/components/model-table.tsx b/src/token-tracker/components/model-table.tsx deleted file mode 100644 index 9a14c9b..0000000 --- a/src/token-tracker/components/model-table.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { formatTokens } from "@/token-tracker/utils"; -import type { ModelUsage } from "@/token-tracker/types"; - -interface ModelTableProps { - models: ModelUsage[]; - compact?: boolean; -} - -function formatCost(usd: number): string { - return `$${usd.toFixed(4)}`; -} - -export function ModelTable({ models, compact }: ModelTableProps) { - const cell = compact ? "py-1" : "py-2"; - - return ( - - - - - - - - - - - - - {models.map((m) => ( - - - - - - - - - ))} - -
ModelInputOutputCache readCache writeCost
{m.model} - {formatTokens(m.inputTokens)} - - {formatTokens(m.outputTokens)} - - {formatTokens(m.cacheReadTokens)} - - {formatTokens(m.cacheWriteTokens)} - - {formatCost(m.costUsd)} -
- ); -} diff --git a/src/token-tracker/components/password-form.tsx b/src/token-tracker/components/password-form.tsx deleted file mode 100644 index d8125c3..0000000 --- a/src/token-tracker/components/password-form.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { verifyDashboardPassword } from "@/token-tracker/actions"; - -interface PasswordFormProps { - hasError: boolean; -} - -export function PasswordForm({ hasError }: PasswordFormProps) { - return ( -
-
- - -
- {hasError && ( -

Incorrect password.

- )} - -
- ); -} diff --git a/src/token-tracker/components/setup-required.tsx b/src/token-tracker/components/setup-required.tsx deleted file mode 100644 index 1c2e75d..0000000 --- a/src/token-tracker/components/setup-required.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Container } from "@/components/container"; -import { Heading } from "@/components/heading"; -import { Spacer } from "@/components/spacer"; -import { Button } from "@/components/ui/button"; - -const DEPLOY_URL = - "https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fopen-silver%2Fopen-silver"; - -const DOCS_URL = - "https://github.com/open-silver/open-silver/blob/main/src/token-tracker/README.md"; - -interface SetupRequiredProps { - missingVars?: string[]; -} - -export function SetupRequired({ missingVars }: SetupRequiredProps) { - return ( - - - Self-Hosted Setup Required - - -
-
-

- Token Tracker is an internal team utility designed to be - self-hosted. Each organization deploys their own isolated instance - — usage data never leaves your Vercel project. -

-

- The public Open Silver deployment is a reference implementation - only. To use Token Tracker, deploy your own instance and configure - the required environment variables. -

-
- - {missingVars && missingVars.length > 0 && ( -
-

Missing environment variables

-
    - {missingVars.map((v) => ( -
  • - {v} -
  • - ))} -
-

- Set these in your Vercel project settings or local{" "} - .env file, then redeploy. -

-
- )} - - -
-
- ); -} diff --git a/src/token-tracker/config.ts b/src/token-tracker/config.ts deleted file mode 100644 index 96d62d7..0000000 --- a/src/token-tracker/config.ts +++ /dev/null @@ -1,27 +0,0 @@ -const REQUIRED_VARS = ["DASHBOARD_PASSWORD"] as const; - -export type RequiredVar = - | (typeof REQUIRED_VARS)[number] - | "BLOB_READ_WRITE_TOKEN"; - -export function getTokenTrackerConfig(): { - isConfigured: boolean; - missingVars: RequiredVar[]; -} { - const missingVars: RequiredVar[] = REQUIRED_VARS.filter( - (key) => !process.env[key], - ); - - const hasStorage = - !!process.env.BLOB_READ_WRITE_TOKEN || - !!process.env.TOKEN_TRACKER_LOCAL_BLOB_DIR; - - if (!hasStorage) { - missingVars.push("BLOB_READ_WRITE_TOKEN"); - } - - return { - isConfigured: missingVars.length === 0, - missingVars, - }; -} diff --git a/src/token-tracker/constants.ts b/src/token-tracker/constants.ts deleted file mode 100644 index 24cbaf9..0000000 --- a/src/token-tracker/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const METADATA = { - title: "Token Tracker", - description: - "A local collector that reads AI token usage from Claude Code logs and reports it to your team dashboard.", -}; - -export const BLOB_PREFIX = "token-tracker"; diff --git a/src/token-tracker/pages/landing.tsx b/src/token-tracker/pages/landing.tsx deleted file mode 100644 index 06804ae..0000000 --- a/src/token-tracker/pages/landing.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { Container } from "@/components/container"; -import { Description } from "@/components/description"; -import { Heading } from "@/components/heading"; -import { Spacer } from "@/components/spacer"; -import { Button } from "@/components/ui/button"; - -const DEPLOY_URL = - "https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsilver-dev-org%2Fopen-silver"; - -const GITHUB_URL = "https://github.com/silver-dev-org/open-silver"; - -const configJson = `{ - "mcpServers": { - "silver-tracker": { - "command": "npx", - "args": ["-y", "@ftaboadac/silver-tracker-mcp"] - } - } -}`; - -const installCards = [ - { - label: "Claude Code", - sublabel: null, - code: "claude mcp add silver-tracker --\nnpx -y @ftaboadac/silver-tracker-mcp", - }, - { - label: "Codex CLI", - sublabel: null, - code: "codex mcp add silver-tracker --\nnpx -y @ftaboadac/silver-tracker-mcp", - }, - { - label: "Cursor / Gemini CLI", - sublabel: "~/.cursor/mcp.json or ~/.gemini/settings.json", - code: configJson, - }, - { - label: "CLI fallback", - sublabel: "Any environment without MCP support", - code: "npx -y @ftaboadac/silver-tracker", - }, -]; - -function CodeBlock({ children }: { children: string }) { - return ( -
-      {children}
-    
- ); -} - -export function LandingPage() { - return ( - -
- - Token Tracker - - - - Track AI token usage across your team's coding tools — - self-hosted, no API keys required. - - - -
- - - -
-

- Install -

-
- {installCards.map((card) => ( -
-
-

{card.label}

- {card.sublabel && ( -

{card.sublabel}

- )} -
- {card.code} -
- ))} -
-
- - - -
-
-
- - Self-hosted - -
-

- Each organization deploys their own instance. Usage data lives - entirely within your own Vercel project — we never see it. -

-

- Collectors read local log files from Claude Code, Codex CLI, - and Gemini CLI. No provider admin keys required. Reports are - stored in your own Vercel Blob store, behind a - password-protected dashboard. -

-
- -
- -
-

- Supported sources -

-
    -
  • Claude Code
  • -
  • Codex CLI
  • -
  • Gemini CLI
  • -
  • - Cursor 3.1+ no longer exposes local token data — see README -
  • -
-
-
-
-
- ); -} diff --git a/src/token-tracker/storage.ts b/src/token-tracker/storage.ts deleted file mode 100644 index 9312051..0000000 --- a/src/token-tracker/storage.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { BLOB_PREFIX } from "@/token-tracker/constants"; -import type { UserReport } from "@/token-tracker/types"; -import { get, list, put } from "@vercel/blob"; -import fs from "fs/promises"; -import path from "path"; - -const localDir = process.env.TOKEN_TRACKER_LOCAL_BLOB_DIR; - -function isValidUserReport(value: unknown): value is UserReport { - return ( - typeof value === "object" && - value !== null && - typeof (value as UserReport).email === "string" && - (value as UserReport).email.includes("@") && - Array.isArray((value as UserReport).sources) && - typeof (value as UserReport).updatedAt === "string" - ); -} - -async function readBlobText(pathname: string): Promise { - if (localDir) { - try { - return await fs.readFile(path.join(localDir, pathname), "utf8"); - } catch { - return null; - } - } - const result = await get(pathname, { access: "private" }); - if (!result || result.statusCode !== 200) return null; - return new Response(result.stream).text(); -} - -async function writeBlobText(pathname: string, body: string): Promise { - if (localDir) { - const filePath = path.join(localDir, pathname); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, body, "utf8"); - return; - } - await put(pathname, body, { - access: "private", - contentType: "application/json", - addRandomSuffix: false, - }); -} - -async function listBlobPathnames(prefix: string): Promise { - if (localDir) { - const dir = path.join(localDir, prefix); - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - return entries - .filter((e) => e.isFile()) - .map((e) => path.posix.join(prefix.replace(/\/$/, ""), e.name)); - } catch { - return []; - } - } - const { blobs } = await list({ prefix }); - return blobs.map((b) => b.pathname); -} - -async function getReportByPathname( - pathname: string, -): Promise { - const text = await readBlobText(pathname); - if (!text) return null; - const data: unknown = JSON.parse(text); - return isValidUserReport(data) ? data : null; -} - -export async function readReport(hashedId: string): Promise { - return getReportByPathname(`${BLOB_PREFIX}/${hashedId}.json`); -} - -export async function writeReport( - pathname: string, - report: UserReport, -): Promise { - await writeBlobText(pathname, JSON.stringify(report)); -} - -export async function listAllReports(): Promise< - { report: UserReport; pathname: string }[] -> { - const pathnames = await listBlobPathnames(`${BLOB_PREFIX}/`); - - const results = await Promise.allSettled( - pathnames.map(async (pathname) => { - const data = await getReportByPathname(pathname); - if (!data) { - console.warn(`[token-tracker] Skipping invalid blob: ${pathname}`); - return null; - } - return { report: data, pathname }; - }), - ); - - return results - .filter( - ( - r, - ): r is PromiseFulfilledResult<{ - report: UserReport; - pathname: string; - } | null> => r.status === "fulfilled", - ) - .map((r) => r.value) - .filter((r): r is { report: UserReport; pathname: string } => r !== null); -} diff --git a/src/token-tracker/types.ts b/src/token-tracker/types.ts deleted file mode 100644 index 2f4a99f..0000000 --- a/src/token-tracker/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -export type UsageSource = - | "claude-code" - | "cursor" - | "codex" - | "gemini-cli" - | "aider"; - -export type ModelUsage = { - model: string; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - costUsd: number; -}; - -export type SourceReport = { - source: UsageSource; - models: ModelUsage[]; - lastSyncedAt: string; -}; - -export type UserReport = { - email: string; - sources: SourceReport[]; - updatedAt: string; -}; diff --git a/src/token-tracker/utils.ts b/src/token-tracker/utils.ts deleted file mode 100644 index 564f6d4..0000000 --- a/src/token-tracker/utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -import crypto from "crypto"; - -export function dashboardCookieValue(): string { - const password = process.env.DASHBOARD_PASSWORD; - if (!password) return ""; - return crypto.createHash("sha256").update(password).digest("hex"); -} - -export function normalizeEmail(email: string): string { - return email.trim().toLowerCase(); -} - -export function validateEmail(email: string): boolean { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -export function hashEmail(email: string): string { - return crypto.createHash("sha256").update(email).digest("hex"); -} - -export function formatTokens(value: number): string { - if (value >= 1_000_000) { - return `${(value / 1_000_000).toFixed(1)}M`; - } - if (value >= 1_000) { - return `${(value / 1_000).toFixed(1)}K`; - } - return String(value); -} diff --git a/tsconfig.json b/tsconfig.json index 449976f..f68ef58 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "cli", "mcp"] + "exclude": ["node_modules", "cli"] } From 671ac1364b57e2c4b42ea8aa9e3b20c94ac29e21 Mon Sep 17 00:00:00 2001 From: Facundo Taboada Date: Tue, 5 May 2026 16:22:17 -0300 Subject: [PATCH 5/5] docs: clean up README scope and revert unrelated example.env changes --- cli/README.md | 38 +++++++++++++++++--------------------- example.env | 3 +++ 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/cli/README.md b/cli/README.md index 4c91515..b0ad710 100644 --- a/cli/README.md +++ b/cli/README.md @@ -6,14 +6,15 @@ No account, no server, no network calls — everything runs locally. ## Install -```sh -npm install -g @ftaboadac/silver-token-tracker -``` +This package will be published to npm by the silver-dev-org maintainers. Install instructions will be added once the official package is live. -Or run without installing: +For now, you can run the CLI directly from this repo: ```sh -npx -y @ftaboadac/silver-token-tracker run +git clone https://github.com/silver-dev-org/open-silver.git +cd open-silver/cli +bun install && bun run build +node dist/index.js run ``` ## Usage @@ -29,23 +30,15 @@ silver-token-tracker --help # show help and supported sources ``` Claude Code ──────────────────────────────────────────────────────────────────────── - ┌────────────────────────────┬──────────┬──────────┬────────────┬─────────────┬────────────┐ - │ Model │ Input │ Output │ Cache Read │ Cache Write │ Cost (USD) │ - ├────────────────────────────┼──────────┼──────────┼────────────┼─────────────┼────────────┤ - │ claude-sonnet-4-6 │ 124.5K │ 18.2K │ 890.1K │ 45.3K │ $1.26 │ - │ claude-opus-4 │ 12.1K │ 2.3K │ 5.0K │ 1.1K │ $0.89 │ - └────────────────────────────┴──────────┴──────────┴────────────┴─────────────┴────────────┘ - - Codex CLI - ──────────────────────────────────────────────────────────────────────── - ┌────────────────────────────┬──────────┬──────────┬────────────┬─────────────┬────────────┐ - │ Model │ Input │ Output │ Cache Read │ Cache Write │ Cost (USD) │ - ├────────────────────────────┼──────────┼──────────┼────────────┼─────────────┼────────────┤ - │ gpt-4.1 │ 45.2K │ 6.8K │ 0 │ 0 │ $0.15 │ - └────────────────────────────┴──────────┴──────────┴────────────┴─────────────┴────────────┘ + ┌───────────────────────────┬───────┬────────┬────────────┬─────────────┬────────────┐ + │ Model │ Input │ Output │ Cache Read │ Cache Write │ Cost (USD) │ + ├───────────────────────────┼───────┼────────┼────────────┼─────────────┼────────────┤ + │ claude-sonnet-4-6 │ 8.7K │ 1.0M │ 52.2M │ 3.5M │ $44.57 │ + │ claude-haiku-4-5-20251001 │ 4.4K │ 67.6K │ 5.2M │ 571.3K │ $1.26 │ + └───────────────────────────┴───────┴────────┴────────────┴─────────────┴────────────┘ ──────────────────────────────────────────────────────────────────────── - TOTAL 2 sources · 1.1M tokens · $2.30 + TOTAL 1 source · 62.6M tokens · $45.83 ──────────────────────────────────────────────────────────────────────── ``` @@ -65,4 +58,7 @@ Source headers and the cost column are color-highlighted in the terminal. - Token counts are aggregated across all sessions found on disk (all-time, not filtered by date). - Costs are estimated using public API pricing — they may differ from what you are actually billed if you are on a subscription plan. -- This package is a reference implementation published under the author's personal scope (`@ftaboadac`). The maintainers of [silver-dev-org](https://github.com/silver-dev-org) will publish the official version under their repo with attribution. When that happens, this package will be deprecated in favor of the official one. + +--- + +**Reference implementation:** A working build is published on npm under `@ftaboadac/silver-token-tracker` for early testing purposes. This is not the canonical install path — the official package will be published by the silver-dev-org maintainers once the repo is transferred. diff --git a/example.env b/example.env index 41b076f..69055dc 100644 --- a/example.env +++ b/example.env @@ -1,3 +1,6 @@ +# Vercel +BLOB_READ_WRITE_TOKEN="vercel_blob_rw_" + # Resend RESEND_KEY=re_