From acea7f3d24e0c9e05a6ddb766c0a9b4541681e3b Mon Sep 17 00:00:00 2001 From: Aditya Borakati <41518783+ABorakati@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:19:50 +0100 Subject: [PATCH] feat: live multi-provider quota panel (#1278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: live provider quota panel (Claude + Codex) Adds Claude and Codex plan usage to the context window percentage circle tooltip, querying their respective APIs directly using existing CLI auth tokens. Co-Authored-By: Claude Sonnet 4.6 * fix: address review feedback - restore quota trigger, guard NaN date - Re-add triggerFetch() on agent idle/error in agent status subscription - Guard formatResetsAtLabel against NaN from malformed API date strings Co-Authored-By: Claude Sonnet 4.6 * fix: resolve composer conflict and stub subscribe in websocket tests * feat(quota): add cursor, copilot, zai, grok, and kimi quota UI and improve type safety * feat(quota): make Claude and Codex pluggable, and map additional quota/credits metrics * security(quota): fix shell injection vulnerability by migrating exec to execFile * revert: restore original scripts/dev.ps1 and packages/desktop/scripts/dev.ps1 * Fix i18n resource test expectation * Fix quota tooltip empty-provider state * Preserve zero values in Grok quota * Fix empty quota fetch state * Ignore quota frames in relay reconnect tests * Persist refreshed Codex tokens to source auth file * fix(quota): read Claude OAuth credentials from the macOS login Keychain On macOS, Claude Code stores its OAuth credential in the login Keychain (generic-password item, service "Claude Code-credentials"), not in ~/.claude/.credentials.json — that file usually does not exist there, so the Claude provider's existsSync check failed and macOS users silently got no Claude quota. Read it via the macOS `security` CLI (its ACL only trusts /usr/bin/security to decrypt; a native Keychain read would prompt), and stay read-only there since Claude Code owns the refresh/persist. Linux and Windows keep using ~/.claude/.credentials.json unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix quota keychain timeout and local host reconciliation * Fix terminal notification test quota stub * Ignore quota frames in terminal notification tests * fix(quota): bypass Claude token refresh on macOS Keychain-backed logins * fix(dev): update local dev daemon port to 6768 on Windows scripts/dev.ps1 * fix(dev): bypass Unix dev-daemon.sh on Windows in dev.ps1 * fix(client): handle and dispatch provider_quota event in DaemonClient * fix(desktop): fix PowerShell quote stripping in desktop dev.ps1 config seeding * fix(desktop): execute config update via temp file to avoid PowerShell quoting bugs * fix(desktop): fix concurrently command invocation on Windows in dev.ps1 * fix(desktop): resolve path escaping and environment setting syntax errors in dev.ps1 for Windows * Add on-demand provider usage views * Fix Cursor usage billing dates * Move provider usage renderer coverage to e2e * Use provider manifest for quota fetchers * Add provider usage fetch timeouts * Reshape provider usage fetchers --------- Co-authored-by: ABorakati Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Mohamed Boudra Co-authored-by: lumingjun --- docs/providers.md | 15 + package-lock.json | 76 +- packages/app/e2e/helpers/provider-usage.ts | 154 ++++ packages/app/e2e/helpers/settings.ts | 3 +- .../app/e2e/provider-usage-settings.spec.ts | 152 ++++ .../app/e2e/provider-usage-tooltip.spec.ts | 113 +++ .../src/components/context-window-meter.tsx | 73 +- .../components/context-window-meter.utils.ts | 9 + packages/app/src/composer/index.tsx | 17 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../app/src/provider-usage/balance-bar.tsx | 118 +++ packages/app/src/provider-usage/card.tsx | 201 +++++ packages/app/src/provider-usage/copy.ts | 18 + packages/app/src/provider-usage/format.ts | 53 ++ packages/app/src/provider-usage/list.tsx | 26 + .../src/provider-usage/settings-section.tsx | 96 +++ packages/app/src/provider-usage/tone.ts | 8 + .../src/provider-usage/tooltip-section.tsx | 71 ++ packages/app/src/provider-usage/types.ts | 26 + .../src/provider-usage/use-provider-usage.ts | 105 +++ .../app/src/provider-usage/window-bar.tsx | 112 +++ packages/app/src/screens/settings-screen.tsx | 5 + .../app/src/screens/settings/host-page.tsx | 20 + packages/app/src/utils/host-routes.test.ts | 1 + packages/app/src/utils/host-routes.ts | 1 + packages/client/src/daemon-client.test.ts | 75 ++ packages/client/src/daemon-client.ts | 12 + packages/desktop/scripts/dev.ps1 | 28 +- packages/protocol/src/messages.test.ts | 87 +++ packages/protocol/src/messages.ts | 73 ++ .../agent/provider-snapshot-manager.test.ts | 11 +- .../server/agent/provider-snapshot-manager.ts | 11 +- .../agent/providers/mock-slow-provider.ts | 2 +- .../src/server/agent/providers/pi/agent.ts | 38 +- .../server/src/server/bootstrap.smoke.test.ts | 13 + packages/server/src/server/bootstrap.ts | 7 +- packages/server/src/server/session.ts | 35 + .../websocket-server.notifications.test.ts | 1 + .../websocket-server.relay-reconnect.test.ts | 53 +- ...cket-server.terminal-notifications.test.ts | 53 +- .../server/src/server/websocket-server.ts | 9 + .../src/services/quota-fetcher/manifest.ts | 57 ++ .../src/services/quota-fetcher/provider.ts | 20 + .../quota-fetcher/providers/claude.ts | 293 ++++++++ .../services/quota-fetcher/providers/codex.ts | 273 +++++++ .../quota-fetcher/providers/copilot.ts | 97 +++ .../quota-fetcher/providers/cursor.ts | 178 +++++ .../services/quota-fetcher/providers/grok.ts | 118 +++ .../services/quota-fetcher/providers/kimi.ts | 100 +++ .../services/quota-fetcher/providers/zai.ts | 79 ++ .../services/quota-fetcher/service.test.ts | 697 ++++++++++++++++++ .../src/services/quota-fetcher/service.ts | 89 +++ .../src/services/quota-fetcher/usage.ts | 81 ++ scripts/dev.ps1 | 7 +- 59 files changed, 3944 insertions(+), 132 deletions(-) create mode 100644 packages/app/e2e/helpers/provider-usage.ts create mode 100644 packages/app/e2e/provider-usage-settings.spec.ts create mode 100644 packages/app/e2e/provider-usage-tooltip.spec.ts create mode 100644 packages/app/src/components/context-window-meter.utils.ts create mode 100644 packages/app/src/provider-usage/balance-bar.tsx create mode 100644 packages/app/src/provider-usage/card.tsx create mode 100644 packages/app/src/provider-usage/copy.ts create mode 100644 packages/app/src/provider-usage/format.ts create mode 100644 packages/app/src/provider-usage/list.tsx create mode 100644 packages/app/src/provider-usage/settings-section.tsx create mode 100644 packages/app/src/provider-usage/tone.ts create mode 100644 packages/app/src/provider-usage/tooltip-section.tsx create mode 100644 packages/app/src/provider-usage/types.ts create mode 100644 packages/app/src/provider-usage/use-provider-usage.ts create mode 100644 packages/app/src/provider-usage/window-bar.tsx create mode 100644 packages/server/src/services/quota-fetcher/manifest.ts create mode 100644 packages/server/src/services/quota-fetcher/provider.ts create mode 100644 packages/server/src/services/quota-fetcher/providers/claude.ts create mode 100644 packages/server/src/services/quota-fetcher/providers/codex.ts create mode 100644 packages/server/src/services/quota-fetcher/providers/copilot.ts create mode 100644 packages/server/src/services/quota-fetcher/providers/cursor.ts create mode 100644 packages/server/src/services/quota-fetcher/providers/grok.ts create mode 100644 packages/server/src/services/quota-fetcher/providers/kimi.ts create mode 100644 packages/server/src/services/quota-fetcher/providers/zai.ts create mode 100644 packages/server/src/services/quota-fetcher/service.test.ts create mode 100644 packages/server/src/services/quota-fetcher/service.ts create mode 100644 packages/server/src/services/quota-fetcher/usage.ts diff --git a/docs/providers.md b/docs/providers.md index a517314be..0054d1218 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -54,6 +54,21 @@ Boundary tests should assert observable behavior: cold reads may call provider a --- +## Provider Usage Fetchers + +Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly. + +To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape: + +- `providerId`, `displayName`, `status`, and optional `planLabel` +- any number of `windows` such as Session, Weekly, or Biweekly +- optional `balances` for credits, USD, requests, or tokens +- optional `details` for provider-specific rows + +Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit. + +--- + ## ACP Provider Checklist ### 1. Create the provider class diff --git a/package-lock.json b/package-lock.json index e28e0925c..84056bf11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11207,8 +11207,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.59.0", @@ -11221,8 +11220,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", @@ -11235,8 +11233,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.59.0", @@ -11249,8 +11246,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.59.0", @@ -11263,8 +11259,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.59.0", @@ -11277,8 +11272,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", @@ -11291,8 +11285,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", @@ -11305,8 +11298,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", @@ -11319,8 +11311,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", @@ -11333,8 +11324,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", @@ -11347,8 +11337,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", @@ -11361,8 +11350,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", @@ -11375,8 +11363,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", @@ -11389,8 +11376,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", @@ -11403,8 +11389,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", @@ -11417,8 +11402,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", @@ -11431,8 +11415,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", @@ -11445,8 +11428,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.59.0", @@ -11459,8 +11441,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.59.0", @@ -11473,8 +11454,7 @@ "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", @@ -11487,8 +11467,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", @@ -11501,8 +11480,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", @@ -11515,8 +11493,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", @@ -11529,8 +11506,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", @@ -11543,8 +11519,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -18272,7 +18247,6 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } diff --git a/packages/app/e2e/helpers/provider-usage.ts b/packages/app/e2e/helpers/provider-usage.ts new file mode 100644 index 000000000..9ce3bc884 --- /dev/null +++ b/packages/app/e2e/helpers/provider-usage.ts @@ -0,0 +1,154 @@ +import type { Page } from "@playwright/test"; +import type { ProviderUsage } from "@getpaseo/protocol/messages"; +import { daemonWsRoutePattern } from "./daemon-port"; + +interface ProviderUsageFixturePayload { + fetchedAt: string; + providers: ProviderUsage[]; +} + +export interface ProviderUsageFixture { + requestCount(): number; + waitForRequestCount(count: number): Promise; +} + +type WebSocketMessage = string | Buffer; + +function parseJson(message: WebSocketMessage): unknown { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function getSessionMessage(message: WebSocketMessage): Record | null { + const envelope = parseJson(message); + if (!envelope || typeof envelope !== "object") { + return null; + } + const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; + if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { + return null; + } + if (typeof maybeEnvelope.message !== "object") { + return null; + } + return maybeEnvelope.message as Record; +} + +function withProviderUsageFeature(message: WebSocketMessage): string | null { + const envelope = parseJson(message); + if (!envelope || typeof envelope !== "object") { + return null; + } + const maybeEnvelope = envelope as { + type?: unknown; + message?: { + type?: unknown; + payload?: Record; + }; + }; + const payload = maybeEnvelope.message?.payload; + if ( + maybeEnvelope.type !== "session" || + maybeEnvelope.message?.type !== "status" || + payload?.status !== "server_info" + ) { + return null; + } + return JSON.stringify({ + ...maybeEnvelope, + message: { + ...maybeEnvelope.message, + payload: { + ...payload, + features: { + ...(typeof payload.features === "object" && payload.features !== null + ? payload.features + : {}), + providerUsageList: true, + }, + }, + }, + }); +} + +export async function installProviderUsageFixture( + page: Page, + payloads: ProviderUsageFixturePayload[], +): Promise { + let requests = 0; + const waiters: Array<{ count: number; resolve: () => void }> = []; + + function notifyWaiters() { + for (const waiter of waiters.splice(0)) { + if (requests >= waiter.count) { + waiter.resolve(); + } else { + waiters.push(waiter); + } + } + } + + function payloadForRequest(): ProviderUsageFixturePayload { + const index = Math.min(requests - 1, payloads.length - 1); + const payload = payloads[index]; + if (!payload) { + throw new Error("Provider usage fixture requires at least one payload."); + } + return payload; + } + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if (sessionMessage?.type === "provider.usage.list.request") { + requests += 1; + const requestId = sessionMessage.requestId; + if (typeof requestId !== "string") { + throw new Error("provider.usage.list.request missing requestId"); + } + const payload = payloadForRequest(); + notifyWaiters(); + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "provider.usage.list.response", + payload: { + requestId, + fetchedAt: payload.fetchedAt, + providers: payload.providers, + }, + }, + }), + ); + return; + } + server.send(message); + }); + + server.onMessage((message) => { + const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null; + ws.send(serverInfo ?? message); + }); + }); + + return { + requestCount() { + return requests; + }, + waitForRequestCount(count: number) { + if (requests >= count) { + return Promise.resolve(); + } + return new Promise((resolve) => { + waiters.push({ count, resolve }); + }); + }, + }; +} diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index e14fa7229..982731e54 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -25,7 +25,7 @@ const SECTION_LABELS = { export type SettingsSection = keyof typeof SECTION_LABELS | "projects"; -type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host"; +type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host"; export async function openSettingsSection(page: Page, section: SettingsSection): Promise { const sidebar = page.getByTestId("settings-sidebar"); @@ -374,6 +374,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise { + test("renders every provider returned by the daemon usage RPC", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usageFixture = await installProviderUsageFixture(page, [ + { + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "claude", + displayName: "Claude", + status: "available", + planLabel: "Max 20x", + windows: [{ id: "session", label: "Session", usedPct: 7 }], + }, + { + providerId: "codex", + displayName: "Codex", + status: "available", + planLabel: "Pro 20x", + windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }], + }, + { + providerId: "glm", + displayName: "GLM coding plan", + status: "available", + planLabel: "GLM coding plan", + sourceLabel: "OpenUsage 0.6.27", + windows: [ + { id: "biweekly", label: "Biweekly", usedPct: 23 }, + { id: "daily", label: "Daily", remainingPct: 30 }, + ], + balances: [ + { id: "credits", label: "Credits", remaining: 1234, unit: "credits" }, + { id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" }, + ], + details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }], + }, + ], + }, + ]); + + await gotoAppShell(page); + await openSettings(page); + expect(usageFixture.requestCount()).toBe(0); + await openSettingsHostSection(page, serverId, "usage"); + await usageFixture.waitForRequestCount(1); + + const card = page.getByTestId("provider-usage-card"); + await expect(card).toBeVisible({ timeout: 10_000 }); + await expect(card.getByText("Claude", { exact: true })).toBeVisible(); + await expect(card.getByText("Codex", { exact: true })).toBeVisible(); + await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible(); + await expect(card.getByText("Biweekly", { exact: true })).toBeVisible(); + await expect(card.getByText("Daily", { exact: true })).toBeVisible(); + await expect(card.getByText("70%")).toBeVisible(); + await expect(card.getByText("Credits", { exact: true })).toBeVisible(); + await expect(card.getByText("1,234 left", { exact: true })).toBeVisible(); + await expect(card.getByText("Extra usage", { exact: true })).toBeVisible(); + await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible(); + await expect(card.getByText("Valid until", { exact: true })).toBeVisible(); + await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); + await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible(); + }); + + test("refresh invalidates and refetches usage", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usageFixture = await installProviderUsageFixture(page, [ + { + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "glm", + displayName: "GLM coding plan", + status: "available", + planLabel: "GLM coding plan", + windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }], + }, + ], + }, + { + fetchedAt: "2026-06-19T00:01:00.000Z", + providers: [ + { + providerId: "glm", + displayName: "GLM coding plan", + status: "available", + planLabel: "GLM coding plan", + windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }], + }, + ], + }, + ]); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHostSection(page, serverId, "usage"); + await usageFixture.waitForRequestCount(1); + await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 }); + + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await usageFixture.waitForRequestCount(2); + + expect(usageFixture.requestCount()).toBe(2); + await expect(page.getByText("64%")).toBeVisible(); + }); + + test("one provider error does not collapse the usage list", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + await installProviderUsageFixture(page, [ + { + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "claude", + displayName: "Claude", + status: "error", + planLabel: null, + windows: [], + error: "Claude auth expired", + }, + { + providerId: "codex", + displayName: "Codex", + status: "available", + planLabel: "Pro 20x", + windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }], + }, + ], + }, + ]); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHostSection(page, serverId, "usage"); + + const card = page.getByTestId("provider-usage-card"); + await expect(card).toBeVisible({ timeout: 10_000 }); + await expect(card.getByText("Error", { exact: true })).toBeVisible(); + await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible(); + await expect(card.getByText("Codex", { exact: true })).toBeVisible(); + await expect(card.getByText("71%")).toBeVisible(); + }); +}); diff --git a/packages/app/e2e/provider-usage-tooltip.spec.ts b/packages/app/e2e/provider-usage-tooltip.spec.ts new file mode 100644 index 000000000..55666a7a1 --- /dev/null +++ b/packages/app/e2e/provider-usage-tooltip.spec.ts @@ -0,0 +1,113 @@ +import { expect, test, type Page } from "./fixtures"; +import { expectComposerVisible } from "./helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { installProviderUsageFixture } from "./helpers/provider-usage"; + +const MOBILE_VIEWPORT = { width: 390, height: 844 }; + +async function openMockAgent(page: Page) { + await page.setViewportSize(MOBILE_VIEWPORT); + const session = await seedMockAgentWorkspace({ + repoPrefix: "provider-usage-tooltip-", + title: "Provider usage tooltip e2e", + initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.", + }); + await openAgentRoute(page, session); + await expectComposerVisible(page); + await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 }); + return session; +} + +test.describe("provider usage tooltip", () => { + test("fetches usage when the context tooltip opens and renders the active provider", async ({ + page, + }) => { + test.setTimeout(180_000); + const usageFixture = await installProviderUsageFixture(page, [ + { + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "mock", + displayName: "Mock provider", + status: "available", + planLabel: "Test plan", + windows: [ + { + id: "session", + label: "Session", + usedPct: 42, + remainingPct: 58, + resetsAt: "2026-06-19T05:00:00.000Z", + }, + ], + }, + ], + }, + ]); + const session = await openMockAgent(page); + try { + expect(usageFixture.requestCount()).toBe(0); + + await page.getByTestId("context-window-meter").hover(); + await usageFixture.waitForRequestCount(1); + + await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByText("Test plan")).toBeVisible(); + await expect(page.getByText("Session", { exact: true })).toBeVisible(); + await expect(page.getByText("42%")).toBeVisible(); + } finally { + await session.cleanup(); + } + }); + + test("refreshes usage again each time the tooltip is shown", async ({ page }) => { + test.setTimeout(180_000); + const usageFixture = await installProviderUsageFixture(page, [ + { + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "mock", + displayName: "Mock provider", + status: "available", + planLabel: "Test plan", + windows: [{ id: "session", label: "Session", usedPct: 41 }], + }, + ], + }, + { + fetchedAt: "2026-06-19T00:01:00.000Z", + providers: [ + { + providerId: "mock", + displayName: "Mock provider", + status: "available", + planLabel: "Test plan", + windows: [{ id: "session", label: "Session", usedPct: 64 }], + }, + ], + }, + ]); + const session = await openMockAgent(page); + try { + const meter = page.getByTestId("context-window-meter"); + + await meter.hover(); + await usageFixture.waitForRequestCount(1); + await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 }); + + await page.mouse.move(0, 0); + await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0); + + await meter.hover(); + await usageFixture.waitForRequestCount(2); + expect(usageFixture.requestCount()).toBe(2); + await expect(page.getByText("64%")).toBeVisible(); + } finally { + await session.cleanup(); + } + }); +}); diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 6d9fe1755..85bdd6585 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,14 +1,21 @@ +import { useCallback, useState } from "react"; import { Pressable, Text, View } from "react-native"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section"; +import { useProviderUsage } from "@/provider-usage/use-provider-usage"; +import { formatTokenCount } from "./context-window-meter.utils"; interface ContextWindowMeterProps { maxTokens: number; usedTokens: number; totalCostUsd?: number | null; showPercentage?: boolean; + serverId?: string; + /** The Paseo provider key, e.g. "claude", "gemini", "codex" */ + provider?: string | null; } const SVG_SIZE = 16; @@ -41,16 +48,6 @@ function clampPercentage(value: number): number { return Math.max(0, Math.min(100, value)); } -function formatTokenCount(value: number): string { - if (value >= 1_000_000) { - return `${Math.round(value / 1_000_000)}m`; - } - if (value >= 1_000) { - return `${Math.round(value / 1_000)}k`; - } - return Math.round(value).toString(); -} - function formatSessionCost(value: number): string | null { if (!Number.isFinite(value) || value <= 0) { return null; @@ -75,15 +72,52 @@ function getMeterColors( return { progress: theme.colors.foregroundMuted, track }; } +function getMeterGeometry(showPercentage: boolean) { + if (showPercentage) { + return { + svgSize: COMPACT_SVG_SIZE, + center: COMPACT_CENTER, + radius: COMPACT_RADIUS, + strokeWidth: COMPACT_STROKE_WIDTH, + circumference: COMPACT_CIRCUMFERENCE, + containerStyle: styles.containerWithLabel, + }; + } + return { + svgSize: SVG_SIZE, + center: CENTER, + radius: RADIUS, + strokeWidth: STROKE_WIDTH, + circumference: CIRCUMFERENCE, + containerStyle: styles.container, + }; +} + export function ContextWindowMeter({ maxTokens, usedTokens, totalCostUsd, showPercentage = false, + serverId, + provider, }: ContextWindowMeterProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); + const [isTooltipOpen, setIsTooltipOpen] = useState(false); + const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage( + serverId ?? null, + { enabled: isTooltipOpen }, + ); const percentage = getUsagePercentage(maxTokens, usedTokens); + const handleTooltipOpenChange = useCallback( + (nextOpen: boolean) => { + setIsTooltipOpen(nextOpen); + if (nextOpen) { + void refreshProviderUsage(); + } + }, + [refreshProviderUsage], + ); if (percentage === null) { return null; @@ -91,22 +125,25 @@ export function ContextWindowMeter({ const clampedPercentage = clampPercentage(percentage); const roundedPercentage = Math.round(percentage); - const svgSize = showPercentage ? COMPACT_SVG_SIZE : SVG_SIZE; - const center = showPercentage ? COMPACT_CENTER : CENTER; - const radius = showPercentage ? COMPACT_RADIUS : RADIUS; - const strokeWidth = showPercentage ? COMPACT_STROKE_WIDTH : STROKE_WIDTH; - const circumference = showPercentage ? COMPACT_CIRCUMFERENCE : CIRCUMFERENCE; + const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = + getMeterGeometry(showPercentage); const dashOffset = circumference - (clampedPercentage / 100) * circumference; const colors = getMeterColors(clampedPercentage, theme); const formattedSessionCost = typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; - const containerStyle = showPercentage ? styles.containerWithLabel : styles.container; return ( - + ) : null} + @@ -194,6 +232,7 @@ const styles = StyleSheet.create((theme) => ({ }, tooltipContent: { gap: theme.spacing[1], + minWidth: 200, }, tooltipTitle: { color: theme.colors.foreground, diff --git a/packages/app/src/components/context-window-meter.utils.ts b/packages/app/src/components/context-window-meter.utils.ts new file mode 100644 index 000000000..a0fa45ccb --- /dev/null +++ b/packages/app/src/components/context-window-meter.utils.ts @@ -0,0 +1,9 @@ +export function formatTokenCount(value: number): string { + if (value >= 1_000_000) { + return `${Math.round(value / 1_000_000)}m`; + } + if (value >= 1_000) { + return `${Math.round(value / 1_000)}k`; + } + return Math.round(value).toString(); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 05f244705..fc04ae8af 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -196,6 +196,8 @@ function buildAgentStateSelector(serverId: string, agentId: string) { contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null, contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null, totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null, + model: agent?.model ?? null, + provider: agent?.provider ?? null, }; }; } @@ -205,6 +207,8 @@ function renderContextWindowMeter( contextWindowUsedTokens: number | null, totalCostUsd: number | null, showPercentage: boolean, + serverId: string, + provider: string | null, ): ReactElement | null { if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) { return null; @@ -215,6 +219,8 @@ function renderContextWindowMeter( usedTokens={contextWindowUsedTokens} totalCostUsd={totalCostUsd} showPercentage={showPercentage} + serverId={serverId} + provider={provider} /> ); } @@ -1643,8 +1649,17 @@ export function Composer({ contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout, + serverId, + agentState.provider, ), - [contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout], + [ + contextWindowMaxTokens, + contextWindowUsedTokens, + agentState.totalCostUsd, + isCompactLayout, + serverId, + agentState.provider, + ], ); const { beforeVoiceContent, footerInlineContent } = useMemo( () => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout), diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 8b524d770..db1638913 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1394,6 +1394,7 @@ export const ar: TranslationResources = { agents: "Agents", workspaces: "Workspaces", providers: "مقدمي الخدمات", + usage: "الاستخدام", terminals: "Terminals", host: "Host", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c24647418..9a56ab44b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1401,6 +1401,7 @@ export const en = { agents: "Agents", workspaces: "Workspaces", providers: "Providers", + usage: "Usage", terminals: "Terminals", host: "Host", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1536bb502..13cd887f1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1430,6 +1430,7 @@ export const es: TranslationResources = { agents: "Agents", workspaces: "Workspaces", providers: "Proveedores", + usage: "Uso", terminals: "Terminals", host: "Host", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 398cbefb2..7cea39611 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1433,6 +1433,7 @@ export const fr: TranslationResources = { agents: "Agents", workspaces: "Workspaces", providers: "Fournisseurs", + usage: "Utilisation", terminals: "Terminals", host: "Host", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 45690ef1d..c07851afb 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1422,6 +1422,7 @@ export const ru: TranslationResources = { agents: "Agents", workspaces: "Workspaces", providers: "Провайдеры", + usage: "Использование", terminals: "Terminals", host: "Host", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index ca0a98c3c..4c9b9ecf3 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1377,6 +1377,7 @@ export const zhCN: TranslationResources = { agents: "Agents", workspaces: "Workspaces", providers: "Providers", + usage: "使用情况", terminals: "Terminals", host: "Host", }, diff --git a/packages/app/src/provider-usage/balance-bar.tsx b/packages/app/src/provider-usage/balance-bar.tsx new file mode 100644 index 000000000..bd430b7a3 --- /dev/null +++ b/packages/app/src/provider-usage/balance-bar.tsx @@ -0,0 +1,118 @@ +import { useMemo } from "react"; +import { Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { clampPct, formatAmount, formatResetLabel } from "./format"; +import type { ProviderUsageBalance, ProviderUsageTone } from "./types"; + +interface ResolvedBalance { + amountText: string; + usedPct: number | null; +} + +function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance { + const { used, remaining, limit, unit } = balance; + if (limit != null && limit > 0) { + const usedAmount = used ?? (remaining != null ? limit - remaining : null); + const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null; + const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—"; + return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct }; + } + if (remaining != null) { + return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null }; + } + if (used != null) { + return { amountText: formatAmount(used, unit), usedPct: null }; + } + return { amountText: "—", usedPct: null }; +} + +function fillToneStyle(tone: ProviderUsageTone) { + switch (tone) { + case "ok": + return styles.fillOk; + case "warning": + return styles.fillWarning; + case "danger": + return styles.fillDanger; + default: + return styles.fillDefault; + } +} + +export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) { + const { amountText, usedPct } = resolveBalance(balance); + const tone = balance.tone ?? "default"; + const resetLabel = formatResetLabel(balance.resetsAt); + + const fillStyle = useMemo>( + () => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }], + [usedPct, tone], + ); + + return ( + + + + {balance.label} + + + {amountText} + {resetLabel ? {` · ${resetLabel}`} : null} + + + {usedPct != null ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: 3, + }, + labelRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: theme.spacing[2], + }, + label: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + value: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + }, + reset: { + color: theme.colors.foregroundMuted, + fontWeight: theme.fontWeight.normal, + }, + track: { + height: 4, + borderRadius: 2, + backgroundColor: theme.colors.surface3, + overflow: "hidden", + }, + fill: { + height: 4, + borderRadius: 2, + }, + fillDefault: { + backgroundColor: theme.colors.foregroundMuted, + }, + fillOk: { + backgroundColor: theme.colors.statusSuccess, + }, + fillWarning: { + backgroundColor: theme.colors.statusWarning, + }, + fillDanger: { + backgroundColor: theme.colors.statusDanger, + }, +})); diff --git a/packages/app/src/provider-usage/card.tsx b/packages/app/src/provider-usage/card.tsx new file mode 100644 index 000000000..0260425fd --- /dev/null +++ b/packages/app/src/provider-usage/card.tsx @@ -0,0 +1,201 @@ +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { getProviderIcon } from "@/components/provider-icons"; +import { StatusBadge } from "@/components/ui/status-badge"; +import type { Theme } from "@/styles/theme"; +import { ProviderUsageBalanceBar } from "./balance-bar"; +import { formatAgo } from "./format"; +import type { ProviderUsage } from "./types"; +import { ProviderUsageWindowBar } from "./window-bar"; + +interface ProviderUsageIconProps { + iconKey: string; + size: number; + color?: string; +} + +function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) { + const Icon = getProviderIcon(iconKey); + return ; +} + +const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon); + +const foregroundIconColor = (theme: Theme) => ({ color: theme.colors.foreground }); + +function statusText(usage: ProviderUsage): string | null { + if (usage.status === "available") return null; + return usage.status === "error" ? "Error" : "Unavailable"; +} + +function footerText(usage: ProviderUsage): string | null { + const updated = formatAgo(usage.fetchedAt); + const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter( + (part): part is string => typeof part === "string" && part.length > 0, + ); + return parts.length > 0 ? parts.join(" · ") : null; +} + +export function ProviderUsageCard({ + usage, + compact = false, +}: { + usage: ProviderUsage; + compact?: boolean; +}) { + const status = statusText(usage); + const footer = footerText(usage); + const balances = usage.balances ?? []; + const details = usage.details ?? []; + + const containerStyle = useMemo( + () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], + [compact], + ); + const dotStyle = useMemo( + () => [ + styles.statusDot, + usage.status === "available" && styles.statusDotAvailable, + usage.status === "error" && styles.statusDotError, + ], + [usage.status], + ); + + return ( + + + + + {usage.displayName} + + {usage.planLabel ? : null} + + {status ? ( + + + {status} + + ) : null} + + + {usage.error ? ( + + {usage.error} + + ) : null} + + {usage.windows.length > 0 || balances.length > 0 ? ( + + {usage.windows.map((window) => ( + + ))} + {balances.map((balance) => ( + + ))} + + ) : null} + + {details.length > 0 ? ( + + {details.map((detail) => ( + + + {detail.label} + + + {detail.value} + + + ))} + + ) : null} + + {footer ? ( + + {footer} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[3], + }, + containerPadded: { + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + }, + containerCompact: { + gap: theme.spacing[2], + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + name: { + flexShrink: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + headerSpacer: { + flex: 1, + }, + statusRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: theme.colors.foregroundMuted, + }, + statusDotAvailable: { + backgroundColor: theme.colors.statusSuccess, + }, + statusDotError: { + backgroundColor: theme.colors.statusDanger, + }, + statusLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + bars: { + gap: theme.spacing[2], + }, + details: { + gap: theme.spacing[1], + }, + detailRow: { + flexDirection: "row", + justifyContent: "space-between", + gap: theme.spacing[2], + }, + detailLabel: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + detailValue: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + }, + error: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + lineHeight: theme.fontSize.xs * 1.4, + }, + footer: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, +})); diff --git a/packages/app/src/provider-usage/copy.ts b/packages/app/src/provider-usage/copy.ts new file mode 100644 index 000000000..1dcc817ef --- /dev/null +++ b/packages/app/src/provider-usage/copy.ts @@ -0,0 +1,18 @@ +// User-facing copy for the provider-usage surfaces, centralized so localization +// is a single-file change. INTEGRATION: move these into the i18n resources +// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the +// call sites once the feature is wired to data. Kept inline for now because the +// surfaces are not yet mounted and the locale files are being edited elsewhere. +export const providerUsageCopy = { + title: "Plan usage", + refresh: "Refresh", + refreshing: "Refreshing...", + loading: "Loading usage...", + empty: "No usage data", + errorTitle: "Unable to load usage", + hostUnavailable: "Connect to this host to see provider usage", + hostUpgradeRequired: "Update the host to see provider usage", + clientUnavailable: "Host connection is not ready", + retry: "Try again", + tooltipLoading: "Loading plan usage…", +} as const; diff --git a/packages/app/src/provider-usage/format.ts b/packages/app/src/provider-usage/format.ts new file mode 100644 index 000000000..d903818f4 --- /dev/null +++ b/packages/app/src/provider-usage/format.ts @@ -0,0 +1,53 @@ +import { formatTokenCount } from "@/components/context-window-meter.utils"; +import type { ProviderUsageBalanceUnit } from "./types"; + +export function clampPct(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +export function formatPct(value: number): string { + return `${Math.round(clampPct(value))}%`; +} + +function relativeDuration(iso: string): string | null { + const diffMs = new Date(iso).getTime() - Date.now(); + if (!Number.isFinite(diffMs)) return null; + if (diffMs <= 0) return "now"; + const diffMinutes = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + if (diffDays > 0) return `${diffDays}d`; + if (diffHours > 0) return `${diffHours}h`; + return `${diffMinutes}m`; +} + +export function formatResetLabel(iso: string | null | undefined): string | null { + if (!iso) return null; + const rel = relativeDuration(iso); + if (!rel) return null; + return rel === "now" ? "resetting now" : `resets ${rel}`; +} + +export function formatAgo(iso: string | null | undefined): string | null { + if (!iso) return null; + const diffMs = Date.now() - new Date(iso).getTime(); + if (!Number.isFinite(diffMs)) return null; + if (diffMs < 60_000) return "just now"; + const diffMinutes = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + if (diffDays > 0) return `${diffDays}d ago`; + if (diffHours > 0) return `${diffHours}h ago`; + return `${diffMinutes}m ago`; +} + +export function formatAmount(value: number, unit: ProviderUsageBalanceUnit): string { + switch (unit) { + case "usd": + return `$${value.toFixed(2)}`; + case "tokens": + return formatTokenCount(value); + default: + return value.toLocaleString(); + } +} diff --git a/packages/app/src/provider-usage/list.tsx b/packages/app/src/provider-usage/list.tsx new file mode 100644 index 000000000..c9ff6742b --- /dev/null +++ b/packages/app/src/provider-usage/list.tsx @@ -0,0 +1,26 @@ +import { Fragment } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { settingsStyles } from "@/styles/settings"; +import { ProviderUsageCard } from "./card"; +import type { ProviderUsage } from "./types"; + +export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) { + return ( + + {providers.map((usage, index) => ( + + {index > 0 ? : null} + + + ))} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + divider: { + height: 1, + backgroundColor: theme.colors.border, + }, +})); diff --git a/packages/app/src/provider-usage/settings-section.tsx b/packages/app/src/provider-usage/settings-section.tsx new file mode 100644 index 000000000..58da33fff --- /dev/null +++ b/packages/app/src/provider-usage/settings-section.tsx @@ -0,0 +1,96 @@ +import { RefreshCw } from "lucide-react-native"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { settingsStyles } from "@/styles/settings"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { providerUsageCopy } from "./copy"; +import { ProviderUsageList } from "./list"; +import type { ProviderUsageView } from "./types"; + +export function ProviderUsageSettingsSection({ + view, + onRefresh, +}: { + view: ProviderUsageView; + onRefresh: () => void; +}) { + const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); + + const refreshButton = useMemo( + () => ( + + ), + [busy, onRefresh], + ); + + return ( + + + + ); +} + +function ProviderUsageBody({ + view, + onRefresh, +}: { + view: ProviderUsageView; + onRefresh: () => void; +}) { + if (view.kind === "loading") { + return ( + + {providerUsageCopy.loading} + + ); + } + + if (view.kind === "error") { + return ( + + + + ); + } + + if (view.payload.providers.length === 0) { + return ( + + {providerUsageCopy.empty} + + ); + } + + return ; +} + +const styles = StyleSheet.create((theme) => ({ + emptyCard: { + padding: theme.spacing[4], + alignItems: "center", + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); + +const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard]; diff --git a/packages/app/src/provider-usage/tone.ts b/packages/app/src/provider-usage/tone.ts new file mode 100644 index 000000000..5cf6f68ba --- /dev/null +++ b/packages/app/src/provider-usage/tone.ts @@ -0,0 +1,8 @@ +import type { ProviderUsageTone } from "./types"; + +export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone { + if (usedPct == null) return "default"; + if (usedPct > 90) return "danger"; + if (usedPct >= 70) return "warning"; + return "default"; +} diff --git a/packages/app/src/provider-usage/tooltip-section.tsx b/packages/app/src/provider-usage/tooltip-section.tsx new file mode 100644 index 000000000..a9861b746 --- /dev/null +++ b/packages/app/src/provider-usage/tooltip-section.tsx @@ -0,0 +1,71 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ProviderUsageCard } from "./card"; +import { providerUsageCopy } from "./copy"; +import type { ProviderUsage, ProviderUsageView } from "./types"; + +function matchProvider( + providers: ProviderUsage[], + activeProviderId: string | null | undefined, +): ProviderUsage | null { + if (!activeProviderId) return null; + const target = activeProviderId.toLowerCase(); + return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null; +} + +// Renders the active agent's provider usage inside the context-meter tooltip. +// Returns nothing when the active provider has no usage entry, so the meter's +// own context section stays the whole tooltip. +export function ProviderUsageTooltipSection({ + view, + activeProviderId, +}: { + view: ProviderUsageView; + activeProviderId: string | null | undefined; +}) { + if (view.kind === "loading") { + return ( + <> + + {providerUsageCopy.tooltipLoading} + + ); + } + + if (view.kind === "error") { + return ( + <> + + {view.message} + + ); + } + + const usage = matchProvider(view.payload.providers, activeProviderId); + if (!usage) return null; + + return ( + <> + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + divider: { + height: StyleSheet.hairlineWidth, + backgroundColor: theme.colors.border, + marginVertical: theme.spacing[1] + 1, + }, + detail: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + lineHeight: theme.fontSize.xs * 1.4, + }, + error: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + lineHeight: theme.fontSize.xs * 1.4, + }, +})); diff --git a/packages/app/src/provider-usage/types.ts b/packages/app/src/provider-usage/types.ts new file mode 100644 index 000000000..52ff7c31f --- /dev/null +++ b/packages/app/src/provider-usage/types.ts @@ -0,0 +1,26 @@ +import type { + ProviderUsage, + ProviderUsageBalance, + ProviderUsageDetail, + ProviderUsageListResponseMessage, + ProviderUsageStatus, + ProviderUsageTone, + ProviderUsageWindow, +} from "@getpaseo/protocol/messages"; + +export type { + ProviderUsage, + ProviderUsageBalance, + ProviderUsageDetail, + ProviderUsageStatus, + ProviderUsageTone, + ProviderUsageWindow, +}; + +export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"]; +export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; + +export type ProviderUsageView = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean }; diff --git a/packages/app/src/provider-usage/use-provider-usage.ts b/packages/app/src/provider-usage/use-provider-usage.ts new file mode 100644 index 000000000..da40795e7 --- /dev/null +++ b/packages/app/src/provider-usage/use-provider-usage.ts @@ -0,0 +1,105 @@ +import { useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import { providerUsageCopy } from "./copy"; +import type { ProviderUsageListPayload, ProviderUsageView } from "./types"; + +export const PROVIDER_USAGE_STALE_TIME_MS = 5 * 60 * 1000; + +type ProviderUsageClient = Pick; + +export function providerUsageQueryKey(serverId: string | null | undefined) { + return ["providerUsage", serverId ?? ""] as const; +} + +async function fetchProviderUsage(client: ProviderUsageClient): Promise { + return client.listProviderUsage(); +} + +interface UseProviderUsageOptions { + enabled?: boolean; +} + +export function useProviderUsage( + serverId: string | null | undefined, + options: UseProviderUsageOptions = {}, +): { + view: ProviderUsageView; + refresh: () => Promise; + canFetch: boolean; +} { + const queryClient = useQueryClient(); + const client = useHostRuntimeClient(serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const supportsProviderUsage = useSessionStore( + (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true, + ); + const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]); + const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage); + const enabled = Boolean((options.enabled ?? true) && canFetch); + + const queryFn = useCallback(async () => { + if (!client) { + throw new Error(providerUsageCopy.clientUnavailable); + } + return fetchProviderUsage(client); + }, [client]); + + const query = useQuery({ + queryKey, + queryFn, + enabled, + staleTime: PROVIDER_USAGE_STALE_TIME_MS, + refetchOnMount: true, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); + + const refresh = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey }); + if (!canFetch) { + return; + } + await queryClient.fetchQuery({ + queryKey, + queryFn, + staleTime: PROVIDER_USAGE_STALE_TIME_MS, + }); + }, [canFetch, queryClient, queryFn, queryKey]); + + const view = useMemo(() => { + if (!serverId || !client || !isConnected) { + return { kind: "error", message: providerUsageCopy.hostUnavailable }; + } + if (!supportsProviderUsage) { + return { kind: "error", message: providerUsageCopy.hostUpgradeRequired }; + } + if (query.data) { + return { + kind: "ready", + payload: query.data, + isRefreshing: query.isFetching, + }; + } + if (query.isError) { + return { + kind: "error", + message: query.error instanceof Error ? query.error.message : String(query.error), + }; + } + return { kind: "loading" }; + }, [ + client, + isConnected, + query.data, + query.error, + query.isError, + query.isFetching, + serverId, + supportsProviderUsage, + ]); + + return { view, refresh, canFetch }; +} diff --git a/packages/app/src/provider-usage/window-bar.tsx b/packages/app/src/provider-usage/window-bar.tsx new file mode 100644 index 000000000..c3e379391 --- /dev/null +++ b/packages/app/src/provider-usage/window-bar.tsx @@ -0,0 +1,112 @@ +import { useMemo } from "react"; +import { Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { clampPct, formatPct, formatResetLabel } from "./format"; +import { deriveTone } from "./tone"; +import type { ProviderUsageTone, ProviderUsageWindow } from "./types"; + +function resolveUsedPct(window: ProviderUsageWindow): number | null { + if (window.usedPct != null) return window.usedPct; + if (window.remainingPct != null) return 100 - window.remainingPct; + return null; +} + +function fillToneStyle(tone: ProviderUsageTone) { + switch (tone) { + case "ok": + return styles.fillOk; + case "warning": + return styles.fillWarning; + case "danger": + return styles.fillDanger; + default: + return styles.fillDefault; + } +} + +export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) { + const usedPct = resolveUsedPct(window); + const tone = window.tone ?? deriveTone(usedPct); + + const fillWidth = clampPct(usedPct ?? 0); + const fillStyle = useMemo>( + () => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }], + [fillWidth, tone], + ); + + const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; + const trailing = isAtRisk + ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() + : formatResetLabel(window.resetsAt); + + return ( + + + + {window.label} + + + {usedPct != null ? formatPct(usedPct) : "—"} + {trailing ? ( + {` · ${trailing}`} + ) : null} + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: 3, + }, + labelRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: theme.spacing[2], + }, + label: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + value: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + }, + reset: { + color: theme.colors.foregroundMuted, + fontWeight: theme.fontWeight.normal, + }, + atRisk: { + color: theme.colors.statusDanger, + fontWeight: theme.fontWeight.normal, + }, + track: { + height: 4, + borderRadius: 2, + backgroundColor: theme.colors.surface3, + overflow: "hidden", + }, + fill: { + height: 4, + borderRadius: 2, + }, + fillDefault: { + backgroundColor: theme.colors.foregroundMuted, + }, + fillOk: { + backgroundColor: theme.colors.statusSuccess, + }, + fillWarning: { + backgroundColor: theme.colors.statusWarning, + }, + fillDanger: { + backgroundColor: theme.colors.statusDanger, + }, +})); diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index c50bae368..ff798940e 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -33,6 +33,7 @@ import { Network, Bot, Boxes, + Gauge, Keyboard, Stethoscope, Info, @@ -103,6 +104,7 @@ import { HostAgentsPage, HostSettingsPage, HostProvidersPage, + HostUsagePage, HostWorkspacesPage, HostTerminalsPage, } from "@/screens/settings/host-page"; @@ -171,6 +173,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, + { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, { id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal }, { id: "host", labelKey: "settings.hostSections.host", icon: Server }, ]; @@ -188,6 +191,8 @@ function renderHostSettingsContent( return ; case "providers": return ; + case "usage": + return ; case "terminals": return ; case "host": diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 68e2748a9..7a976316b 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -46,6 +46,8 @@ import { useHosts, } from "@/runtime/host-runtime"; import { ProvidersSection } from "@/screens/settings/providers-section"; +import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section"; +import { useProviderUsage } from "@/provider-usage/use-provider-usage"; import { SettingsSection } from "@/screens/settings/settings-section"; import { useSessionStore } from "@/stores/session-store"; import { settingsStyles } from "@/styles/settings"; @@ -306,6 +308,24 @@ export function HostProvidersPage({ serverId }: { serverId: string }) { ); } +export function HostUsagePage({ serverId }: { serverId: string }) { + const host = useHostProfile(serverId); + const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(serverId); + const handleRefresh = useCallback(() => { + void refreshProviderUsage(); + }, [refreshProviderUsage]); + + if (!host) { + return ; + } + + return ( + + + + ); +} + export function HostSettingsPage({ serverId, onHostRemoved, diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 8758da968..c053bae1a 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -183,6 +183,7 @@ describe("host settings section slugs", () => { expect(normalizeHostSectionSlug("agents")).toBe("agents"); expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces"); expect(normalizeHostSectionSlug("providers")).toBe("providers"); + expect(normalizeHostSectionSlug("usage")).toBe("usage"); expect(normalizeHostSectionSlug("host")).toBe("host"); }); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index d277e4b87..ad1a36bba 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -417,6 +417,7 @@ export const HOST_SECTION_SLUGS = [ "agents", "workspaces", "providers", + "usage", "terminals", "host", ] as const; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index f4eed72dd..457072779 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -4070,6 +4070,81 @@ test("dispatches terminals_changed events to typed listeners", async () => { ]); }); +test("sends provider.usage.list.request and resolves provider.usage.list.response", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const usagePromise = client.listProviderUsage({ requestId: "usage-1" }); + + expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({ + type: "session", + message: { + type: "provider.usage.list.request", + requestId: "usage-1", + }, + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "provider.usage.list.response", + payload: { + requestId: "usage-1", + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "glm", + displayName: "GLM coding plan", + status: "available", + planLabel: "GLM coding plan", + windows: [ + { + id: "biweekly", + label: "Biweekly", + usedPct: 23, + remainingPct: 77, + }, + ], + }, + ], + }, + }), + ); + + await expect(usagePromise).resolves.toEqual({ + requestId: "usage-1", + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "glm", + displayName: "GLM coding plan", + status: "available", + planLabel: "GLM coding plan", + windows: [ + { + id: "biweekly", + label: "Biweekly", + usedPct: 23, + remainingPct: 77, + }, + ], + }, + ], + }); +}); + test("sends close_items_request and resolves close_items_response", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 0c9ac89f4..4e959a4e8 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -63,6 +63,7 @@ import type { GetProvidersSnapshotResponseMessage, RefreshProvidersSnapshotResponseMessage, ProviderDiagnosticResponseMessage, + ProviderUsageListResponseMessage, DaemonGetStatusResponse, DaemonGetPairingOfferResponse, AgentRewindResponseMessage, @@ -346,6 +347,7 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"]; type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; +type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; type ReadProjectConfigPayload = Extract< @@ -3812,6 +3814,16 @@ export class DaemonClient { }); } + async listProviderUsage(options?: { requestId?: string }): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "provider.usage.list.request", + }, + timeout: 30000, + }); + } + async listCommands(agentId: string, requestId?: string): Promise; async listCommands(agentId: string, options?: ListCommandsOptions): Promise; async listCommands( diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 index f7b07064d..22818528e 100644 --- a/packages/desktop/scripts/dev.ps1 +++ b/packages/desktop/scripts/dev.ps1 @@ -4,6 +4,7 @@ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $DesktopDir = (Resolve-Path "$ScriptDir\..").Path $AppDir = (Resolve-Path "$DesktopDir\..\app").Path $RootDir = (Resolve-Path "$DesktopDir\..\..").Path +$env:PATH = "$RootDir\node_modules\.bin;$env:PATH" # Build the Electron main process npm run build:main @@ -78,18 +79,27 @@ if (-not $env:PASEO_LISTEN) { $env:PASEO_LISTEN = "127.0.0.1:$DevDaemonPort" } # ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME # (that could clobber a production config.json with the dev port + wildcard CORS). if ($PaseoHomeManaged) { - node -e ' -const fs = require("fs"); -const [path, port] = [process.argv[1], process.argv[2]]; + $env:TMP_CFG_PATH = "$($env:PASEO_HOME)/config.json" + $env:TMP_CFG_PORT = $DevDaemonPort + $TmpScript = [System.IO.Path]::GetTempFileName() + ".js" + $ScriptContent = @" +const fs = require('fs'); +const path = process.env.TMP_CFG_PATH; +const port = process.env.TMP_CFG_PORT; let cfg = {}; -try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {} +try { cfg = JSON.parse(fs.readFileSync(path, 'utf8')); } catch(e) {} cfg.version = cfg.version || 1; cfg.daemon = cfg.daemon || {}; -cfg.daemon.listen = `127.0.0.1:${port}`; +cfg.daemon.listen = '127.0.0.1:' + port; cfg.daemon.cors = cfg.daemon.cors || {}; -cfg.daemon.cors.allowedOrigins = ["*"]; +cfg.daemon.cors.allowedOrigins = ['*']; fs.writeFileSync(path, JSON.stringify(cfg, null, 2)); -' "$($env:PASEO_HOME)/config.json" $DevDaemonPort +"@ + Set-Content -Path $TmpScript -Value $ScriptContent + node $TmpScript + Remove-Item $TmpScript -ErrorAction SilentlyContinue + Remove-Item Env:\TMP_CFG_PATH -ErrorAction SilentlyContinue + Remove-Item Env:\TMP_CFG_PORT -ErrorAction SilentlyContinue } else { Write-Host " (custom PASEO_HOME - leaving its config.json untouched)" } @@ -107,9 +117,9 @@ Write-Host @" "@ # Launch Metro + Electron together, kill both on exit -& "$RootDir\node_modules\.bin\concurrently" ` +concurrently ` --kill-others ` --names "metro,electron" ` --prefix-colors "magenta,cyan" ` - "cd `"$AppDir`" && `$env:PASEO_WEB_PLATFORM = `"electron`"; npx expo start --port $($env:EXPO_PORT)" ` + "cd `"$AppDir`" && cross-env PASEO_WEB_PLATFORM=electron npx expo start --port $($env:EXPO_PORT)" ` "npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`"" diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 828b14d39..ea5771f1a 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -133,6 +133,93 @@ describe("workspace descriptor message compatibility", () => { }); }); +describe("provider usage list message contract", () => { + test("accepts the usage list request as a namespaced correlated RPC", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "provider.usage.list.request", + requestId: "usage-1", + }); + + expect(parsed).toEqual({ + type: "provider.usage.list.request", + requestId: "usage-1", + }); + }); + + test("accepts new providers and new usage windows as normalized data", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "provider.usage.list.response", + payload: { + requestId: "usage-2", + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "glm", + displayName: "GLM coding plan", + status: "available", + planLabel: "GLM coding plan", + fetchedAt: "2026-06-19T00:00:00.000Z", + windows: [ + { + id: "biweekly", + label: "Biweekly", + usedPct: 23, + remainingPct: 77, + resetsAt: "2026-07-03T00:00:00.000Z", + tone: "ok", + }, + ], + balances: [ + { + id: "credits", + label: "Credits", + remaining: 120, + unit: "credits", + }, + ], + details: [{ id: "region", label: "Region", value: "US" }], + error: null, + }, + ], + }, + }); + + expect(parsed.type).toBe("provider.usage.list.response"); + if (parsed.type !== "provider.usage.list.response") { + throw new Error("Expected provider.usage.list.response"); + } + expect(parsed.payload.providers[0]?.providerId).toBe("glm"); + expect(parsed.payload.providers[0]?.windows[0]?.label).toBe("Biweekly"); + }); + + test("keeps protocol numbers strict after API boundary normalization", () => { + const parsed = SessionOutboundMessageSchema.safeParse({ + type: "provider.usage.list.response", + payload: { + requestId: "usage-3", + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "claude", + displayName: "Claude", + status: "available", + planLabel: "Max 20x", + windows: [ + { + id: "session", + label: "Session", + usedPct: "7", + }, + ], + }, + ], + }, + }); + + expect(parsed.success).toBe(false); + }); +}); + describe("agent detach RPC", () => { test("parses the namespaced detach request", () => { const parsed = SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 965241fbc..88ca63fd3 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1201,6 +1201,11 @@ export const ProviderDiagnosticRequestMessageSchema = z.object({ requestId: z.string(), }); +export const ProviderUsageListRequestMessageSchema = z.object({ + type: z.literal("provider.usage.list.request"), + requestId: z.string(), +}); + export const ResumeAgentRequestMessageSchema = z.object({ type: z.literal("resume_agent_request"), handle: AgentPersistenceHandleSchema, @@ -2029,6 +2034,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ GetProvidersSnapshotRequestMessageSchema, RefreshProvidersSnapshotRequestMessageSchema, ProviderDiagnosticRequestMessageSchema, + ProviderUsageListRequestMessageSchema, ResumeAgentRequestMessageSchema, ImportAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, @@ -2300,6 +2306,8 @@ export const ServerInfoStatusPayloadSchema = z projectRemove: z.boolean().optional(), // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 worktreeRestore: z.boolean().optional(), + // COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98. + providerUsageList: z.boolean().optional(), // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. agentDetach: z.boolean().optional(), }) @@ -3830,6 +3838,61 @@ export const ProviderDiagnosticResponseMessageSchema = z.object({ }), }); +export const ProviderUsageToneSchema = z.enum(["default", "ok", "warning", "danger"]); +export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "error"]); + +export const ProviderUsageWindowSchema = z.object({ + id: z.string(), + label: z.string(), + usedPct: z.number().nullable().optional(), + remainingPct: z.number().nullable().optional(), + resetsAt: z.string().nullable().optional(), + runsOutAt: z.string().nullable().optional(), + shortfallPct: z.number().nullable().optional(), + tone: ProviderUsageToneSchema.optional(), +}); + +export const ProviderUsageBalanceSchema = z.object({ + id: z.string(), + label: z.string(), + used: z.number().nullable().optional(), + remaining: z.number().nullable().optional(), + limit: z.number().nullable().optional(), + unit: z.enum(["usd", "credits", "requests", "tokens"]), + resetsAt: z.string().nullable().optional(), + tone: ProviderUsageToneSchema.optional(), +}); + +export const ProviderUsageDetailSchema = z.object({ + id: z.string(), + label: z.string(), + value: z.string(), + tone: ProviderUsageToneSchema.optional(), +}); + +export const ProviderUsageSchema = z.object({ + providerId: z.string(), + displayName: z.string(), + status: ProviderUsageStatusSchema, + planLabel: z.string().nullable(), + sourceLabel: z.string().nullable().optional(), + fetchedAt: z.string().nullable().optional(), + nextRefreshAt: z.string().nullable().optional(), + windows: z.array(ProviderUsageWindowSchema), + balances: z.array(ProviderUsageBalanceSchema).optional(), + details: z.array(ProviderUsageDetailSchema).optional(), + error: z.string().nullable().optional(), +}); + +export const ProviderUsageListResponseMessageSchema = z.object({ + type: z.literal("provider.usage.list.response"), + payload: z.object({ + requestId: z.string(), + fetchedAt: z.string(), + providers: z.array(ProviderUsageSchema), + }), +}); + const AgentSlashCommandSchema = z.object({ name: z.string(), description: z.string(), @@ -4093,6 +4156,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProvidersSnapshotUpdateMessageSchema, RefreshProvidersSnapshotResponseMessageSchema, ProviderDiagnosticResponseMessageSchema, + ProviderUsageListResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, TerminalsChangedSchema, @@ -4228,6 +4292,15 @@ export type RefreshProvidersSnapshotResponseMessage = z.infer< export type ProviderDiagnosticResponseMessage = z.infer< typeof ProviderDiagnosticResponseMessageSchema >; +export type ProviderUsageTone = z.infer; +export type ProviderUsageStatus = z.infer; +export type ProviderUsage = z.infer; +export type ProviderUsageWindow = z.infer; +export type ProviderUsageBalance = z.infer; +export type ProviderUsageDetail = z.infer; +export type ProviderUsageListResponseMessage = z.infer< + typeof ProviderUsageListResponseMessageSchema +>; export type ChatCreateResponse = z.infer; export type ChatListResponse = z.infer; export type ChatInspectResponse = z.infer; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 4ee09301c..5453b92b0 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -11,7 +11,7 @@ import type { ResolveAgentCreateConfigInput, } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; -import { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; +import { ProviderSnapshotManager, resolveSnapshotCwd } from "./provider-snapshot-manager.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; const TEST_CAPABILITIES = { @@ -873,4 +873,13 @@ describe("ProviderSnapshotManager cwd routing", () => { manager.destroy(); } }); + + test("resolveSnapshotCwd normalizes pure drive letters to append backslash on Windows", () => { + const resolved = resolveSnapshotCwd("C:"); + if (process.platform === "win32") { + expect(resolved).toBe("C:\\"); + } else { + expect(resolved).toBeDefined(); + } + }); }); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index b273f38d9..e0d1c1add 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -766,9 +766,16 @@ export function resolveSnapshotCwd(cwd?: string | null): string { if (!trimmed) { return homedir(); } - const expanded = + let expanded = trimmed === "~" || trimmed.startsWith("~/") ? `${homedir()}${trimmed.slice(1)}` : trimmed; - return resolve(expanded); + if (process.platform === "win32" && /^[A-Za-z]:$/.test(expanded)) { + expanded = `${expanded}\\`; + } + let resolved = resolve(expanded); + if (process.platform === "win32" && /^[A-Za-z]:$/.test(resolved)) { + resolved = `${resolved}\\`; + } + return resolved; } function entriesToArray( diff --git a/packages/server/src/server/agent/providers/mock-slow-provider.ts b/packages/server/src/server/agent/providers/mock-slow-provider.ts index 7867cf6d1..5c2ba5a21 100644 --- a/packages/server/src/server/agent/providers/mock-slow-provider.ts +++ b/packages/server/src/server/agent/providers/mock-slow-provider.ts @@ -35,7 +35,7 @@ export class MockSlowProviderClient implements AgentClient { readonly capabilities = CAPABILITIES; async isAvailable(): Promise { - return true; + return process.env.PASEO_ENABLE_MOCK_SLOW === "true"; } listModels(_options: ListModelsOptions): Promise { diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 019084dfb..1acadf96e 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -5,6 +5,8 @@ import { join } from "node:path"; import type { Logger } from "pino"; import { z } from "zod"; +import { withTimeout } from "../../../../utils/promise-timeout.js"; + import { type AgentCapabilityFlags, type AgentClient, @@ -1996,21 +1998,33 @@ export class PiRpcAgentClient implements AgentClient { } async isAvailable(): Promise { - const launch = await this.resolvePiLaunch(); - const availability = await checkProviderLaunchAvailable(launch); - if (!availability.available) { - return false; - } - const runtimeSession = await this.runtime.startSession({ cwd: homedir() }).catch(() => null); - if (!runtimeSession) { - return false; - } try { - return (await runtimeSession.getAvailableModels()).length > 0; + return await withTimeout( + (async () => { + const launch = await this.resolvePiLaunch(); + const availability = await checkProviderLaunchAvailable(launch); + if (!availability.available) { + return false; + } + const runtimeSession = await this.runtime + .startSession({ cwd: homedir() }) + .catch(() => null); + if (!runtimeSession) { + return false; + } + try { + return (await runtimeSession.getAvailableModels()).length > 0; + } catch { + return false; + } finally { + await runtimeSession.close().catch(() => undefined); + } + })(), + 2000, + "Pi availability check timed out", + ); } catch { return false; - } finally { - await runtimeSession.close().catch(() => undefined); } } diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index d48a6b1c0..f43a673e7 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -436,6 +436,19 @@ describe("paseo daemon bootstrap", () => { }); }); + test("parses IPv6 listen targets correctly", () => { + expect(parseListenString("[::1]:6767")).toEqual({ + type: "tcp", + host: "::1", + port: 6767, + }); + expect(parseListenString("[::]:6767")).toEqual({ + type: "tcp", + host: "::", + port: 6767, + }); + }); + test("rejects Windows absolute paths that are not named pipes", () => { // A Windows drive path like C:\daemon must NOT be silently parsed as TCP // (split(":") would yield host="C" and port="\\daemon" which is nonsensical). diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ed92d23d4..703ac337a 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -66,12 +66,15 @@ export function parseListenString(listen: string): ListenTarget { } // 6. host:port — TCP if (listen.includes(":")) { - const [host, portStr] = listen.split(":"); + const lastColonIdx = listen.lastIndexOf(":"); + const host = listen.slice(0, lastColonIdx); + const portStr = listen.slice(lastColonIdx + 1); const parsedPort = parseInt(portStr, 10); if (!Number.isFinite(parsedPort)) { throw new Error(`Invalid port in listen string: ${listen}`); } - return { type: "tcp", host: host || "127.0.0.1", port: parsedPort }; + const cleanHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + return { type: "tcp", host: cleanHost || "127.0.0.1", port: parsedPort }; } throw new Error(`Invalid listen string: ${listen}`); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 12a5b1d92..d7cf2ac38 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -246,6 +246,7 @@ import { type GitHubService, type PullRequestTimelineItem, } from "../services/github-service.js"; +import type { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { summarizeFetchWorkspacesEntries, workspaceIdsOnCheckout, @@ -631,6 +632,7 @@ export interface SessionOptions { tts: Resolvable; terminalManager: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; + providerUsageService: ProviderUsageService; serviceProxy?: ServiceProxySubsystem; scriptRuntimeStore?: WorkspaceScriptRuntimeStore; workspaceSetupSnapshots?: Map; @@ -865,6 +867,7 @@ export class Session { } | null = null; private readonly terminalManager: TerminalManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager; + private readonly providerUsageService: ProviderUsageService; private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private readonly serviceProxy: ServiceProxySubsystem | null; private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; @@ -934,6 +937,7 @@ export class Session { tts, terminalManager, providerSnapshotManager, + providerUsageService, serviceProxy, scriptRuntimeStore, workspaceSetupSnapshots, @@ -1027,6 +1031,7 @@ export class Session { logger: this.sessionLogger, }); this.providerSnapshotManager = providerSnapshotManager; + this.providerUsageService = providerUsageService; this.serviceProxy = serviceProxy ?? null; this.scriptRuntimeStore = scriptRuntimeStore ?? null; this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map(); @@ -2282,6 +2287,8 @@ export class Session { return this.handleRefreshProvidersSnapshotRequest(msg); case "provider_diagnostic_request": return this.handleProviderDiagnosticRequest(msg); + case "provider.usage.list.request": + return this.handleProviderUsageListRequest(msg); default: return undefined; } @@ -4394,6 +4401,34 @@ export class Session { } } + private async handleProviderUsageListRequest( + msg: Extract, + ): Promise { + try { + const usage = await this.providerUsageService.listUsage(); + this.emit({ + type: "provider.usage.list.response", + payload: { + requestId: msg.requestId, + fetchedAt: usage.fetchedAt, + providers: usage.providers, + }, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.sessionLogger.error({ err }, "Failed to list provider usage"); + this.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: `Failed to list provider usage: ${err.message}`, + code: "provider_usage_list_failed", + }, + }); + } + } + private assertSafeGitRef(ref: string, label: string): void { if (!/^[A-Za-z0-9._/-]+$/.test(ref)) { throw new Error(`Invalid ${label}: ${ref}`); diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 92a611797..7f3d490d9 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -76,6 +76,7 @@ class RecordingPushNotificationSender implements PushNotificationSender { function createServer(agentManagerOverrides?: Record) { const pushNotifications = new RecordingPushNotificationSender(); const agentManager = { + subscribe: vi.fn(() => () => {}), setAgentAttentionCallback: vi.fn(), getAgent: vi.fn(() => null), getLastAssistantMessage: vi.fn(async () => null), diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index d2c9abbb4..a1ba6c53d 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -125,6 +125,30 @@ function parseSentEnvelope(data: unknown): z.infer { return WireEnvelopeSchema.parse(JSON.parse(data)); } +function sentEnvelopes(socket: MockSocket): z.infer[] { + return socket.sent.filter((data) => typeof data === "string").map(parseSentEnvelope); +} + +function sentServerInfoEnvelopes(socket: MockSocket): z.infer[] { + return sentEnvelopes(socket).filter( + (envelope) => parseServerInfoStatusPayload(envelope.message?.payload) !== null, + ); +} + +function sentBinaryFrames(socket: MockSocket): Uint8Array[] { + return socket.sent.map(asUint8Array).filter((frame): frame is Uint8Array => frame !== null); +} + +function sentTerminalFrames( + socket: MockSocket, +): NonNullable>[] { + return sentBinaryFrames(socket) + .map(decodeTerminalStreamFrame) + .filter( + (frame): frame is NonNullable> => frame !== null, + ); +} + const BinaryFrameSchema = z.object({ kind: z.literal("terminal"), frame: z.object({ @@ -201,6 +225,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu createStub(createLogger()), "srv_test", createStub({ + subscribe: vi.fn(() => () => {}), setAgentAttentionCallback: vi.fn(), getAgent: vi.fn(() => null), getMetricsSnapshot: vi.fn(() => ({ @@ -705,20 +730,20 @@ describe("relay external socket reconnect behavior", () => { socket, clientId: "cid-server-info-broadcast", }); - expect(socket.sent).toHaveLength(1); + expect(sentServerInfoEnvelopes(socket)).toHaveLength(1); const speechReadiness = createReadySpeechReadinessSnapshot(); server.publishSpeechReadiness(speechReadiness); - expect(socket.sent).toHaveLength(2); + expect(sentServerInfoEnvelopes(socket)).toHaveLength(2); - const secondEnvelope = parseSentEnvelope(socket.sent[1]); + const secondEnvelope = sentServerInfoEnvelopes(socket)[1]; const secondPayload = parseServerInfoStatusPayload(secondEnvelope.message?.payload); expect(secondPayload?.capabilities?.voice?.dictation.enabled).toBe(true); expect(secondPayload?.capabilities?.voice?.voice.enabled).toBe(true); // Same readiness should not produce another server_info broadcast. server.publishSpeechReadiness(speechReadiness); - expect(socket.sent).toHaveLength(2); + expect(sentServerInfoEnvelopes(socket)).toHaveLength(2); await server.close(); }); @@ -731,12 +756,12 @@ describe("relay external socket reconnect behavior", () => { socket, clientId: "cid-server-info-download-guidance", }); - expect(socket.sent).toHaveLength(1); + expect(sentServerInfoEnvelopes(socket)).toHaveLength(1); server.publishSpeechReadiness(createDownloadInProgressSpeechReadinessSnapshot()); - expect(socket.sent).toHaveLength(2); + expect(sentServerInfoEnvelopes(socket)).toHaveLength(2); - const envelope = parseSentEnvelope(socket.sent[1]); + const envelope = sentServerInfoEnvelopes(socket)[1]; const payload = parseServerInfoStatusPayload(envelope.message?.payload); expect(payload?.capabilities?.voice?.dictation.enabled).toBe(true); expect(payload?.capabilities?.voice?.voice.enabled).toBe(true); @@ -797,14 +822,12 @@ describe("relay external socket reconnect behavior", () => { onBinaryMessage(new Uint8Array([TerminalStreamOpcode.Output, 12, 0x6f, 0x6b])); } - expect(socket.sent).toHaveLength(2); - const binaryPayload = asUint8Array(socket.sent[1]); - expect(binaryPayload).not.toBeNull(); - const frame = decodeTerminalStreamFrame(binaryPayload!); - expect(frame).not.toBeNull(); - expect(frame!.opcode).toBe(TerminalStreamOpcode.Output); - expect(frame!.slot).toBe(12); - expect(new TextDecoder().decode(frame!.payload ?? new Uint8Array())).toBe("ok"); + const terminalFrames = sentTerminalFrames(socket); + expect(terminalFrames).toHaveLength(1); + const frame = terminalFrames[0]; + expect(frame.opcode).toBe(TerminalStreamOpcode.Output); + expect(frame.slot).toBe(12); + expect(new TextDecoder().decode(frame.payload ?? new Uint8Array())).toBe("ok"); await server.close(); }); diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 2ef83adff..9056d06e6 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -111,6 +111,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work const pushNotifications = new RecordingPushNotificationSender(); const agentManager = { setAgentAttentionCallback: vi.fn(), + subscribe: vi.fn(() => () => {}), getAgent: vi.fn(() => null), getLastAssistantMessage: vi.fn(async () => null), getMetricsSnapshot: vi.fn(() => ({ @@ -200,22 +201,40 @@ function connectClient(server: VoiceAssistantWebSocketServer) { return ws; } +function sentTerminalAttentionMessages(ws: ReturnType) { + return ws.send.mock.calls + .map(([rawMessage]) => { + expect(typeof rawMessage).toBe("string"); + if (typeof rawMessage !== "string") throw new Error("Expected string WebSocket frame"); + return JSON.parse(rawMessage); + }) + .filter( + (message) => + message.type === "session" && message.message.type === "terminal_attention_required", + ) + .map((message) => message.message.payload as TerminalAttentionPayload); +} + +interface TerminalAttentionPayload { + terminalId: string; + cwd: string; + workspaceId?: string; + shouldNotify: boolean; + reason: "finished" | "needs_input"; + title: string; + body: string; +} + function readTerminalAttentionMessage(ws: ReturnType) { - const rawMessage = ws.send.mock.calls[0]?.[0]; - expect(typeof rawMessage).toBe("string"); - if (typeof rawMessage !== "string") throw new Error("Expected string WebSocket frame"); - const message = JSON.parse(rawMessage); - expect(message.type).toBe("session"); - expect(message.message.type).toBe("terminal_attention_required"); - return message.message.payload as { - terminalId: string; - cwd: string; - workspaceId?: string; - shouldNotify: boolean; - reason: "finished" | "needs_input"; - title: string; - body: string; - }; + const terminalMessages = sentTerminalAttentionMessages(ws); + expect(terminalMessages).toHaveLength(1); + const [payload] = terminalMessages; + if (!payload) throw new Error("Expected terminal attention message"); + return payload; +} + +function expectNoTerminalAttentionMessage(ws: ReturnType) { + expect(sentTerminalAttentionMessages(ws)).toHaveLength(0); } const CWD = "/home/user/project"; @@ -357,7 +376,7 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () => await flushAsync(); - expect(ws.send).not.toHaveBeenCalled(); + expectNoTerminalAttentionMessage(ws); }); it("does not broadcast on working -> unknown", async () => { @@ -376,7 +395,7 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () => await flushAsync(); - expect(ws.send).not.toHaveBeenCalled(); + expectNoTerminalAttentionMessage(ws); }); it("broadcasts needs_input after working -> attention", async () => { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index d0423fbd5..0814d2829 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -61,6 +61,7 @@ import { WebSocketRuntimeMetricsWindow, type WebSocketRuntimeCounters, } from "./websocket/runtime-metrics.js"; +import { ProviderUsageService } from "../services/quota-fetcher/service.js"; const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; @@ -407,6 +408,7 @@ export class VoiceAssistantWebSocketServer { private eventLoopDelayMonitor: ReturnType | null = null; private unsubscribeSpeechReadiness: (() => void) | null = null; private unsubscribeDaemonConfigChange: (() => void) | null = null; + private readonly providerUsageService: ProviderUsageService; private unsubscribeTerminalActivity: (() => void) | null = null; constructor( @@ -533,6 +535,10 @@ export class VoiceAssistantWebSocketServer { }); }); + this.providerUsageService = new ProviderUsageService({ + logger: this.logger, + }); + this.wss = this.createWebSocketServer(server, wsConfig, auth); this.startRuntimeMetricsInterval(); @@ -977,6 +983,7 @@ export class VoiceAssistantWebSocketServer { tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, + providerUsageService: this.providerUsageService, serviceProxy: this.serviceProxy ?? undefined, scriptRuntimeStore: this.scriptRuntimeStore ?? undefined, workspaceSetupSnapshots: this.workspaceSetupSnapshots, @@ -1161,6 +1168,8 @@ export class VoiceAssistantWebSocketServer { projectRemove: true, // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 worktreeRestore: true, + // COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98. + providerUsageList: true, // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. agentDetach: true, }, diff --git a/packages/server/src/services/quota-fetcher/manifest.ts b/packages/server/src/services/quota-fetcher/manifest.ts new file mode 100644 index 000000000..220bd4757 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/manifest.ts @@ -0,0 +1,57 @@ +import type { + ProviderUsageFetcher, + ProviderUsageFetcherFactoryOptions, + ProviderUsageFetcherManifestEntry, +} from "./provider.js"; +import { ClaudeQuotaProvider } from "./providers/claude.js"; +import { CodexQuotaProvider } from "./providers/codex.js"; +import { CopilotQuotaProvider } from "./providers/copilot.js"; +import { CursorQuotaProvider } from "./providers/cursor.js"; +import { GrokQuotaProvider } from "./providers/grok.js"; +import { KimiQuotaProvider } from "./providers/kimi.js"; +import { ZaiQuotaProvider } from "./providers/zai.js"; + +export const PROVIDER_USAGE_FETCHERS: readonly ProviderUsageFetcherManifestEntry[] = [ + { + providerId: "claude", + create: (options) => + new ClaudeQuotaProvider({ + logger: options.logger, + fetch: options.fetch, + }), + }, + { + providerId: "codex", + create: (options) => + new CodexQuotaProvider({ + logger: options.logger, + fetch: options.fetch, + }), + }, + { + providerId: "copilot", + create: (options) => new CopilotQuotaProvider({ logger: options.logger, fetch: options.fetch }), + }, + { + providerId: "cursor", + create: (options) => new CursorQuotaProvider({ logger: options.logger, fetch: options.fetch }), + }, + { + providerId: "zai", + create: (options) => new ZaiQuotaProvider({ logger: options.logger, fetch: options.fetch }), + }, + { + providerId: "grok", + create: (options) => new GrokQuotaProvider({ logger: options.logger, fetch: options.fetch }), + }, + { + providerId: "kimi", + create: (options) => new KimiQuotaProvider({ logger: options.logger, fetch: options.fetch }), + }, +]; + +export function createProviderUsageFetchers( + options: ProviderUsageFetcherFactoryOptions, +): ProviderUsageFetcher[] { + return PROVIDER_USAGE_FETCHERS.map((entry) => entry.create(options)); +} diff --git a/packages/server/src/services/quota-fetcher/provider.ts b/packages/server/src/services/quota-fetcher/provider.ts new file mode 100644 index 000000000..c8b6687ce --- /dev/null +++ b/packages/server/src/services/quota-fetcher/provider.ts @@ -0,0 +1,20 @@ +import type { Logger } from "pino"; +import type { ProviderUsage } from "../../server/messages.js"; + +export type ProviderApiFetch = typeof fetch; + +export interface ProviderUsageFetcher { + readonly providerId: string; + readonly displayName: string; + fetchUsage(): Promise; +} + +export interface ProviderUsageFetcherFactoryOptions { + logger: Logger; + fetch?: ProviderApiFetch; +} + +export interface ProviderUsageFetcherManifestEntry { + readonly providerId: string; + create(options: ProviderUsageFetcherFactoryOptions): ProviderUsageFetcher; +} diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/services/quota-fetcher/providers/claude.ts new file mode 100644 index 000000000..caa7f967f --- /dev/null +++ b/packages/server/src/services/quota-fetcher/providers/claude.ts @@ -0,0 +1,293 @@ +import { execFile } from "node:child_process"; +import { existsSync, promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import type { Logger } from "pino"; +import { z } from "zod"; +import type { + ProviderUsage, + ProviderUsageDetail, + ProviderUsageWindow, +} from "../../../server/messages.js"; +import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; +import { + ApiNumberSchema, + fetchProviderApi, + unavailableUsage, + windowFromUsedPct, +} from "../usage.js"; + +const execFileAsync = promisify(execFile); +const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000; +const CLAUDE_OAUTH_BETA = "oauth-2025-04-20"; +const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; + +const ClaudeCredentialsSchema = z.object({ + claudeAiOauth: z + .object({ + accessToken: z.string().optional(), + refreshToken: z.string().optional(), + subscriptionType: z.string().optional(), + rateLimitTier: z.string().optional(), + }) + .optional(), +}); + +const ClaudeUsageWindowSchema = z.object({ + utilization: ApiNumberSchema, + resets_at: z.string().optional(), +}); + +const ClaudeUsageResponseSchema = z.object({ + five_hour: ClaudeUsageWindowSchema.nullish(), + seven_day: ClaudeUsageWindowSchema.nullish(), + seven_day_opus: ClaudeUsageWindowSchema.nullish(), + seven_day_omelette: ClaudeUsageWindowSchema.nullish(), + extra_usage: z + .object({ + is_enabled: z.boolean().optional(), + }) + .nullish(), +}); + +const ClaudeTokenRefreshSchema = z.object({ + access_token: z.string().optional(), + refresh_token: z.string().optional(), +}); + +type ClaudeCredentials = z.infer; +type ClaudeUsageResponse = z.infer; +type ClaudeTokenRefresh = z.infer; + +interface ClaudeCredentialRecord { + oauth: { accessToken: string } & NonNullable; + filePath: string | null; +} + +interface ClaudeQuotaProviderOptions { + logger: Logger; + claudeHome?: string; + claudeKeychainReader?: () => Promise; + platform?: typeof process.platform; + fetch?: ProviderApiFetch; +} + +function buildClaudePlan( + subscriptionType: string | undefined, + rateLimitTier: string | undefined, +): string | null { + if (!subscriptionType) return null; + const label = subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1); + const tier = rateLimitTier?.split("_").pop(); + return tier ? `${label} ${tier}` : label; +} + +async function readClaudeKeychainCredentials(): Promise { + try { + const { stdout } = await execFileAsync( + "security", + ["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"], + { timeout: CLAUDE_KEYCHAIN_TIMEOUT_MS }, + ); + const raw = stdout.trim(); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +export class ClaudeQuotaProvider implements ProviderUsageFetcher { + readonly providerId = "claude"; + readonly displayName = "Claude"; + + private readonly claudeHome: string; + private readonly readKeychainCredentials: () => Promise; + private readonly platform: typeof process.platform; + private readonly fetchApi: ProviderApiFetch; + + constructor(options: ClaudeQuotaProviderOptions) { + this.claudeHome = + options.claudeHome || process.env["CLAUDE_HOME"] || join(homedir(), ".claude"); + this.readKeychainCredentials = options.claudeKeychainReader ?? readClaudeKeychainCredentials; + this.platform = options.platform ?? process.platform; + this.fetchApi = options.fetch ?? fetch; + } + + async fetchUsage(): Promise { + const credentials = await this.readCredentials(); + if (!credentials) { + return unavailableUsage(this); + } + + const { oauth, filePath } = credentials; + const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); + let resp = await this.callClaudeApi(oauth.accessToken); + + if (resp === "NEEDS_AUTH") { + if (!filePath || !oauth.refreshToken) { + return unavailableUsage(this); + } + + const refreshed = await this.refreshClaudeToken(oauth.refreshToken); + if (!refreshed?.access_token) { + return unavailableUsage(this); + } + + await this.saveClaudeCredentials(filePath, { + ...oauth, + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token ?? oauth.refreshToken, + }); + + resp = await this.callClaudeApi(refreshed.access_token); + if (resp === "NEEDS_AUTH") { + return unavailableUsage(this); + } + } + + const windows: ProviderUsageWindow[] = []; + if (resp.five_hour) { + windows.push( + windowFromUsedPct({ + id: "five_hour", + label: "Session", + utilizationPct: resp.five_hour.utilization, + resetsAt: resp.five_hour.resets_at ?? null, + tone: "ok", + }), + ); + } + if (resp.seven_day) { + windows.push( + windowFromUsedPct({ + id: "weekly", + label: "Weekly", + utilizationPct: resp.seven_day.utilization, + resetsAt: resp.seven_day.resets_at ?? null, + tone: "ok", + }), + ); + } + if (resp.seven_day_opus) { + windows.push( + windowFromUsedPct({ + id: "weekly_opus", + label: "Weekly · Opus", + utilizationPct: resp.seven_day_opus.utilization, + resetsAt: resp.seven_day_opus.resets_at ?? null, + tone: "ok", + }), + ); + } + if (resp.seven_day_omelette) { + windows.push( + windowFromUsedPct({ + id: "weekly_omelette", + label: "Weekly · Omelette", + utilizationPct: resp.seven_day_omelette.utilization, + resetsAt: resp.seven_day_omelette.resets_at ?? null, + tone: "ok", + }), + ); + } + + const details: ProviderUsageDetail[] = []; + const extraUsageEnabled = resp.extra_usage?.is_enabled; + if (extraUsageEnabled !== undefined) { + details.push({ + id: "extra_usage", + label: "Extra usage", + value: extraUsageEnabled ? "Enabled" : "Disabled", + }); + } + + return { + providerId: this.providerId, + displayName: this.displayName, + status: "available", + planLabel: plan, + windows, + balances: [], + details, + error: null, + }; + } + + private async readCredentials(): Promise { + const credPath = join(this.claudeHome, ".credentials.json"); + + if (existsSync(credPath)) { + try { + const creds = ClaudeCredentialsSchema.parse( + JSON.parse(await fs.readFile(credPath, "utf8")), + ); + const oauth = creds.claudeAiOauth; + if (oauth?.accessToken) { + return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: credPath }; + } + } catch { + // Fall through to the macOS Keychain below. + } + } + + if (this.platform === "darwin") { + const creds = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials()); + const oauth = creds.success ? creds.data.claudeAiOauth : undefined; + if (oauth?.accessToken) { + return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: null }; + } + } + + return null; + } + + private async callClaudeApi(token: string): Promise { + const res = await fetchProviderApi(this.fetchApi, "https://api.anthropic.com/api/oauth/usage", { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "anthropic-beta": CLAUDE_OAUTH_BETA, + }, + }); + if (res.status === 401 || res.status === 403) return "NEEDS_AUTH"; + if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`); + return ClaudeUsageResponseSchema.parse(await res.json()); + } + + private async refreshClaudeToken(refreshToken: string): Promise { + const res = await fetchProviderApi( + this.fetchApi, + "https://platform.claude.com/v1/oauth/token", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLAUDE_CLIENT_ID, + scope: "user:profile user:inference user:sessions:claude_code user:mcp_servers", + }), + }, + ); + if (!res.ok) return null; + return ClaudeTokenRefreshSchema.parse(await res.json()); + } + + private async saveClaudeCredentials( + credPath: string, + oauth: ClaudeCredentials["claudeAiOauth"], + ): Promise { + try { + const existing = ClaudeCredentialsSchema.parse( + JSON.parse(await fs.readFile(credPath, "utf8")), + ); + existing.claudeAiOauth = oauth; + await fs.writeFile(credPath, JSON.stringify(existing, null, 2), { mode: 0o600 }); + } catch { + // Non-fatal; Claude Code can refresh again on its own next time. + } + } +} diff --git a/packages/server/src/services/quota-fetcher/providers/codex.ts b/packages/server/src/services/quota-fetcher/providers/codex.ts new file mode 100644 index 000000000..76adcb3f2 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/providers/codex.ts @@ -0,0 +1,273 @@ +import { existsSync, promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "pino"; +import { z } from "zod"; +import type { + ProviderUsage, + ProviderUsageBalance, + ProviderUsageWindow, +} from "../../../server/messages.js"; +import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; +import { + ApiNumberSchema, + balanceToneFromRemaining, + fetchProviderApi, + unavailableUsage, + windowFromUsedPct, +} from "../usage.js"; + +const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; + +const CodexAuthSchema = z.object({ + tokens: z + .object({ + access_token: z.string().optional(), + refresh_token: z.string().optional(), + account_id: z.string().optional(), + }) + .optional(), +}); + +const CodexWindowSchema = z.object({ + used_percent: ApiNumberSchema.optional(), + reset_at: ApiNumberSchema.optional(), +}); + +const CodexUsageResponseSchema = z.object({ + plan_type: z.string().optional(), + email: z.string().optional(), + rate_limit: z + .object({ + primary_window: CodexWindowSchema.nullish(), + secondary_window: CodexWindowSchema.nullish(), + }) + .nullish(), + code_review_rate_limit: z + .object({ + primary_window: CodexWindowSchema.nullish(), + }) + .nullish(), + credits: z + .object({ + has_credits: z.boolean().optional(), + unlimited: z.boolean().optional(), + balance: ApiNumberSchema.optional(), + }) + .nullish(), +}); + +const CodexTokenRefreshSchema = z.object({ + access_token: z.string().optional(), + refresh_token: z.string().optional(), +}); + +type CodexAuth = z.infer; +type CodexWindow = z.infer; +type CodexUsageResponse = z.infer; +type CodexTokenRefresh = z.infer; + +interface CodexAuthRecord { + auth: CodexAuth; + path: string; +} + +interface CodexQuotaProviderOptions { + logger: Logger; + codexHome?: string; + fetch?: ProviderApiFetch; +} + +function codexWindow( + window: CodexWindow | null | undefined, +): { usedPct: number; resetsAt: string | null } | null { + if (!window) return null; + return { + usedPct: window.used_percent ?? 0, + resetsAt: window.reset_at != null ? new Date(window.reset_at * 1000).toISOString() : null, + }; +} + +export class CodexQuotaProvider implements ProviderUsageFetcher { + readonly providerId = "codex"; + readonly displayName = "Codex"; + + private readonly codexHome: string; + private readonly fetchApi: ProviderApiFetch; + + constructor(options: CodexQuotaProviderOptions) { + this.codexHome = options.codexHome || process.env["CODEX_HOME"] || join(homedir(), ".codex"); + this.fetchApi = options.fetch ?? fetch; + } + + async fetchUsage(): Promise { + const authRecord = await this.readCodexAuth(); + const auth = authRecord?.auth; + const accessToken = auth?.tokens?.access_token; + if (!authRecord || !auth || !accessToken) { + return unavailableUsage(this); + } + + const { refresh_token, account_id } = auth.tokens ?? {}; + let resp = await this.callCodexApi(accessToken, account_id); + + if (resp === "NEEDS_AUTH") { + if (!refresh_token) { + return unavailableUsage(this); + } + const refreshed = await this.refreshCodexToken(refresh_token); + if (!refreshed?.access_token) { + return unavailableUsage(this); + } + + await this.saveCodexAuth(authRecord.path, auth, refreshed); + resp = await this.callCodexApi(refreshed.access_token, account_id); + if (resp === "NEEDS_AUTH") { + return unavailableUsage(this); + } + } + + return this.toUsage(resp); + } + + private toUsage(resp: CodexUsageResponse): ProviderUsage { + const session = codexWindow(resp.rate_limit?.primary_window); + const weekly = codexWindow(resp.rate_limit?.secondary_window); + const codeReview = codexWindow(resp.code_review_rate_limit?.primary_window); + const windows: ProviderUsageWindow[] = []; + + if (session) { + windows.push( + windowFromUsedPct({ + id: "session", + label: "Session", + utilizationPct: session.usedPct, + resetsAt: session.resetsAt, + tone: "ok", + }), + ); + } + if (weekly) { + windows.push( + windowFromUsedPct({ + id: "weekly", + label: "Weekly", + utilizationPct: weekly.usedPct, + resetsAt: weekly.resetsAt, + tone: weekly.usedPct >= 70 ? "warning" : "ok", + }), + ); + } + if (codeReview) { + windows.push( + windowFromUsedPct({ + id: "code_review", + label: "Code review", + utilizationPct: codeReview.usedPct, + resetsAt: codeReview.resetsAt, + tone: codeReview.usedPct >= 70 ? "warning" : "ok", + }), + ); + } + + const balances: ProviderUsageBalance[] = []; + if (resp.credits?.balance !== undefined) { + balances.push({ + id: "credits", + label: "Credits", + remaining: resp.credits.balance, + unit: "usd", + tone: balanceToneFromRemaining(resp.credits.balance), + }); + } + + return { + providerId: this.providerId, + displayName: this.displayName, + status: "available", + planLabel: resp.plan_type ?? null, + windows, + balances, + details: [], + error: null, + }; + } + + private async readCodexAuth(): Promise { + const candidates = [ + ...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []), + join(homedir(), ".config", "codex", "auth.json"), + join(this.codexHome, "auth.json"), + ]; + for (const path of candidates) { + if (!existsSync(path)) continue; + try { + const auth = CodexAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); + if (auth.tokens?.access_token) return { auth, path }; + } catch { + continue; + } + } + return null; + } + + private async callCodexApi( + token: string, + accountId?: string, + ): Promise { + const headers: Record = { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }; + if (accountId) headers["ChatGPT-Account-Id"] = accountId; + + const res = await fetchProviderApi( + this.fetchApi, + "https://chatgpt.com/backend-api/wham/usage", + { + headers, + }, + ); + if (res.status === 401 || res.status === 403) return "NEEDS_AUTH"; + if (!res.ok) throw new Error(`Codex usage API returned ${res.status}`); + const text = await res.text(); + if (text.trim().startsWith("<")) return "NEEDS_AUTH"; + return CodexUsageResponseSchema.parse(JSON.parse(text)); + } + + private async refreshCodexToken(refreshToken: string): Promise { + const params = new URLSearchParams({ + grant_type: "refresh_token", + client_id: CODEX_CLIENT_ID, + refresh_token: refreshToken, + }); + const res = await fetchProviderApi(this.fetchApi, "https://auth.openai.com/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + if (!res.ok) return null; + return CodexTokenRefreshSchema.parse(await res.json()); + } + + private async saveCodexAuth( + authPath: string, + original: CodexAuth, + refreshed: CodexTokenRefresh, + ): Promise { + try { + const updated: CodexAuth = { + ...original, + tokens: { + ...original.tokens, + access_token: refreshed.access_token ?? original.tokens?.access_token, + refresh_token: refreshed.refresh_token ?? original.tokens?.refresh_token, + }, + }; + await fs.writeFile(authPath, JSON.stringify(updated, null, 2), { mode: 0o600 }); + } catch { + // Non-fatal; the next call can refresh again. + } + } +} diff --git a/packages/server/src/services/quota-fetcher/providers/copilot.ts b/packages/server/src/services/quota-fetcher/providers/copilot.ts new file mode 100644 index 000000000..f8b4f3183 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/providers/copilot.ts @@ -0,0 +1,97 @@ +import { existsSync, promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "pino"; +import { z } from "zod"; +import type { ProviderUsage, ProviderUsageDetail } from "../../../server/messages.js"; +import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; +import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js"; + +const CopilotUsageResponseSchema = z.object({ + copilot_plan: ApiOptionalStringSchema, + quota_reset_date: ApiOptionalStringSchema, +}); + +interface CopilotQuotaProviderOptions { + logger: Logger; + fetch?: ProviderApiFetch; +} + +async function readGithubCliToken(): Promise { + const candidates: string[] = []; + if (process.env["APPDATA"]) { + candidates.push(join(process.env["APPDATA"], "GitHub CLI", "hosts.yml")); + } + candidates.push(join(homedir(), ".config", "gh", "hosts.yml")); + + for (const path of candidates) { + if (!existsSync(path)) continue; + try { + const raw = await fs.readFile(path, "utf8"); + const match = raw.match(/oauth_token:\s*["']?([a-zA-Z0-9_-]+)["']?/); + if (match?.[1]) return match[1]; + } catch { + continue; + } + } + return null; +} + +export class CopilotQuotaProvider implements ProviderUsageFetcher { + readonly providerId = "copilot"; + readonly displayName = "GitHub Copilot"; + + private readonly logger: Logger; + private readonly fetchApi: ProviderApiFetch; + + constructor(options: CopilotQuotaProviderOptions) { + this.logger = options.logger; + this.fetchApi = options.fetch ?? fetch; + } + + async fetchUsage(): Promise { + const token = + process.env["COPILOT_TOKEN"] || + process.env["GITHUB_TOKEN"] || + process.env["GITHUB_PAT"] || + (await readGithubCliToken()); + + if (!token) return unavailableUsage(this); + + const res = await fetchProviderApi( + this.fetchApi, + "https://api.github.com/copilot_internal/user", + { + headers: { + Authorization: `token ${token}`, + Accept: "application/json", + "Editor-Version": "vscode/1.96.2", + "Editor-Plugin-Version": "copilot-chat/0.26.7", + "User-Agent": "GitHubCopilotChat/0.26.7", + "X-Github-Api-Version": "2025-04-01", + }, + }, + ); + + if (!res.ok) { + this.logger.debug({ status: res.status }, "Copilot usage fetch failed"); + return unavailableUsage(this); + } + + const resp = CopilotUsageResponseSchema.parse(await res.json()); + const details: ProviderUsageDetail[] = resp.quota_reset_date + ? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }] + : []; + + return { + providerId: this.providerId, + displayName: this.displayName, + status: "available", + planLabel: resp.copilot_plan || null, + windows: [], + balances: [], + details, + error: null, + }; + } +} diff --git a/packages/server/src/services/quota-fetcher/providers/cursor.ts b/packages/server/src/services/quota-fetcher/providers/cursor.ts new file mode 100644 index 000000000..8831e2617 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/providers/cursor.ts @@ -0,0 +1,178 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import type { Logger } from "pino"; +import { z } from "zod"; +import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js"; +import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; +import { + ApiNullableNumberSchema, + balanceToneFromRemaining, + fetchProviderApi, + toIsoStringOrNull, + unavailableUsage, +} from "../usage.js"; + +const execFileAsync = promisify(execFile); +const CURSOR_SQLITE_TIMEOUT_MS = 2_000; + +const CursorBillingCycleTimestampSchema = z.preprocess( + (value) => (typeof value === "string" || typeof value === "number" ? value : null), + z.union([z.string(), z.number()]).nullable(), +); + +const CursorUsageResponseSchema = z.object({ + planUsage: z + .object({ + totalSpend: ApiNullableNumberSchema, + includedSpend: ApiNullableNumberSchema, + bonusSpend: ApiNullableNumberSchema, + remaining: ApiNullableNumberSchema, + limit: ApiNullableNumberSchema, + }) + .nullish(), + billingCycleStart: CursorBillingCycleTimestampSchema, + billingCycleEnd: CursorBillingCycleTimestampSchema, +}); + +const CursorAuthStatusSchema = z.object({ + accessToken: z.string().optional(), +}); + +type CursorUsageResponse = z.infer; + +interface CursorQuotaProviderOptions { + logger: Logger; + fetch?: ProviderApiFetch; +} + +function parseCursorBillingCycleTimestamp( + value: CursorUsageResponse["billingCycleStart"], +): string | null { + if (value === null) return null; + + const raw = String(value).trim(); + if (!raw) return null; + + const numeric = Number(raw); + if (Number.isFinite(numeric)) { + const timestampMs = Math.abs(numeric) < 10_000_000_000 ? numeric * 1000 : numeric; + return toIsoStringOrNull(timestampMs); + } + + return toIsoStringOrNull(new Date(raw).getTime()); +} + +function centsToDollars(value: number | null): number | null { + return value === null ? null : value / 100; +} + +async function readCursorTokenFromSqlite(): Promise { + const dbPaths: string[] = []; + if (process.env["APPDATA"]) { + dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); + } + dbPaths.push( + join( + homedir(), + "Library", + "Application Support", + "Cursor", + "User", + "globalStorage", + "state.vscdb", + ), + ); + dbPaths.push(join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb")); + + for (const path of dbPaths) { + if (!existsSync(path)) continue; + try { + const { stdout } = await execFileAsync( + "sqlite3", + [path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"], + { timeout: CURSOR_SQLITE_TIMEOUT_MS }, + ); + if (stdout) { + const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim())); + if (parsed.accessToken) return parsed.accessToken; + } + } catch { + continue; + } + } + return null; +} + +export class CursorQuotaProvider implements ProviderUsageFetcher { + readonly providerId = "cursor"; + readonly displayName = "Cursor"; + + private readonly logger: Logger; + private readonly fetchApi: ProviderApiFetch; + + constructor(options: CursorQuotaProviderOptions) { + this.logger = options.logger; + this.fetchApi = options.fetch ?? fetch; + } + + async fetchUsage(): Promise { + const token = + process.env["CURSOR_ACCESS_TOKEN"] || + process.env["CURSOR_TOKEN"] || + (await readCursorTokenFromSqlite()); + + if (!token) return unavailableUsage(this); + + const res = await fetchProviderApi( + this.fetchApi, + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + }, + body: JSON.stringify({}), + }, + ); + + if (!res.ok) { + this.logger.debug({ status: res.status }, "Cursor usage fetch failed"); + return unavailableUsage(this); + } + + const resp = CursorUsageResponseSchema.parse(await res.json()); + const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd); + const balances: ProviderUsageBalance[] = []; + if (resp.planUsage) { + const totalSpend = centsToDollars(resp.planUsage.totalSpend); + const remaining = centsToDollars(resp.planUsage.remaining); + const limit = centsToDollars(resp.planUsage.limit); + balances.push({ + id: "plan_usage", + label: "Plan usage", + used: totalSpend, + remaining, + limit, + unit: "usd", + resetsAt: billingCycleEnd, + tone: balanceToneFromRemaining(remaining), + }); + } + + return { + providerId: this.providerId, + displayName: this.displayName, + status: "available", + planLabel: null, + windows: [], + balances, + details: [], + error: null, + }; + } +} diff --git a/packages/server/src/services/quota-fetcher/providers/grok.ts b/packages/server/src/services/quota-fetcher/providers/grok.ts new file mode 100644 index 000000000..3c4986a32 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/providers/grok.ts @@ -0,0 +1,118 @@ +import { existsSync, promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "pino"; +import { z } from "zod"; +import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js"; +import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; +import { + ApiNumberSchema, + balanceToneFromRemaining, + fetchProviderApi, + unavailableUsage, +} from "../usage.js"; + +const GrokUsageResponseSchema = z.object({ + config: z + .object({ + monthlyLimit: z + .object({ + val: ApiNumberSchema.optional(), + }) + .nullish(), + }) + .nullish(), + usage: z + .object({ + creditUsage: ApiNumberSchema.optional(), + }) + .nullish(), +}); + +const GrokAuthSchema = z.object({ + access_token: z.string().optional(), +}); + +interface GrokQuotaProviderOptions { + logger: Logger; + fetch?: ProviderApiFetch; +} + +export class GrokQuotaProvider implements ProviderUsageFetcher { + readonly providerId = "grok"; + readonly displayName = "Grok"; + + private readonly logger: Logger; + private readonly fetchApi: ProviderApiFetch; + + constructor(options: GrokQuotaProviderOptions) { + this.logger = options.logger; + this.fetchApi = options.fetch ?? fetch; + } + + async fetchUsage(): Promise { + const token = + process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await this.readGrokToken()); + + if (!token) return unavailableUsage(this); + + const res = await fetchProviderApi( + this.fetchApi, + "https://cli-chat-proxy.grok.com/v1/billing", + { + headers: { + Authorization: `Bearer ${token}`, + "X-XAI-Token-Auth": "xai-grok-cli", + Accept: "application/json", + }, + }, + ); + + if (!res.ok) { + this.logger.debug({ status: res.status }, "Grok usage fetch failed"); + return unavailableUsage(this); + } + + const resp = GrokUsageResponseSchema.parse(await res.json()); + const monthlyLimit = resp.config?.monthlyLimit?.val ?? null; + const creditUsage = resp.usage?.creditUsage ?? null; + const balances: ProviderUsageBalance[] = []; + if (monthlyLimit !== null || creditUsage !== null) { + const remaining = + monthlyLimit !== null && creditUsage !== null + ? Math.max(0, monthlyLimit - creditUsage) + : null; + balances.push({ + id: "monthly_credits", + label: "Monthly credits", + used: creditUsage, + remaining, + limit: monthlyLimit, + unit: "credits", + tone: balanceToneFromRemaining(remaining), + }); + } + + return { + providerId: this.providerId, + displayName: this.displayName, + status: "available", + planLabel: null, + windows: [], + balances, + details: [], + error: null, + }; + } + + private async readGrokToken(): Promise { + const path = join(homedir(), ".grok", "auth.json"); + if (!existsSync(path)) return null; + try { + const auth = GrokAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); + return auth.access_token ?? null; + } catch { + return null; + } + } +} diff --git a/packages/server/src/services/quota-fetcher/providers/kimi.ts b/packages/server/src/services/quota-fetcher/providers/kimi.ts new file mode 100644 index 000000000..5c46f8ff3 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/providers/kimi.ts @@ -0,0 +1,100 @@ +import { existsSync, promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "pino"; +import { z } from "zod"; +import type { ProviderUsage } from "../../../server/messages.js"; +import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; +import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js"; + +const KimiUsageResponseSchema = z.object({ + usage: z + .object({ + limit: ApiOptionalStringSchema, + remaining: ApiOptionalStringSchema, + resetTime: ApiOptionalStringSchema, + }) + .nullish(), +}); + +const KimiAuthSchema = z.object({ + access_token: z.string().optional(), +}); + +interface KimiQuotaProviderOptions { + logger: Logger; + fetch?: ProviderApiFetch; +} + +export class KimiQuotaProvider implements ProviderUsageFetcher { + readonly providerId = "kimi"; + readonly displayName = "Kimi"; + + private readonly logger: Logger; + private readonly fetchApi: ProviderApiFetch; + + constructor(options: KimiQuotaProviderOptions) { + this.logger = options.logger; + this.fetchApi = options.fetch ?? fetch; + } + + async fetchUsage(): Promise { + const token = + process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"] || (await this.readKimiToken()); + + if (!token) return unavailableUsage(this); + + const res = await fetchProviderApi(this.fetchApi, "https://api.kimi.com/coding/v1/usages", { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + }); + + if (!res.ok) { + this.logger.debug({ status: res.status }, "Kimi usage fetch failed"); + return unavailableUsage(this); + } + + const resp = KimiUsageResponseSchema.parse(await res.json()); + const limit = resp.usage?.limit === undefined ? null : Number(resp.usage.limit); + const remaining = resp.usage?.remaining === undefined ? null : Number(resp.usage.remaining); + const hasFiniteLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0; + const hasFiniteRemaining = typeof remaining === "number" && Number.isFinite(remaining); + const usedPct = + hasFiniteLimit && hasFiniteRemaining + ? Math.max(0, Math.min(100, ((limit - remaining) / limit) * 100)) + : null; + + return { + providerId: this.providerId, + displayName: this.displayName, + status: "available", + planLabel: null, + windows: [ + { + id: "coding_usage", + label: "Coding usage", + usedPct, + remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), + resetsAt: resp.usage?.resetTime ?? null, + tone: "ok", + }, + ], + balances: [], + details: [], + error: null, + }; + } + + private async readKimiToken(): Promise { + const path = join(homedir(), ".kimi", "credentials", "kimi-code.json"); + if (!existsSync(path)) return null; + try { + const credentials = KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); + return credentials.access_token ?? null; + } catch { + return null; + } + } +} diff --git a/packages/server/src/services/quota-fetcher/providers/zai.ts b/packages/server/src/services/quota-fetcher/providers/zai.ts new file mode 100644 index 000000000..e8884cf2c --- /dev/null +++ b/packages/server/src/services/quota-fetcher/providers/zai.ts @@ -0,0 +1,79 @@ +import type { Logger } from "pino"; +import { z } from "zod"; +import type { ProviderUsage, ProviderUsageDetail } from "../../../server/messages.js"; +import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; +import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js"; + +const ZaiUsageResponseSchema = z.object({ + data: z + .array( + z.object({ + productName: ApiOptionalStringSchema, + status: ApiOptionalStringSchema, + purchaseTime: ApiOptionalStringSchema, + valid: ApiOptionalStringSchema, + }), + ) + .optional(), +}); + +interface ZaiQuotaProviderOptions { + logger: Logger; + fetch?: ProviderApiFetch; +} + +export class ZaiQuotaProvider implements ProviderUsageFetcher { + readonly providerId = "zai"; + readonly displayName = "Z.ai"; + + private readonly logger: Logger; + private readonly fetchApi: ProviderApiFetch; + + constructor(options: ZaiQuotaProviderOptions) { + this.logger = options.logger; + this.fetchApi = options.fetch ?? fetch; + } + + async fetchUsage(): Promise { + const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"]; + if (!token) return unavailableUsage(this); + + const res = await fetchProviderApi( + this.fetchApi, + "https://api.z.ai/api/biz/subscription/list", + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + }, + ); + + if (!res.ok) { + this.logger.debug({ status: res.status }, "Z.ai usage fetch failed"); + return unavailableUsage(this); + } + + const resp = ZaiUsageResponseSchema.parse(await res.json()); + const sub = resp.data?.[0]; + if (!sub) return unavailableUsage(this); + + const details: ProviderUsageDetail[] = []; + if (sub.status) details.push({ id: "status", label: "Status", value: sub.status }); + if (sub.valid) details.push({ id: "valid", label: "Valid", value: sub.valid }); + if (sub.purchaseTime) { + details.push({ id: "purchase_time", label: "Purchased", value: sub.purchaseTime }); + } + + return { + providerId: this.providerId, + displayName: this.displayName, + status: "available", + planLabel: sub.productName || null, + windows: [], + balances: [], + details, + error: null, + }; + } +} diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts new file mode 100644 index 000000000..2c3f41686 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -0,0 +1,697 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderUsage } from "../../server/messages.js"; +import type { ProviderUsageFetcher } from "./provider.js"; +import { ClaudeQuotaProvider } from "./providers/claude.js"; +import { CodexQuotaProvider } from "./providers/codex.js"; +import { CopilotQuotaProvider } from "./providers/copilot.js"; +import { CursorQuotaProvider } from "./providers/cursor.js"; +import { GrokQuotaProvider } from "./providers/grok.js"; +import { KimiQuotaProvider } from "./providers/kimi.js"; +import { ZaiQuotaProvider } from "./providers/zai.js"; +import { ProviderUsageService } from "./service.js"; + +function writeClaudeCredentials( + dir: string, + accessToken: string, + refreshToken = "rt_test", + subscriptionType = "pro", + rateLimitTier = "default_1x", +): void { + writeFileSync( + join(dir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { accessToken, refreshToken, subscriptionType, rateLimitTier }, + }), + ); +} + +function writeCodexAuth(dir: string, accessToken: string, refreshToken = "rt_codex"): void { + writeFileSync( + join(dir, "auth.json"), + JSON.stringify({ tokens: { access_token: accessToken, refresh_token: refreshToken } }), + ); +} + +function makeClaudeResponse( + overrides: Partial<{ + five_hour: { utilization: number | string; resets_at: string }; + seven_day: { utilization: number | string; resets_at: string }; + seven_day_opus: { utilization: number | string; resets_at: string }; + }> = {}, +) { + return { + five_hour: { utilization: 11, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" }, + seven_day_opus: { utilization: 0.5, resets_at: "2026-06-04T00:00:00Z" }, + ...overrides, + }; +} + +function makeCodexResponse(overrides: object = {}) { + return { + plan_type: "plus", + email: "user@example.com", + rate_limit: { + primary_window: { used_percent: 42, reset_at: 1_748_812_800 }, + secondary_window: { used_percent: 8, reset_at: 1_749_072_000 }, + }, + ...overrides, + }; +} + +function mockFetch(handlers: Map Response>): typeof fetch { + return vi.fn(async (url: RequestInfo | URL) => { + const key = url.toString(); + const handler = handlers.get(key); + if (!handler) throw new Error(`Unmocked fetch: ${key}`); + return handler(); + }) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function createLogger() { + const logger = { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + child: () => logger, + }; + return logger as never; +} + +function usageFetcher(usage: ProviderUsage): ProviderUsageFetcher { + return { + providerId: usage.providerId, + displayName: usage.displayName, + fetchUsage: async () => usage, + }; +} + +function findProvider(result: { providers: ProviderUsage[] }, providerId: string): ProviderUsage { + const provider = result.providers.find((candidate) => candidate.providerId === providerId); + if (!provider) { + throw new Error(`Missing provider ${providerId}`); + } + return provider; +} + +describe("ProviderUsageService", () => { + it("returns arbitrary registered providers and windows as normalized usage data", async () => { + const service = new ProviderUsageService({ + logger: createLogger(), + now: () => Date.parse("2026-06-19T00:00:00.000Z"), + fetchers: [ + usageFetcher({ + providerId: "glm", + displayName: "GLM coding plan", + status: "available", + planLabel: "GLM coding plan", + windows: [ + { + id: "biweekly", + label: "Biweekly", + usedPct: 23, + remainingPct: 77, + resetsAt: "2026-07-03T00:00:00.000Z", + }, + ], + }), + ], + }); + + await expect(service.listUsage()).resolves.toEqual({ + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "glm", + displayName: "GLM coding plan", + status: "available", + planLabel: "GLM coding plan", + windows: [ + { + id: "biweekly", + label: "Biweekly", + usedPct: 23, + remainingPct: 77, + resetsAt: "2026-07-03T00:00:00.000Z", + }, + ], + }, + ], + }); + }); + + it("caches usage until forced to refresh", async () => { + let now = Date.parse("2026-06-19T00:00:00.000Z"); + let calls = 0; + const service = new ProviderUsageService({ + logger: createLogger(), + now: () => now, + cacheTtlMs: 60_000, + fetchers: [ + { + providerId: "claude", + displayName: "Claude", + fetchUsage: async () => { + calls += 1; + return { + providerId: "claude", + displayName: "Claude", + status: "available", + planLabel: "Max 20x", + windows: [{ id: "session", label: "Session", usedPct: calls }], + }; + }, + }, + ], + }); + + const first = await service.listUsage(); + now += 30_000; + const cached = await service.listUsage(); + const refreshed = await service.listUsage({ forceRefresh: true }); + + expect(calls).toBe(2); + expect(cached).toBe(first); + expect(refreshed.providers[0]?.windows[0]?.usedPct).toBe(2); + }); + + it("deduplicates concurrent cache misses", async () => { + let calls = 0; + let resolveUsage: ((usage: ProviderUsage) => void) | null = null; + const service = new ProviderUsageService({ + logger: createLogger(), + now: () => Date.parse("2026-06-19T00:00:00.000Z"), + fetchers: [ + { + providerId: "claude", + displayName: "Claude", + fetchUsage: () => { + calls += 1; + return new Promise((resolve) => { + resolveUsage = resolve; + }); + }, + }, + ], + }); + + const first = service.listUsage(); + const second = service.listUsage(); + + expect(calls).toBe(1); + resolveUsage?.({ + providerId: "claude", + displayName: "Claude", + status: "available", + planLabel: "Max 20x", + windows: [{ id: "session", label: "Session", usedPct: 12 }], + }); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult).toBe(secondResult); + expect(calls).toBe(1); + }); + + it("isolates one provider error without dropping other providers", async () => { + const service = new ProviderUsageService({ + logger: createLogger(), + now: () => Date.parse("2026-06-19T00:00:00.000Z"), + fetchers: [ + { + providerId: "claude", + displayName: "Claude", + fetchUsage: async () => { + throw new Error("Claude auth expired"); + }, + }, + usageFetcher({ + providerId: "codex", + displayName: "Codex", + status: "available", + planLabel: "Pro 20x", + windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }], + }), + ], + }); + + await expect(service.listUsage()).resolves.toEqual({ + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "claude", + displayName: "Claude", + status: "error", + planLabel: null, + windows: [], + balances: [], + details: [], + error: "Claude auth expired", + }, + { + providerId: "codex", + displayName: "Codex", + status: "available", + planLabel: "Pro 20x", + windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }], + }, + ], + }); + }); +}); + +describe("real provider usage fetchers", () => { + let claudeHome: string; + let codexHome: string; + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "usage-test-claude-")); + codexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-")); + homeDir = mkdtempSync(join(tmpdir(), "usage-test-home-")); + fetchApi = mockFetch(new Map()); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + + for (const key of [ + "APPDATA", + "COPILOT_TOKEN", + "GITHUB_TOKEN", + "GITHUB_PAT", + "CURSOR_ACCESS_TOKEN", + "CURSOR_TOKEN", + "ZAI_API_KEY", + "GLM_API_KEY", + "GROK_API_KEY", + "GROK_TOKEN", + "KIMI_TOKEN", + "KIMI_API_KEY", + "CODEX_HOME", + ]) { + delete process.env[key]; + } + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + rmSync(homeDir, { recursive: true, force: true }); + for (const key in originalEnv) { + process.env[key] = originalEnv[key]; + } + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + }); + + function service( + options: { platform?: typeof process.platform; keychain?: () => Promise } = {}, + ) { + const logger = createLogger(); + const fetchThroughTestDouble = ((url: RequestInfo | URL, init?: RequestInit) => + fetchApi(url, init)) as typeof fetch; + return new ProviderUsageService({ + logger, + now: () => Date.parse("2026-06-19T00:00:00.000Z"), + fetchers: [ + new ClaudeQuotaProvider({ + logger, + claudeHome, + claudeKeychainReader: options.keychain ?? (async () => null), + platform: options.platform, + fetch: fetchThroughTestDouble, + }), + new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }), + new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }), + new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }), + new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), + new GrokQuotaProvider({ logger, fetch: fetchThroughTestDouble }), + new KimiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), + ], + cacheTtlMs: 0, + }); + } + + it("fetches Claude usage, coerces API numbers, and attaches HTTP timeout signals", async () => { + writeClaudeCredentials(claudeHome, "at_valid"); + fetchApi = mockFetch( + new Map([ + [ + "https://api.anthropic.com/api/oauth/usage", + () => + jsonResponse( + makeClaudeResponse({ + five_hour: { utilization: "11", resets_at: "2026-06-01T21:00:00Z" }, + }), + ), + ], + ]), + ); + + const result = await service().listUsage(); + const claude = findProvider(result, "claude"); + + expect(claude).toMatchObject({ + status: "available", + planLabel: "Pro 1x", + windows: expect.arrayContaining([ + expect.objectContaining({ id: "five_hour", usedPct: 11 }), + expect.objectContaining({ id: "weekly", usedPct: 1 }), + expect.objectContaining({ id: "weekly_opus", usedPct: 0.5 }), + ]), + }); + expect(fetchApi).toHaveBeenCalledWith( + "https://api.anthropic.com/api/oauth/usage", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("returns unavailable Claude usage when credentials are missing", async () => { + fetchApi = vi.fn() as never; + + const result = await service().listUsage(); + const claude = findProvider(result, "claude"); + + expect(claude.status).toBe("unavailable"); + expect(fetchApi).not.toHaveBeenCalled(); + }); + + it("refreshes Claude access tokens on 401 and retries", async () => { + writeClaudeCredentials(claudeHome, "at_expired", "rt_valid"); + let usageCalls = 0; + fetchApi = vi.fn(async (url: RequestInfo | URL) => { + const endpoint = url.toString(); + if (endpoint === "https://api.anthropic.com/api/oauth/usage") { + usageCalls += 1; + if (usageCalls === 1) return new Response(null, { status: 401 }); + return jsonResponse(makeClaudeResponse()); + } + if (endpoint === "https://platform.claude.com/v1/oauth/token") { + return jsonResponse({ access_token: "at_refreshed", refresh_token: "rt_new" }); + } + throw new Error(`Unmocked: ${endpoint}`); + }) as never; + + const result = await service().listUsage(); + + expect(findProvider(result, "claude").status).toBe("available"); + expect(usageCalls).toBe(2); + }); + + it("returns unavailable Claude usage when 401 persists after refresh", async () => { + writeClaudeCredentials(claudeHome, "at_bad", "rt_bad"); + fetchApi = mockFetch( + new Map([ + ["https://api.anthropic.com/api/oauth/usage", () => new Response(null, { status: 401 })], + [ + "https://platform.claude.com/v1/oauth/token", + () => jsonResponse({ access_token: "at_still_bad", refresh_token: "rt_still_bad" }), + ], + ]), + ); + + const result = await service().listUsage(); + + expect(findProvider(result, "claude").status).toBe("unavailable"); + }); + + it("does not refresh Claude tokens read from the macOS Keychain", async () => { + const usageFetch = vi.fn(async () => new Response(null, { status: 401 })); + fetchApi = usageFetch as never; + + const result = await service({ + platform: "darwin", + keychain: async () => ({ + claudeAiOauth: { + accessToken: "at_expired", + refreshToken: "rt_valid", + }, + }), + }).listUsage(); + + expect(findProvider(result, "claude").status).toBe("unavailable"); + expect(usageFetch).toHaveBeenCalledTimes(1); + expect(usageFetch).toHaveBeenCalledWith( + "https://api.anthropic.com/api/oauth/usage", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer at_expired" }), + }), + ); + }); + + it("fetches Codex windows and coerces string credit balances", async () => { + writeCodexAuth(codexHome, "at_codex_valid"); + fetchApi = mockFetch( + new Map([ + [ + "https://chatgpt.com/backend-api/wham/usage", + () => + jsonResponse( + makeCodexResponse({ + code_review_rate_limit: null, + credits: { balance: "0" }, + }), + ), + ], + ]), + ); + + const result = await service().listUsage(); + const codex = findProvider(result, "codex"); + + expect(codex).toMatchObject({ + status: "available", + planLabel: "plus", + windows: expect.arrayContaining([ + expect.objectContaining({ id: "session", usedPct: 42 }), + expect.objectContaining({ id: "weekly", usedPct: 8 }), + ]), + balances: [expect.objectContaining({ id: "credits", remaining: 0 })], + }); + }); + + it("treats a Codex HTML usage response as auth failure", async () => { + writeCodexAuth(codexHome, "at_codex_stale"); + fetchApi = mockFetch( + new Map([ + [ + "https://chatgpt.com/backend-api/wham/usage", + () => new Response("Login", { status: 200 }), + ], + ["https://auth.openai.com/oauth/token", () => new Response(null, { status: 401 })], + ]), + ); + + const result = await service().listUsage(); + + expect(findProvider(result, "codex").status).toBe("unavailable"); + }); + + it("persists refreshed Codex tokens to the auth file that was read", async () => { + const alternateCodexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-alt-")); + process.env["CODEX_HOME"] = alternateCodexHome; + writeFileSync(join(alternateCodexHome, "auth.json"), JSON.stringify({ tokens: {} })); + writeCodexAuth(codexHome, "at_codex_stale", "rt_codex_valid"); + + let usageCalls = 0; + fetchApi = mockFetch( + new Map([ + [ + "https://chatgpt.com/backend-api/wham/usage", + () => { + usageCalls += 1; + if (usageCalls === 1) return new Response(null, { status: 401 }); + return jsonResponse(makeCodexResponse()); + }, + ], + [ + "https://auth.openai.com/oauth/token", + () => jsonResponse({ access_token: "at_codex_fresh", refresh_token: "rt_codex_fresh" }), + ], + ]), + ); + + try { + const result = await service().listUsage(); + + const refreshedAuth = JSON.parse(readFileSync(join(codexHome, "auth.json"), "utf8")); + const untouchedAuth = JSON.parse(readFileSync(join(alternateCodexHome, "auth.json"), "utf8")); + expect(findProvider(result, "codex").status).toBe("available"); + expect(refreshedAuth.tokens.access_token).toBe("at_codex_fresh"); + expect(refreshedAuth.tokens.refresh_token).toBe("rt_codex_fresh"); + expect(untouchedAuth.tokens.access_token).toBeUndefined(); + } finally { + rmSync(alternateCodexHome, { recursive: true, force: true }); + } + }); + + it("fetches Copilot usage from COPILOT_TOKEN", async () => { + process.env["COPILOT_TOKEN"] = "copilot_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.github.com/copilot_internal/user", + () => + jsonResponse({ + copilot_plan: "business", + quota_reset_date: "2026-07-01T00:00:00Z", + }), + ], + ]), + ); + + const copilot = findProvider(await service().listUsage(), "copilot"); + + expect(copilot).toMatchObject({ + status: "available", + planLabel: "business", + details: [{ id: "reset", label: "Quota reset", value: "2026-07-01T00:00:00Z" }], + }); + }); + + it("fetches Cursor usage and normalizes malformed billing dates to null", async () => { + process.env["CURSOR_ACCESS_TOKEN"] = "cursor_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + () => + jsonResponse({ + planUsage: { + totalSpend: "1500", + includedSpend: "1000", + bonusSpend: "500", + remaining: "2500", + limit: "4000", + }, + billingCycleStart: "2026-01-14T12:42:14.000Z", + billingCycleEnd: "not-a-date", + }), + ], + ]), + ); + + const cursor = findProvider(await service().listUsage(), "cursor"); + + expect(cursor).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "plan_usage", + used: 15, + remaining: 25, + limit: 40, + resetsAt: null, + }), + ], + }); + }); + + it("fetches Z.ai usage from ZAI_API_KEY", async () => { + process.env["ZAI_API_KEY"] = "zai_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.z.ai/api/biz/subscription/list", + () => + jsonResponse({ + data: [ + { + productName: "GLM Coding Max", + status: "VALID", + purchaseTime: "2026-01-12 16:55:13", + valid: "2026-02-12 16:55:13-2026-03-12 16:55:13", + }, + ], + }), + ], + ]), + ); + + const zai = findProvider(await service().listUsage(), "zai"); + + expect(zai).toMatchObject({ + status: "available", + planLabel: "GLM Coding Max", + details: expect.arrayContaining([{ id: "status", label: "Status", value: "VALID" }]), + }); + }); + + it("fetches Grok usage and preserves zero values", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing", + () => + jsonResponse({ + config: { monthlyLimit: { val: 0 } }, + usage: { creditUsage: 0 }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 0, + remaining: 0, + limit: 0, + }), + ], + }); + }); + + it("fetches Kimi usage from KIMI_TOKEN", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.kimi.com/coding/v1/usages", + () => + jsonResponse({ + usage: { + limit: "100", + remaining: "74", + resetTime: "2026-02-11T17:32:50Z", + }, + }), + ], + ]), + ); + + const kimi = findProvider(await service().listUsage(), "kimi"); + + expect(kimi).toMatchObject({ + status: "available", + windows: [ + expect.objectContaining({ + id: "coding_usage", + usedPct: 26, + remainingPct: 74, + resetsAt: "2026-02-11T17:32:50Z", + }), + ], + }); + }); +}); diff --git a/packages/server/src/services/quota-fetcher/service.ts b/packages/server/src/services/quota-fetcher/service.ts new file mode 100644 index 000000000..dde20fb24 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/service.ts @@ -0,0 +1,89 @@ +import type { Logger } from "pino"; +import type { ProviderUsage } from "../../server/messages.js"; +import { createProviderUsageFetchers } from "./manifest.js"; +import type { ProviderApiFetch, ProviderUsageFetcher } from "./provider.js"; +import { unavailableUsage } from "./usage.js"; + +export interface ProviderUsageServiceOptions { + logger: Logger; + fetchers?: ProviderUsageFetcher[]; + fetch?: ProviderApiFetch; + cacheTtlMs?: number; + now?: () => number; +} + +export interface ProviderUsageListResult { + fetchedAt: string; + providers: ProviderUsage[]; +} + +const DEFAULT_PROVIDER_USAGE_CACHE_TTL_MS = 5 * 60 * 1000; + +export class ProviderUsageService { + private readonly logger: Logger; + private readonly fetchers: ProviderUsageFetcher[]; + private readonly cacheTtlMs: number; + private readonly now: () => number; + private cached: { fetchedAtMs: number; result: ProviderUsageListResult } | null = null; + private inFlight: Promise | null = null; + + constructor(options: ProviderUsageServiceOptions) { + this.logger = options.logger.child({ module: "provider-usage-service" }); + this.fetchers = + options.fetchers ?? + createProviderUsageFetchers({ + logger: this.logger, + fetch: options.fetch, + }); + this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_PROVIDER_USAGE_CACHE_TTL_MS; + this.now = options.now ?? Date.now; + } + + async listUsage(options?: { forceRefresh?: boolean }): Promise { + const nowMs = this.now(); + if ( + !options?.forceRefresh && + this.cached && + nowMs - this.cached.fetchedAtMs < this.cacheTtlMs + ) { + return this.cached.result; + } + + if (this.inFlight) { + return this.inFlight; + } + + const request = this.fetchFreshUsage(nowMs); + this.inFlight = request; + try { + return await request; + } finally { + if (this.inFlight === request) { + this.inFlight = null; + } + } + } + + private async fetchFreshUsage(nowMs: number): Promise { + const settled = await Promise.allSettled(this.fetchers.map((fetcher) => fetcher.fetchUsage())); + const providers = settled.map((result, index) => { + const fetcher = this.fetchers[index]; + if (result.status === "fulfilled") { + return result.value; + } + this.logger.debug( + { err: result.reason, providerId: fetcher.providerId }, + "Provider usage fetch failed", + ); + return unavailableUsage({ + providerId: fetcher.providerId, + displayName: fetcher.displayName, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }); + }); + + const result = { fetchedAt: new Date(nowMs).toISOString(), providers }; + this.cached = { fetchedAtMs: nowMs, result }; + return result; + } +} diff --git a/packages/server/src/services/quota-fetcher/usage.ts b/packages/server/src/services/quota-fetcher/usage.ts new file mode 100644 index 000000000..148219960 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/usage.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; +import type { + ProviderUsage, + ProviderUsageBalance, + ProviderUsageWindow, +} from "../../server/messages.js"; +import type { ProviderApiFetch } from "./provider.js"; + +const PROVIDER_HTTP_TIMEOUT_MS = 15_000; + +export const ApiNumberSchema = z.coerce.number().finite(); +export const ApiNullableNumberSchema = z.preprocess( + (value) => (value == null ? null : value), + ApiNumberSchema.nullable(), +); +export const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); + +export function fetchProviderApi( + fetchApi: ProviderApiFetch, + input: RequestInfo | URL, + init: RequestInit = {}, +): Promise { + return fetchApi(input, { + ...init, + signal: init.signal ?? AbortSignal.timeout(PROVIDER_HTTP_TIMEOUT_MS), + }); +} + +export function unavailableUsage(provider: { + providerId: string; + displayName: string; + error?: string | null; +}): ProviderUsage { + return { + providerId: provider.providerId, + displayName: provider.displayName, + status: provider.error ? "error" : "unavailable", + planLabel: null, + windows: [], + balances: [], + details: [], + error: provider.error ?? null, + }; +} + +export function windowFromUsedPct(input: { + id: string; + label: string; + utilizationPct: number | null | undefined; + resetsAt?: string | null; + tone?: ProviderUsageWindow["tone"]; +}): ProviderUsageWindow { + const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null; + const window: ProviderUsageWindow = { + id: input.id, + label: input.label, + usedPct, + remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), + resetsAt: input.resetsAt ?? null, + }; + if (input.tone) { + window.tone = input.tone; + } + return window; +} + +export function balanceToneFromRemaining( + remaining: number | null | undefined, +): ProviderUsageBalance["tone"] { + if (typeof remaining !== "number") return "default"; + if (remaining <= 0) return "danger"; + return "ok"; +} + +export function toIsoStringOrNull(timestampMs: number): string | null { + const date = new Date(timestampMs); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; +} diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 index cbe5a9508..5b390407b 100644 --- a/scripts/dev.ps1 +++ b/scripts/dev.ps1 @@ -38,7 +38,7 @@ Write-Host @" ====================================================== Home: $($env:PASEO_HOME) Models: $($env:PASEO_LOCAL_MODELS_DIR) - Daemon: localhost:6767 + Daemon: localhost:6768 ====================================================== "@ @@ -49,12 +49,13 @@ $env:PASEO_CORS_ORIGINS = "*" # Configure the app to auto-connect to this daemon on localhost $env:APP_VARIANT = "development" -$env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6767" +$env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6768" +$env:PASEO_LISTEN = "127.0.0.1:6768" $env:BROWSER = "none" # Run both with concurrently concurrently ` --names "daemon,metro" ` --prefix-colors "cyan,magenta" ` - "npm run dev:server" ` + "npm run dev:server:watch" ` "cd packages/app && npx expo start"