diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index 7a35114bf2..833820ff2d 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -34,6 +34,7 @@ hub: - "packages/server/src/server/hub/**" server: + - "plugins/**" - "packages/server/**" - "packages/app/e2e/support/fixtures/recording.*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 593ba0d338..0e3f65f336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,9 @@ jobs: - name: Build server stack run: npm run build:server + - name: Verify built-in plugins from built output + run: node --test scripts/builtin-plugins-dist.test.mjs + - name: Typecheck all packages run: npm run typecheck @@ -172,6 +175,10 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + - name: Run built-in plugin tests + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} + run: npm run test --workspace=@getpaseo/builtin-plugins + - name: Run Hub CLI/daemon contract if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }} run: npm run test:hub-cli-contract --workspace=@getpaseo/server diff --git a/docs/glossary.md b/docs/glossary.md index 2c09d04dce..4e0111475c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -31,6 +31,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced. - **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent. - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). +- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). +- **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). +- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`). - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). diff --git a/docs/plugins.md b/docs/plugins.md index 97258e063b..370d3a2bdc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -36,7 +36,8 @@ The daemon stores directory sources under the root `plugins` object: } ``` -The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is +Installed plugins are disabled unless `pluginsEnabled` is `true`. Built-in plugins remain active. +Changing that root field is runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured, enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries remain lifecycle-owned and do not reload from manual config edits. @@ -85,6 +86,15 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source never deletes it. The global `pluginsEnabled` switch remains available. +## Built-in plugins + +Built-in plugins live in `plugins//` and ship with the daemon. Add a directory and one ID to +`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build +copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run +in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin +list. Their client bundles appear in the plugin catalog. Editing one in development requires a +daemon restart. Directory, Git, and npm installs cannot use a built-in ID. + ## Install a Git source GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing @@ -376,6 +386,12 @@ external `href` or `xlink:href` references are rejected. Fragment references suc allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline SVG or URL. +## Usage sources + +Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons. + +The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version. + ## Contribute buttons Header buttons and composer pills share the client-only descriptor and registration lifecycle in diff --git a/docs/providers.md b/docs/providers.md index 88cbb3b7d6..f38074ce71 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -195,26 +195,17 @@ promise for completion: equal results, including equal discovery timestamps, emi --- -## Provider Usage Fetchers +## Usage sources -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. +Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and optional `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. Mark the window the app should show first with `headline: true`. -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: +Create a built-in source under `plugins/-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources). -- `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 +`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`. -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. +### Credentials are read only -Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs. - -Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file. - -### Usage fetchers are read-only on credentials - -A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI. +A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model. --- diff --git a/knip.json b/knip.json index f61a8a33b7..e4f5cec73d 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,10 @@ { "$schema": "./node_modules/knip/schema.json", "workspaces": { + "plugins": { + "entry": ["**/index.{server,client}.{ts,tsx}", "**/*.test.ts"], + "project": ["**/*.{ts,tsx}"] + }, ".": { "entry": ["scripts/**/*.{js,mjs,cjs,ts}"], "project": ["scripts/**/*.{js,mjs,cjs,ts}"] diff --git a/package-lock.json b/package-lock.json index cc0bae123b..c396338f9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ + "plugins", "packages/expo-two-way-audio", "packages/highlight", "packages/plugin", @@ -6333,6 +6334,10 @@ "resolved": "packages/app", "link": true }, + "node_modules/@getpaseo/builtin-plugins": { + "resolved": "plugins", + "link": true + }, "node_modules/@getpaseo/cli": { "resolved": "packages/cli", "link": true @@ -39238,6 +39243,7 @@ "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", + "yaml": "2.9.1", "zod": "^4.4.3" }, "devDependencies": { @@ -40096,6 +40102,21 @@ "node": "^18.17.0 || >=20.5.0" } }, + "packages/server/node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "packages/server/node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", @@ -41712,6 +41733,18 @@ "optional": true } } + }, + "plugins": { + "name": "@getpaseo/builtin-plugins", + "version": "0.0.0", + "dependencies": { + "@getpaseo/plugin": "*", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "vitest": "^4.1.6" + } } } } diff --git a/package.json b/package.json index 97ea27802f..948cd29b27 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "url": "https://github.com/getpaseo/paseo.git" }, "workspaces": [ + "plugins", "packages/expo-two-way-audio", "packages/highlight", "packages/plugin", @@ -66,7 +67,7 @@ "watch:protocol": "npm run watch --workspace=@getpaseo/protocol", "watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput", "typecheck": "npm run typecheck --workspaces --if-present", - "typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli", + "typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/builtin-plugins && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli", "test": "npm run test --workspaces --if-present", "format": "oxfmt .", "format:files": "oxfmt", diff --git a/packages/app/e2e/browser/provider-usage-settings.spec.ts b/packages/app/e2e/browser/provider-usage-settings.spec.ts deleted file mode 100644 index 0763233e10..0000000000 --- a/packages/app/e2e/browser/provider-usage-settings.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { expect, test } from "../support/fixtures"; -import { gotoAppShell, openSettings } from "../support/helpers/app"; -import { installProviderUsageFixture } from "../support/helpers/provider-usage"; -import { getServerId } from "../support/helpers/server-id"; -import { openSettingsHostSection } from "../support/helpers/settings"; - -test.describe("provider usage settings", () => { - 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/browser/provider-usage-tooltip.spec.ts b/packages/app/e2e/browser/provider-usage-tooltip.spec.ts deleted file mode 100644 index 680b7b4b80..0000000000 --- a/packages/app/e2e/browser/provider-usage-tooltip.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { expect, test, type Page } from "../support/fixtures"; -import { expectComposerVisible } from "../support/helpers/composer"; -import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -import { installProviderUsageFixture } from "../support/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/e2e/browser/usage-composer-pill.spec.ts b/packages/app/e2e/browser/usage-composer-pill.spec.ts new file mode 100644 index 0000000000..42df720db7 --- /dev/null +++ b/packages/app/e2e/browser/usage-composer-pill.spec.ts @@ -0,0 +1,219 @@ +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { expect, test, type Page } from "../support/fixtures"; +import { expectComposerVisible, submitMessage } from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; + +const REPORT_ID = "fixture:fixture-account"; + +// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock. +function twoHoursAgo(): string { + return new Date(Date.now() - 2 * 60 * 60_000).toISOString(); +} + +function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry { + return { + id: REPORT_ID, + account: { label: "Fixture account" }, + fetchedAt, + sourceId: "fixture", + sourceLabel: "Fixture plan", + icon: '', + report: { + status: "available", + planLabel: "Test plan", + windows: [ + { id: "session", label: "Session", usedPct: 3 }, + { id: "weekly", label: "Weekly", usedPct, headline: true }, + ], + }, + }; +} + +async function openMockAgent(page: Page) { + const session = await seedMockAgentWorkspace({ + repoPrefix: "usage-composer-pill-", + title: "Usage composer pill e2e", + initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.", + }); + await openAgentRoute(page, session); + await expectComposerVisible(page); + return session; +} + +test.describe("usage composer pill", () => { + test("opens the cached report without a request and shows when it was fetched", async ({ + page, + }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { + agentReportIds: [REPORT_ID], + lists: [[agentEntry(42, twoHoursAgo())]], + }); + const session = await openMockAgent(page); + try { + const pill = page.getByTestId("usage-composer-pill"); + await expect(pill).toContainText("42%", { timeout: 30_000 }); + expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId }); + expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]); + + await pill.click(); + const popover = page.getByTestId("usage-composer-popover"); + await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible(); + await expect(popover.getByText("Test plan")).toBeVisible(); + await expect(popover.getByText("42%")).toBeVisible(); + + await popover.getByTestId("usage-refresh").hover(); + await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago"); + await expect(popover.getByTestId("usage-freshness")).toHaveCount(0); + expect(usage.listRequests()).toHaveLength(1); + } finally { + await session.cleanup(); + } + }); + + test("refreshes only its report from the popover", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { + agentReportIds: [REPORT_ID], + lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]], + }); + const session = await openMockAgent(page); + try { + const pill = page.getByTestId("usage-composer-pill"); + await expect(pill).toContainText("42%", { timeout: 30_000 }); + await pill.click(); + const popover = page.getByTestId("usage-composer-popover"); + await expect(popover.getByText("42%")).toBeVisible(); + + await popover.getByTestId("usage-refresh").click(); + await usage.waitForListRequests(2); + expect(usage.listRequests()).toEqual([ + { forceRefresh: false, reportIds: [REPORT_ID] }, + { forceRefresh: true, reportIds: [REPORT_ID] }, + ]); + await expect(popover.getByText("64%")).toBeVisible(); + await expect(pill).toContainText("64%"); + await expect(popover.getByTestId("usage-refresh")).toBeEnabled(); + + // Pressing closes the tooltip; it reopens on the next hover. + await popover.getByText("Fixture plan", { exact: true }).hover(); + await popover.getByTestId("usage-refresh").hover(); + await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now"); + expect(usage.listRequests()).toHaveLength(2); + } finally { + await session.cleanup(); + } + }); + + test("keeps the previous report and says so when a refresh fails", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { + agentReportIds: [REPORT_ID], + lists: [ + [agentEntry(42, twoHoursAgo())], + { error: "Usage source timed out" }, + () => [agentEntry(64, new Date().toISOString())], + ], + }); + const session = await openMockAgent(page); + try { + const pill = page.getByTestId("usage-composer-pill"); + await expect(pill).toContainText("42%", { timeout: 30_000 }); + await pill.click(); + const popover = page.getByTestId("usage-composer-popover"); + const refresh = popover.getByTestId("usage-refresh"); + + await refresh.click(); + await usage.waitForListRequests(2); + await expect(popover.getByTestId("usage-refresh-error")).toHaveText( + "Unable to refresh usage", + ); + await expect(popover.getByText("42%")).toBeVisible(); + await expect(pill).toContainText("42%"); + + await refresh.click(); + await usage.waitForListRequests(3); + await expect(popover.getByText("64%")).toBeVisible(); + await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0); + } finally { + await session.cleanup(); + } + }); + + test("prints the freshness on the card where there is no hover", async ({ page }) => { + test.setTimeout(180_000); + await page.setViewportSize({ width: 390, height: 844 }); + const usage = await installUsageReportsFixture(page, { + agentReportIds: [REPORT_ID], + lists: [[agentEntry(42, twoHoursAgo())]], + }); + const session = await openMockAgent(page); + try { + const pill = page.getByTestId("usage-composer-pill"); + await expect(pill).toContainText("42%", { timeout: 30_000 }); + await pill.click(); + const popover = page.getByTestId("usage-composer-popover"); + await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago"); + expect(usage.listRequests()).toHaveLength(1); + } finally { + await session.cleanup(); + } + }); + + test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { + agentReportIds: [REPORT_ID], + lists: [[agentEntry(42)]], + }); + const session = await openMockAgent(page); + try { + const pill = page.getByTestId("usage-composer-pill"); + await expect(pill).toContainText("42%", { timeout: 30_000 }); + const requestsBeforeTurn = usage.agentRequests().length; + const listRequestsBeforeTurn = usage.listRequests().length; + + await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill."); + + await usage.waitForAgentRequests(requestsBeforeTurn + 1); + expect(usage.agentRequests().at(-1)).toEqual({ + agentId: session.agentId, + }); + await expect(pill).toContainText("42%"); + expect(usage.listRequests()).toHaveLength(listRequestsBeforeTurn); + } finally { + await session.cleanup(); + } + }); + + test("is hidden when the agent has no usage report", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { agentReportIds: [null] }); + const session = await openMockAgent(page); + try { + await usage.waitForAgentRequests(1); + await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0); + expect(usage.listRequests()).toHaveLength(0); + } finally { + await session.cleanup(); + } + }); + + test("is hidden on a host without usage sources", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { + usageSources: false, + agentReportIds: [REPORT_ID], + lists: [[agentEntry(42)]], + }); + const session = await openMockAgent(page); + try { + await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0); + expect(usage.agentRequests()).toHaveLength(0); + } finally { + await session.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts new file mode 100644 index 0000000000..70d2ae10b8 --- /dev/null +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -0,0 +1,156 @@ +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; + +// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock. +function twoHoursAgo(): string { + return new Date(Date.now() - 2 * 60 * 60_000).toISOString(); +} + +test.describe("usage screen", () => { + test("opens from the sidebar and groups reports under their host", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usage = await installUsageReportsFixture(page, { + lists: [ + [ + { + id: "alpha:a", + account: {}, + fetchedAt: twoHoursAgo(), + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }], + }, + }, + { + id: "beta:b", + account: {}, + fetchedAt: "2026-01-01T00:00:00.000Z", + sourceId: "beta", + sourceLabel: "Beta plan", + report: { status: "unavailable", windows: [] }, + }, + ], + ], + }); + + await gotoAppShell(page); + await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); + await expect(page).toHaveURL(/\/usage$/); + await usage.waitForListRequests(1); + + const group = page.getByTestId(`usage-host-${serverId}`); + await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(group.getByText("31%")).toBeVisible(); + await expect(group.getByText("Beta plan", { exact: true })).toBeVisible(); + await expect(group.getByText("Unavailable", { exact: true })).toBeVisible(); + + await group.getByTestId("usage-refresh").first().hover(); + await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago"); + await expect(group.getByTestId("usage-freshness")).toHaveCount(0); + }); + + test("refreshes one report from its card", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const beta: UsageReportEntry = { + id: "beta:b", + account: {}, + fetchedAt: twoHoursAgo(), + sourceId: "beta", + sourceLabel: "Beta plan", + report: { + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }], + }, + }; + const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({ + id: "alpha:a", + account: {}, + fetchedAt, + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }], + }, + }); + const usage = await installUsageReportsFixture(page, { + lists: [ + [alpha(31, twoHoursAgo()), beta], + () => [ + alpha(58, new Date().toISOString()), + { ...beta, fetchedAt: new Date().toISOString() }, + ], + ], + }); + + await gotoAppShell(page); + await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); + const group = page.getByTestId(`usage-host-${serverId}`); + await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 }); + + const alphaRefresh = group.getByTestId("usage-refresh").first(); + await alphaRefresh.click(); + await usage.waitForListRequests(2); + expect(usage.listRequests()).toEqual([ + { forceRefresh: false, reportIds: undefined }, + { forceRefresh: true, reportIds: ["alpha:a"] }, + ]); + await expect(group.getByText("58%")).toBeVisible(); + await expect(group.getByText("12%")).toBeVisible(); + + await group.getByTestId("usage-refresh").nth(1).hover(); + await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago"); + await alphaRefresh.hover(); + await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now"); + }); + + test("shows the host once it connects after a cold load on a phone", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usage = await installUsageReportsFixture(page, { + lists: [ + [ + { + id: "alpha:a", + account: {}, + fetchedAt: twoHoursAgo(), + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { status: "available", windows: [] }, + }, + ], + ], + }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/usage"); + await usage.waitForListRequests(1); + + const group = page.getByTestId(`usage-host-${serverId}`); + await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago"); + }); + + test("tells the user to update a host without usage sources", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usage = await installUsageReportsFixture(page, { usageSources: false }); + + await gotoAppShell(page); + await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); + + await expect( + page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", { + exact: true, + }), + ).toBeVisible({ timeout: 10_000 }); + expect(usage.listRequests()).toHaveLength(0); + }); +}); diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts new file mode 100644 index 0000000000..623bc51e67 --- /dev/null +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -0,0 +1,136 @@ +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { openSettingsHostSection } from "../support/helpers/settings"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; + +const ICON = ''; + +function report(input: { + sourceId: string; + sourceLabel: string; + report: Partial; +}): UsageReportEntry { + return { + id: `${input.sourceId}:account`, + account: { label: input.sourceId === "alpha" ? "dev@example.com" : undefined }, + fetchedAt: "2026-01-01T00:00:00.000Z", + sourceId: input.sourceId, + sourceLabel: input.sourceLabel, + icon: ICON, + report: { + status: "available", + windows: [], + ...input.report, + }, + }; +} + +test.describe("usage settings", () => { + test("renders every report returned by usage.list_reports", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usage = await installUsageReportsFixture(page, { + lists: [ + [ + report({ + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { + planLabel: "Max", + windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }], + }, + }), + report({ + sourceId: "beta", + sourceLabel: "Beta plan", + report: { + planLabel: "Coding plan", + 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" }], + }, + }), + report({ + sourceId: "gamma", + sourceLabel: "Gamma plan", + report: { status: "error", error: "Gamma auth expired" }, + }), + ], + ], + }); + + await gotoAppShell(page); + await openSettings(page); + expect(usage.listRequests()).toHaveLength(0); + await openSettingsHostSection(page, serverId, "usage"); + await usage.waitForListRequests(1); + + const card = page.getByTestId("usage-card"); + await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible(); + await expect(card.getByText("Beta plan", { exact: true })).toBeVisible(); + await expect(card.getByText("70%")).toBeVisible(); + await expect(card.getByText("1,234 left", { exact: true })).toBeVisible(); + await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible(); + await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); + await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible(); + }); + + test("refresh forces a fresh report", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }]; + const usage = await installUsageReportsFixture(page, { + lists: [ + [ + report({ + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { windows: windows(23) }, + }), + ], + [ + report({ + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { windows: windows(64) }, + }), + ], + ], + }); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHostSection(page, serverId, "usage"); + await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 }); + + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await usage.waitForListRequests(2); + + expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true }); + await expect(page.getByText("64%")).toBeVisible(); + }); + + test("asks to update a host without usage sources and never calls it", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usage = await installUsageReportsFixture(page, { usageSources: false }); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHostSection(page, serverId, "usage"); + + await expect( + page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }), + ).toBeVisible({ timeout: 10_000 }); + expect(usage.listRequests()).toHaveLength(0); + }); +}); diff --git a/packages/app/e2e/support/helpers/provider-usage.ts b/packages/app/e2e/support/helpers/provider-usage.ts deleted file mode 100644 index 9ce3bc8849..0000000000 --- a/packages/app/e2e/support/helpers/provider-usage.ts +++ /dev/null @@ -1,154 +0,0 @@ -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/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index ecfa2a4b09..1702f7fe44 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -74,7 +74,7 @@ export async function openSettingsHostSection( } export async function expectSettingsHeader(page: Page, title: string): Promise { - await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title); + await expect(page.getByTestId("page-title")).toHaveText(title); } export async function openAddHostFlow(page: Page): Promise { diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts new file mode 100644 index 0000000000..6a43d0fe30 --- /dev/null +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -0,0 +1,178 @@ +import type { Page } from "@playwright/test"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { daemonWsRoutePattern } from "./daemon-port"; + +export interface UsageReportsFixture { + listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>; + agentRequests(): Array<{ agentId: string }>; + waitForListRequests(count: number): Promise; + waitForAgentRequests(count: number): Promise; +} + +interface UsageReportsFixtureOptions { + /** + * Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that + * request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`). + */ + lists?: Array UsageListResponse)>; + /** Successive agent report IDs; the last one repeats. */ + agentReportIds?: Array; + /** Advertise `features.usageSources`. False simulates a host from before usage sources. */ + usageSources?: boolean; +} + +type UsageListResponse = UsageReportEntry[] | { error: string }; + +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) as { type?: unknown; message?: unknown } | null; + if (!envelope || envelope.type !== "session" || typeof envelope.message !== "object") { + return null; + } + return envelope.message as Record; +} + +function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null { + const envelope = parseJson(message) as { + type?: unknown; + message?: { type?: unknown; payload?: Record }; + } | null; + const payload = envelope?.message?.payload; + if ( + envelope?.type !== "session" || + envelope.message?.type !== "status" || + payload?.status !== "server_info" + ) { + return null; + } + const features = + typeof payload.features === "object" && payload.features !== null ? payload.features : {}; + return JSON.stringify({ + ...envelope, + message: { + ...envelope.message, + payload: { ...payload, features: { ...features, usageSources: enabled } }, + }, + }); +} + +function pick(values: T[], index: number): T { + const value = values[Math.min(index, values.length - 1)]; + if (value === undefined) throw new Error("Usage fixture requires at least one response."); + return value; +} + +function createCounter() { + let count = 0; + const waiters: Array<{ count: number; resolve: () => void }> = []; + return { + increment() { + count += 1; + for (const waiter of waiters.splice(0)) { + if (count >= waiter.count) waiter.resolve(); + else waiters.push(waiter); + } + }, + waitFor(target: number): Promise { + if (count >= target) return Promise.resolve(); + return new Promise((resolve) => waiters.push({ count: target, resolve })); + }, + }; +} + +export async function installUsageReportsFixture( + page: Page, + options: UsageReportsFixtureOptions, +): Promise { + const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = []; + const agentRequests: Array<{ agentId: string }> = []; + const listCounter = createCounter(); + const agentCounter = createCounter(); + const usageSources = options.usageSources ?? true; + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((message) => { + const request = getSessionMessage(message); + const requestId = request?.requestId; + if (request?.type === "usage.list_reports.request" && typeof requestId === "string") { + listRequests.push({ + forceRefresh: request.forceRefresh === true, + reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined, + }); + const scripted = pick(options.lists ?? [[]], listRequests.length - 1); + const response = typeof scripted === "function" ? scripted() : scripted; + if ("error" in response) { + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "rpc_error", + payload: { + requestId, + requestType: "usage.list_reports.request", + error: response.error, + code: "transport", + }, + }, + }), + ); + listCounter.increment(); + return; + } + const ids = Array.isArray(request.reportIds) ? request.reportIds : null; + const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response; + ws.send( + JSON.stringify({ + type: "session", + message: { type: "usage.list_reports.response", payload: { requestId, reports } }, + }), + ); + listCounter.increment(); + return; + } + if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") { + agentRequests.push({ + agentId: String(request.agentId), + }); + const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1); + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "agent.resolve_usage_report.response", + payload: { requestId, reportId }, + }, + }), + ); + agentCounter.increment(); + return; + } + server.send(message); + }); + + server.onMessage((message) => { + const serverInfo = + typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null; + ws.send(serverInfo ?? message); + }); + }); + + return { + listRequests: () => [...listRequests], + agentRequests: () => [...agentRequests], + waitForListRequests: (count) => listCounter.waitFor(count), + waitForAgentRequests: (count) => agentCounter.waitFor(count), + }; +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 59b198bdf5..cf682e7611 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -860,6 +860,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) { pathname === "/new" || pathname === "/sessions" || pathname === "/schedules" || + pathname === "/usage" || routeHasKnownHost); return {children}; @@ -892,6 +893,7 @@ function RootStack() { + diff --git a/packages/app/src/app/usage.tsx b/packages/app/src/app/usage.tsx new file mode 100644 index 0000000000..2928cca3f3 --- /dev/null +++ b/packages/app/src/app/usage.tsx @@ -0,0 +1,10 @@ +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { UsageScreen } from "@/usage"; + +export default function UsageRoute() { + return ( + + + + ); +} diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 6fe837562e..e587123f19 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,11 +1,8 @@ -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 { @@ -13,9 +10,6 @@ interface ContextWindowMeterProps { usedTokens: number | null; totalCostUsd?: number | null; showPercentage?: boolean; - serverId?: string; - /** The Paseo provider key, e.g. "claude", "gemini", "codex" */ - provider?: string | null; /** Reserve the meter footprint and show a loading ring while usage is pending. */ pending?: boolean; /** Optional glyph envelope for icon-toolbar alignment. */ @@ -101,30 +95,13 @@ export function ContextWindowMeter({ usedTokens, totalCostUsd, showPercentage = false, - serverId, - provider, pending = false, glyphSize, }: ContextWindowMeterProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); - const [isTooltipOpen, setIsTooltipOpen] = useState(false); - const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage( - serverId ?? null, - { enabled: isTooltipOpen }, - ); const percentage = maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; - const handleTooltipOpenChange = useCallback( - (nextOpen: boolean) => { - setIsTooltipOpen(nextOpen); - if (nextOpen) { - void refreshProviderUsage().catch(() => {}); - } - }, - [refreshProviderUsage], - ); - const geometry = getMeterGeometry(showPercentage, glyphSize); // No usage yet: reserve the footprint with a track-only ring while a session is @@ -167,13 +144,7 @@ export function ContextWindowMeter({ typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; return ( - + ) : null} - diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 1cdfe83e6d..4902a3ae93 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,5 @@ import { router } from "expo-router"; -import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native"; +import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -47,7 +47,11 @@ import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; +import { + buildSettingsAddHostRoute, + buildSettingsRoute, + buildUsageRoute, +} from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; @@ -426,6 +430,25 @@ function SidebarHostPicker({ ); } +function SidebarUsageButton({ theme }: { theme: SidebarTheme }) { + const { t } = useTranslation(); + const isCompactLayout = useIsCompactFormFactor(); + const showMobileAgent = usePanelStore((state) => state.showMobileAgent); + const handlePress = useCallback(() => { + if (isCompactLayout) showMobileAgent(); + router.push(buildUsageRoute()); + }, [isCompactLayout, showMobileAgent]); + return ( + + ); +} + function IconTooltipContent({ label, shortcutKeys, @@ -489,6 +512,7 @@ function SidebarFooter({ icon={Import} theme={theme} /> + void; + testID?: string; + titleTestID?: string; + children: ReactNode; +} + +/** + * A full-width page with a centered content column. On desktop the title is a + * document heading inside the page and the header only keeps the titlebar drag + * region and window controls; on compact the title moves into a back header. + */ +export function PageLayout({ title, onBack, testID, titleTestID, children }: PageLayoutProps) { + const isCompact = useIsCompactFormFactor(); + const insets = useSafeAreaInsets(); + const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]); + const showTitle = !isCompact && title !== undefined; + + return ( + + {isCompact ? : } + + + {showTitle ? ( + + + {title} + + + ) : null} + {children} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + scroll: { + flex: 1, + }, + content: { + padding: theme.spacing[4], + paddingTop: theme.spacing[6], + width: "100%", + maxWidth: 720, + alignSelf: "center", + }, + title: { + // Line up with the section titles, which sit inset from their cards. + marginLeft: theme.spacing[1], + marginBottom: theme.spacing[6], + fontSize: theme.fontSize["4xl"], + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 6671c95d4d..a66eaafa1d 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -36,14 +36,14 @@ const CATALOG_ICON_SVGS = new Map( const catalogIconComponents = new Map(); const snapshotIconComponents = new Map(); +/** Renders a sanitized SVG string from a host catalog, tinted through `currentColor`. */ +export function SvgIcon({ svg, size, color }: ProviderIconProps & { svg: string }) { + return createElement(SvgXml, { xml: svg, width: size, height: size, color }); +} + function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent { const SvgProviderIcon: ProviderIconComponent = ({ size, color }) => - createElement(SvgXml, { - xml: iconSvg, - width: size, - height: size, - color, - }); + SvgIcon({ svg: iconSvg, size, color }); SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`; return SvgProviderIcon; } diff --git a/packages/app/src/components/ui/pane-content-toolbar.tsx b/packages/app/src/components/ui/pane-content-toolbar.tsx index b2b630f939..03634670d1 100644 --- a/packages/app/src/components/ui/pane-content-toolbar.tsx +++ b/packages/app/src/components/ui/pane-content-toolbar.tsx @@ -56,6 +56,8 @@ interface ToolbarButtonCommonProps extends Omit< > { children: ReactNode; label: string; + /** What the tooltip says when it is not the label, e.g. a status. Defaults to the label. */ + tooltip?: ReactNode; selected?: boolean; compact?: boolean; shortcut?: ShortcutKey[][] | null; @@ -76,6 +78,7 @@ type ToolbarButtonProps = ToolbarButtonCommonProps & export function ToolbarButton({ children, label, + tooltip: tooltipBody, selected = false, compact = false, shortcut, @@ -105,7 +108,7 @@ export function ToolbarButton({ const tooltip = ( - {label} + {tooltipBody ?? {label}} {shortcut ? : null} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index a8bdec9ede..6380edc856 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -48,6 +48,7 @@ import { type DraftAgentControlsProps, } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; +import { UsageComposerPill } from "@/usage"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; @@ -274,7 +275,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) { contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null, totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null, model: agent?.model ?? null, - provider: agent?.provider ?? null, }; }; } @@ -284,8 +284,6 @@ function renderContextWindowMeter( contextWindowUsedTokens: number | null, totalCostUsd: number | null, showPercentage: boolean, - serverId: string, - provider: string | null, pending: boolean, glyphSize: number, ): ReactElement | null { @@ -299,8 +297,6 @@ function renderContextWindowMeter( usedTokens={contextWindowUsedTokens} totalCostUsd={totalCostUsd} showPercentage={showPercentage} - serverId={serverId} - provider={provider} pending={pending} glyphSize={glyphSize} /> @@ -2085,8 +2081,6 @@ function ComposerContentImpl({ contextWindowUsedTokens, agentState.totalCostUsd, false, - serverId, - agentState.provider, contextWindowPending, contextWindowMeterGlyphSize, ), @@ -2094,15 +2088,18 @@ function ComposerContentImpl({ contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, - serverId, - agentState.provider, contextWindowPending, contextWindowMeterGlyphSize, ], ); const beforeVoiceContent = useMemo( - () => resolveContextWindowPlacement(contextWindowMeter, hasAgent), - [contextWindowMeter, hasAgent], + () => ( + <> + {resolveContextWindowPlacement(contextWindowMeter, hasAgent)} + {hasAgent ? : null} + + ), + [agentId, contextWindowMeter, hasAgent, serverId], ); const hasGithubAttachment = useMemo( diff --git a/packages/app/src/provider-usage/card.tsx b/packages/app/src/provider-usage/card.tsx deleted file mode 100644 index 10ef161fcc..0000000000 --- a/packages/app/src/provider-usage/card.tsx +++ /dev/null @@ -1,198 +0,0 @@ -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 mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -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: { - gap: theme.spacing[4], - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - containerCompact: { - gap: theme.spacing[3], - }, - 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.sm, - }, - bars: { - gap: theme.spacing[3], - }, - 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.sm, - }, - detailValue: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - }, - error: { - color: theme.colors.palette.red[300], - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, - footer: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, -})); diff --git a/packages/app/src/provider-usage/copy.ts b/packages/app/src/provider-usage/copy.ts deleted file mode 100644 index 1dcc817ef5..0000000000 --- a/packages/app/src/provider-usage/copy.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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/settings-section.tsx b/packages/app/src/provider-usage/settings-section.tsx deleted file mode 100644 index 6cb6ff68dd..0000000000 --- a/packages/app/src/provider-usage/settings-section.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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 "@/components/settings/headings/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.base, - }, -})); diff --git a/packages/app/src/provider-usage/tooltip-section.tsx b/packages/app/src/provider-usage/tooltip-section.tsx deleted file mode 100644 index 6a5a896301..0000000000 --- a/packages/app/src/provider-usage/tooltip-section.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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: 1, - // Same token the popover draws its own outline with, so the rule reads as the - // popover's edge. `border` is invisible here (equals the popover background). - backgroundColor: theme.colors.borderAccent, - marginVertical: theme.spacing[2], - // Cancel the tooltip content's horizontal padding so the rule spans edge to edge. - marginHorizontal: -theme.spacing[2], - }, - detail: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, - error: { - color: theme.colors.palette.red[300], - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, -})); diff --git a/packages/app/src/provider-usage/types.ts b/packages/app/src/provider-usage/types.ts deleted file mode 100644 index 52ff7c31f0..0000000000 --- a/packages/app/src/provider-usage/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index ce133726c3..0000000000 --- a/packages/app/src/provider-usage/use-provider-usage.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 () => { - if (!canFetch) return; - await queryClient.invalidateQueries({ queryKey }); - 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/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 83cc37e903..2cea05144d 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -2653,21 +2653,22 @@ export function useHostRuntimeConnectionStatuses( serverIds: readonly string[], ): ReadonlyMap { const store = getHostRuntimeStore(); - const version = useSyncExternalStore( + // The snapshot is the statuses themselves, joined into a string so React compares by + // value. A version counter read only for reactivity is dropped by the React Compiler. + const readStatuses = () => + serverIds + .map((serverId) => store.getSnapshot(serverId)?.connectionStatus ?? "connecting") + .join("\n"); + const statuses = useSyncExternalStore( (onStoreChange) => store.subscribeAll(onStoreChange), - () => store.getVersion(), - () => store.getVersion(), + readStatuses, + readStatuses, ); return useMemo(() => { - // The aggregate version is the reactivity trigger; re-read snapshots on every host tick. - void version; - const entries: Array<[string, HostRuntimeConnectionStatus]> = serverIds.map((serverId) => [ - serverId, - store.getSnapshot(serverId)?.connectionStatus ?? "connecting", - ]); - return new Map(entries); - }, [serverIds, store, version]); + const values = statuses.split("\n") as HostRuntimeConnectionStatus[]; + return new Map(serverIds.map((serverId, index) => [serverId, values[index]])); + }, [serverIds, statuses]); } export function useHostRuntimeLastError(serverId: string): string | null { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 913be879b2..dc1a7c46d6 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -71,7 +71,7 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; import { confirmDialog } from "@/utils/confirm-dialog"; import { BackHeader } from "@/components/headers/back-header"; -import { ScreenHeader } from "@/components/headers/screen-header"; +import { PageLayout } from "@/components/page-layout"; import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal"; @@ -1305,30 +1305,25 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti }, []); const installedPlugins = useInstalledPlugins(); - const detailHeader = ((): { - title: string; - titleAccessory?: ReactNode; - } | null => { + const detailTitle = ((): string | undefined => { if (view.kind === "plugin") { const screen = installedPlugins .find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId) ?.settingsScreens.find((candidate) => candidate.id === view.screenId); - return { title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}` }; + return `${view.pluginId} · ${screen?.title ?? t("settings.title")}`; } if (view.kind === "host") { const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); - if (!item) return null; - return { title: t(item.labelKey) }; + return item ? t(item.labelKey) : undefined; } if (view.kind === "section") { const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section); - if (!item) return null; - return { title: t(item.labelKey) }; + return item ? t(item.labelKey) : undefined; } if (view.kind === "project") { - return { title: t("settings.projects") }; + return t("settings.projects"); } - return null; + return undefined; })(); const content: ReactNode = (() => { @@ -1399,15 +1394,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti ); } - const desktopPageTitle = detailHeader ? ( - - - {detailHeader.title} - - {detailHeader.titleAccessory} - - ) : null; - const addHostModals = ( <> - - - {content} - + + {content} + {addHostModals} ); @@ -1497,15 +1478,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti - {/* Keeps the titlebar drag region and window controls; the page - title lives in the content, like a document heading. */} - - - - {desktopPageTitle} - {content} - - + + {content} + @@ -1536,26 +1511,6 @@ const styles = StyleSheet.create((theme) => ({ scrollView: { flex: 1, }, - content: { - padding: theme.spacing[4], - paddingTop: theme.spacing[6], - width: "100%", - maxWidth: 720, - alignSelf: "center", - }, - pageTitleRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - // Line up with the section titles, which sit inset from their cards. - marginLeft: theme.spacing[1], - marginBottom: theme.spacing[6], - }, - pageTitle: { - fontSize: theme.fontSize["4xl"], - fontWeight: theme.fontWeight.medium, - color: theme.colors.foreground, - }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 5a9fd26b68..f7b27f820f 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -50,8 +50,7 @@ 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 { HostUsageSection } from "@/usage"; import { HostAppearanceSection } from "@/screens/settings/host-appearance-section"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useSessionStore } from "@/stores/session-store"; @@ -343,10 +342,6 @@ 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 ; @@ -354,7 +349,7 @@ export function HostUsagePage({ serverId }: { serverId: string }) { return ( - + ); } diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 286030fd86..124c76d812 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -69,6 +69,7 @@ import { RenderProfile } from "@/utils/render-profiler"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago"; +import { formatCompactTimeAgoAsProse } from "@/utils/time"; import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu"; @@ -148,12 +149,6 @@ function formatAgentTooltipTitle(singleLineTitle: string): string { return `${singleLineTitle.slice(0, AGENT_TOOLTIP_TITLE_MAX_LENGTH - 1).trimEnd()}…`; } -function formatAgentTooltipActivity(compactActivity: string): string { - if (compactActivity === "now") return "just now"; - if (/^\d/.test(compactActivity)) return `${compactActivity} ago`; - return compactActivity; -} - function AgentTabTooltipBody({ serverId, agentId, @@ -169,7 +164,7 @@ function AgentTabTooltipBody({ return state.agentLastActivity.get(agentId) ?? agent?.lastActivityAt ?? null; }); const compactActivity = useCompactTimeAgo(lastActivityAt); - const activity = formatAgentTooltipActivity(compactActivity); + const activity = formatCompactTimeAgoAsProse(compactActivity); return ( diff --git a/packages/app/src/provider-usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx similarity index 91% rename from packages/app/src/provider-usage/balance-bar.tsx rename to packages/app/src/usage/balance-bar.tsx index dbf18bea87..8bf3ad9d83 100644 --- a/packages/app/src/provider-usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -2,14 +2,14 @@ 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"; +import type { UsageBalance, UsageTone } from "./types"; interface ResolvedBalance { amountText: string; usedPct: number | null; } -function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance { +function resolveBalance(balance: UsageBalance): ResolvedBalance { const { used, remaining, limit, unit } = balance; if (limit != null && limit > 0) { const usedAmount = used ?? (remaining != null ? limit - remaining : null); @@ -26,7 +26,7 @@ function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance { return { amountText: "—", usedPct: null }; } -function fillToneStyle(tone: ProviderUsageTone) { +function fillToneStyle(tone: UsageTone) { switch (tone) { case "ok": return styles.fillOk; @@ -39,7 +39,7 @@ function fillToneStyle(tone: ProviderUsageTone) { } } -export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) { +export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { const { amountText, usedPct } = resolveBalance(balance); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx new file mode 100644 index 0000000000..605dd76cfd --- /dev/null +++ b/packages/app/src/usage/card.tsx @@ -0,0 +1,308 @@ +import { RefreshCw } from "lucide-react-native"; +import { useMemo } from "react"; +import { Text, View, type StyleProp, type TextStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + iconButtonChromeGlyphSize, + mutedIconColorMapping, + smallIconButtonChromeFrameSize, +} from "@/components/ui/icon-button-chrome"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ToolbarButton } from "@/components/ui/pane-content-toolbar"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago"; +import { UsageBalanceBar } from "./balance-bar"; +import { usageCopy } from "./copy"; +import { formatUsageFreshness, type UsageRefresh } from "./model"; +import { useReportRefresh } from "./queries"; +import { UsageSourceIcon } from "./source-icon"; +import type { UsageReport, UsageReportEntry } from "./types"; +import { UsageWindowBar } from "./window-bar"; + +function statusText(report: UsageReport): string | null { + if (report.status === "available") return null; + return report.status === "error" ? "Error" : "Unavailable"; +} + +const ThemedRefreshIcon = withUnistyles(RefreshCw); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); + +export function UsageCard({ + serverId, + entry, + compact = false, +}: { + serverId: string; + entry: UsageReportEntry; + compact?: boolean; +}) { + const isCompact = useIsCompactFormFactor(); + const { refresh, refreshState } = useReportRefresh(serverId, entry.id); + // Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip. + const showsFreshnessInline = isNative || isCompact; + const usage = entry.report; + const status = statusText(usage); + const footer = entry.account.label ?? null; + 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 ( + + + + + {entry.sourceLabel} + + {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 || showsFreshnessInline ? ( + + + {footer} + + {showsFreshnessInline ? ( + + ) : null} + + ) : null} + + {refreshState === "failed" ? ( + + {usageCopy.refreshFailed} + + ) : null} + + ); +} + +/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */ +function UsageRefreshButton({ + sourceLabel, + fetchedAt, + refreshState, + onRefresh, + compact, +}: { + sourceLabel: string; + fetchedAt: string; + refreshState: UsageRefresh; + onRefresh: () => void; + compact: boolean; +}) { + const isPending = refreshState === "pending"; + const iconSize = iconButtonChromeGlyphSize("small", compact); + const freshness = useMemo( + () => ( + + ), + [fetchedAt], + ); + return ( + + {isPending ? ( + + ) : ( + + )} + + ); +} + +/** Its own component so the relative-time clock re-renders one ``, not the card. */ +function UsageFreshness({ + fetchedAt, + style, + testID, +}: { + fetchedAt: string; + style: StyleProp; + testID: string; +}) { + const elapsed = useCompactTimeAgo(new Date(fetchedAt)); + return ( + + {formatUsageFreshness(elapsed)} + + ); +} + +// The Refresh glyph lands on the card's right rail and the header keeps its text height; +// the button's larger hitbox overhangs both instead of pushing them. +function iconHitboxOverhang(compact: boolean) { + const overhang = + (smallIconButtonChromeFrameSize(compact) - iconButtonChromeGlyphSize("small", compact)) / 2; + return { marginRight: -overhang, marginVertical: -overhang }; +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[3], + }, + containerPadded: { + gap: theme.spacing[4], + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + }, + containerCompact: { + gap: theme.spacing[3], + }, + 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.sm, + }, + bars: { + gap: theme.spacing[3], + }, + 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.sm, + }, + detailValue: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + error: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, + }, + footerRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + footer: { + flex: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + freshness: { + flexShrink: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + tooltipText: { + color: theme.colors.popoverForeground, + fontSize: theme.fontSize.sm, + }, + refreshButton: iconHitboxOverhang(false), + refreshButtonCompact: iconHitboxOverhang(true), +})); diff --git a/packages/app/src/usage/composer-pill.tsx b/packages/app/src/usage/composer-pill.tsx new file mode 100644 index 0000000000..26cd77793c --- /dev/null +++ b/packages/app/src/usage/composer-pill.tsx @@ -0,0 +1,82 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { MenuTriggerState } from "@/components/ui/menu"; +import { + isToolbarLabelTriggerHighlighted, + toolbarLabelTriggerStyle, + toolbarLabelTriggerTextStyle, +} from "@/components/ui/toolbar-label-trigger"; +import { UsageCard } from "./card"; +import { usageCopy } from "./copy"; +import type { UsagePill } from "./model"; +import { useAgentUsage } from "./queries"; +import { UsageSourceIcon } from "./source-icon"; + +function pillTriggerStyle(state: MenuTriggerState) { + return [toolbarLabelTriggerStyle(state), styles.trigger]; +} + +/** + * The agent's plan usage beside the composer controls. Opens the cached usage card; + * only the card's Refresh asks the source for a new report. + */ +export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) { + const { pill, entry } = useAgentUsage(serverId, agentId); + + if (!pill || !entry) return null; + + return ( + + + {(state) => ( + + )} + + + + + + + + ); +} + +function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) { + return ( + <> + + {pill.text ? ( + + {pill.text} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + height: 28, + flexShrink: 0, + }, + popover: { + padding: theme.spacing[3], + }, +})); diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts new file mode 100644 index 0000000000..ba0c8b0a46 --- /dev/null +++ b/packages/app/src/usage/copy.ts @@ -0,0 +1,18 @@ +// User-facing copy for the usage surfaces, kept in one file so localization is a +// single-file change. +export const usageCopy = { + title: "Usage", + planUsage: "Plan usage", + refresh: "Refresh", + refreshing: "Refreshing...", + refreshFailed: "Unable to refresh usage", + updated: "Updated", + loading: "Loading usage...", + empty: "No usage data", + noHosts: "No connected hosts", + errorTitle: "Unable to load usage", + hostUnavailable: "Connect to this host to see usage", + hostUpgradeRequired: "Update the host to see usage", + clientUnavailable: "Host connection is not ready", + retry: "Try again", +} as const; diff --git a/packages/app/src/provider-usage/format.ts b/packages/app/src/usage/format.ts similarity index 63% rename from packages/app/src/provider-usage/format.ts rename to packages/app/src/usage/format.ts index d903818f48..ac8bffefd0 100644 --- a/packages/app/src/provider-usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,5 +1,5 @@ import { formatTokenCount } from "@/components/context-window-meter.utils"; -import type { ProviderUsageBalanceUnit } from "./types"; +import type { UsageBalanceUnit } from "./types"; export function clampPct(value: number): number { return Math.max(0, Math.min(100, value)); @@ -28,20 +28,7 @@ export function formatResetLabel(iso: string | null | undefined): string | 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 { +export function formatAmount(value: number, unit: UsageBalanceUnit): string { switch (unit) { case "usd": return `$${value.toFixed(2)}`; diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx new file mode 100644 index 0000000000..54dd4ef939 --- /dev/null +++ b/packages/app/src/usage/host-usage-section.tsx @@ -0,0 +1,17 @@ +import { usageCopy } from "./copy"; +import { useHostUsage } from "./queries"; +import { UsageSection } from "./usage-section"; + +/** A host's usage reports, for its settings page. */ +export function HostUsageSection({ serverId }: { serverId: string }) { + const { view, refresh } = useHostUsage(serverId); + return ( + + ); +} diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts new file mode 100644 index 0000000000..54f745a04d --- /dev/null +++ b/packages/app/src/usage/index.ts @@ -0,0 +1,3 @@ +export { UsageComposerPill } from "./composer-pill"; +export { HostUsageSection } from "./host-usage-section"; +export { UsageScreen } from "./usage-screen"; diff --git a/packages/app/src/provider-usage/list.tsx b/packages/app/src/usage/list.tsx similarity index 58% rename from packages/app/src/provider-usage/list.tsx rename to packages/app/src/usage/list.tsx index c9ff6742b2..91aa4d4b50 100644 --- a/packages/app/src/provider-usage/list.tsx +++ b/packages/app/src/usage/list.tsx @@ -2,16 +2,22 @@ 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"; +import { UsageCard } from "./card"; +import type { UsageReportEntry } from "./types"; -export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) { +export function UsageList({ + serverId, + reports, +}: { + serverId: string; + reports: UsageReportEntry[]; +}) { return ( - {providers.map((usage, index) => ( - + {reports.map((entry, index) => ( + {index > 0 ? : null} - + ))} diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts new file mode 100644 index 0000000000..54ebc4df87 --- /dev/null +++ b/packages/app/src/usage/model.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { + formatUsageFreshness, + groupUsageByHost, + replaceReport, + headlineWindow, + resolveUsagePill, + resolveUsageRefresh, + resolveUsageView, + type UsageQueryState, +} from "./model"; +import type { UsageReportEntry, UsageWindow } from "./types"; + +function entry(input: { + windows?: UsageWindow[]; + planLabel?: string; + icon?: string; + sourceId?: string; +}): UsageReportEntry { + return { + id: `${input.sourceId ?? "fixture"}:account-1`, + account: {}, + fetchedAt: "2026-01-01T00:00:00.000Z", + sourceId: input.sourceId ?? "fixture", + sourceLabel: "Fixture source", + ...(input.icon ? { icon: input.icon } : {}), + report: { + status: "available", + ...(input.planLabel ? { planLabel: input.planLabel } : {}), + windows: input.windows ?? [], + }, + }; +} + +function ready(data: UsageReportEntry[]): UsageQueryState { + return { data, error: null, isFetching: false }; +} + +describe("resolveUsagePill", () => { + it("shows the source icon and the headline window percent", () => { + const pill = resolveUsagePill({ + supportsUsage: true, + entry: entry({ + icon: "", + windows: [ + { id: "session", label: "Session", usedPct: 12 }, + { id: "weekly", label: "Weekly", usedPct: 64.6, headline: true }, + ], + }), + }); + + expect(pill).toEqual({ icon: "", sourceLabel: "Fixture source", text: "65%" }); + }); + + it("derives the headline percent from remaining percent", () => { + const pill = resolveUsagePill({ + supportsUsage: true, + entry: entry({ + windows: [{ id: "daily", label: "Daily", remainingPct: 30, headline: true }], + }), + }); + + expect(pill?.text).toBe("70%"); + }); + + it("shows the plan label when no window is the headline", () => { + const pill = resolveUsagePill({ + supportsUsage: true, + entry: entry({ + planLabel: "Pro", + windows: [{ id: "session", label: "Session", usedPct: 40 }], + }), + }); + + expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: "Pro" }); + }); + + it("shows only the icon with neither a headline window nor a plan label", () => { + const pill = resolveUsagePill({ supportsUsage: true, entry: entry({}) }); + + expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: null }); + }); + + it("is hidden when the agent has no usage report", () => { + expect(resolveUsagePill({ supportsUsage: true, entry: null })).toBeNull(); + }); + + it("is hidden on a host without usage sources, whatever data is cached", () => { + const cached = entry({ windows: [{ id: "w", label: "W", usedPct: 5, headline: true }] }); + + expect(resolveUsagePill({ supportsUsage: false, entry: cached })).toBeNull(); + }); +}); + +describe("headlineWindow", () => { + it("does not fall back to the first window", () => { + expect(headlineWindow(entry({ windows: [{ id: "a", label: "A", usedPct: 1 }] }).report)).toBe( + null, + ); + }); +}); + +describe("resolveUsageView", () => { + it("asks for a host update when the host lacks usage sources", () => { + expect( + resolveUsageView({ isConnected: true, supportsUsage: false, query: ready([entry({})]) }), + ).toEqual({ kind: "unavailable", message: "Update the host to see usage" }); + }); + + it("asks for a connection before anything else", () => { + expect( + resolveUsageView({ isConnected: false, supportsUsage: false, query: undefined }), + ).toEqual({ kind: "unavailable", message: "Connect to this host to see usage" }); + }); + + it("moves from loading to ready to error", () => { + const base = { isConnected: true, supportsUsage: true }; + expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" }); + expect( + resolveUsageView({ ...base, query: { data: [], error: null, isFetching: true } }), + ).toEqual({ kind: "ready", reports: [], isRefreshing: true }); + expect( + resolveUsageView({ + ...base, + query: { data: undefined, error: new Error("boom"), isFetching: false }, + }), + ).toEqual({ kind: "error", message: "boom" }); + }); +}); + +describe("groupUsageByHost", () => { + it("groups reports under each connected host in host order", () => { + const first = entry({ sourceId: "one" }); + const second = entry({ sourceId: "two" }); + const groups = groupUsageByHost( + [ + { serverId: "b", label: "Beta", isConnected: true, supportsUsage: true }, + { serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true }, + { serverId: "a", label: "Alpha", isConnected: true, supportsUsage: true }, + ], + new Map([ + ["a", ready([first])], + ["b", ready([second, first])], + ]), + ); + + expect(groups).toEqual([ + { + serverId: "b", + label: "Beta", + view: { kind: "ready", reports: [second, first], isRefreshing: false }, + }, + { + serverId: "a", + label: "Alpha", + view: { kind: "ready", reports: [first], isRefreshing: false }, + }, + ]); + }); + + it("shows the update message in an old host's group", () => { + const groups = groupUsageByHost( + [ + { serverId: "new", label: "New", isConnected: true, supportsUsage: true }, + { serverId: "old", label: "Old", isConnected: true, supportsUsage: false }, + ], + new Map([["new", ready([])]]), + ); + + expect(groups.map((group) => [group.label, group.view])).toEqual([ + ["New", { kind: "ready", reports: [], isRefreshing: false }], + ["Old", { kind: "unavailable", message: "Update the host to see usage" }], + ]); + }); +}); + +describe("formatUsageFreshness", () => { + it("says when the report was fetched", () => { + expect(formatUsageFreshness("now")).toBe("Updated just now"); + expect(formatUsageFreshness("3m")).toBe("Updated 3m ago"); + expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15"); + }); +}); + +describe("resolveUsageRefresh", () => { + it("is pending while a refresh runs, even after an earlier failure", () => { + expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending"); + }); + + it("is failed after a refresh errors", () => { + expect(resolveUsageRefresh({ isPending: false, error: new Error("boom") })).toBe("failed"); + }); + + it("is idle before and after a successful refresh", () => { + expect(resolveUsageRefresh({ isPending: false, error: null })).toBe("idle"); + }); +}); + +describe("replaceReport", () => { + const alpha = entry({ sourceId: "alpha", planLabel: "Old" }); + const beta = entry({ sourceId: "beta" }); + + it("swaps only the refreshed report, in place", () => { + const refreshed = entry({ sourceId: "alpha", planLabel: "New" }); + expect(replaceReport([alpha, beta], alpha.id, refreshed)).toEqual([refreshed, beta]); + }); + + it("drops a report the daemon no longer knows", () => { + expect(replaceReport([alpha, beta], alpha.id, null)).toEqual([beta]); + }); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts new file mode 100644 index 0000000000..c3cddc70fe --- /dev/null +++ b/packages/app/src/usage/model.ts @@ -0,0 +1,126 @@ +import { formatCompactTimeAgoAsProse } from "@/utils/time"; +import { usageCopy } from "./copy"; +import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types"; + +export function usedPercent(window: UsageWindow): number | null { + if (window.usedPct != null) return window.usedPct; + if (window.remainingPct != null) return 100 - window.remainingPct; + return null; +} + +/** The window the source marked as its headline. Sources own that choice; there is no fallback. */ +export function headlineWindow(report: UsageReport): UsageWindow | null { + return report.windows.find((window) => window.headline === true) ?? null; +} + +export interface UsagePill { + icon: string | null; + sourceLabel: string; + /** Headline percent, else the plan label, else nothing beside the icon. */ + text: string | null; +} + +export function resolveUsagePill(input: { + supportsUsage: boolean; + entry: UsageReportEntry | null | undefined; +}): UsagePill | null { + const { supportsUsage, entry } = input; + if (!supportsUsage || !entry) return null; + const window = headlineWindow(entry.report); + const percent = window ? usedPercent(window) : null; + return { + icon: entry.icon ?? null, + sourceLabel: entry.sourceLabel, + text: + percent != null + ? `${Math.round(Math.max(0, Math.min(100, percent)))}%` + : (entry.report.planLabel ?? null), + }; +} + +/** When a report was fetched, from its compact relative time: "Updated 3m ago". */ +export function formatUsageFreshness(compactTimeAgo: string): string { + return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; +} + +/** A user-requested refresh of one report. The previous report stays on screen throughout. */ +export type UsageRefresh = "idle" | "pending" | "failed"; + +export function resolveUsageRefresh(mutation: { + isPending: boolean; + error: unknown; +}): UsageRefresh { + if (mutation.isPending) return "pending"; + if (mutation.error) return "failed"; + return "idle"; +} + +/** + * A host's report list with one report swapped for its refreshed copy, in place. + * `null` means the daemon no longer knows the ID, so the report leaves the list. + */ +export function replaceReport( + reports: readonly UsageReportEntry[], + reportId: string, + refreshed: UsageReportEntry | null, +): UsageReportEntry[] { + if (!refreshed) return reports.filter((report) => report.id !== reportId); + return reports.map((report) => (report.id === reportId ? refreshed : report)); +} + +export interface UsageQueryState { + data: UsageReportEntry[] | undefined; + error: unknown; + isFetching: boolean; +} + +export function resolveUsageView(input: { + isConnected: boolean; + supportsUsage: boolean; + query: UsageQueryState | undefined; +}): UsageView { + const { isConnected, supportsUsage, query } = input; + if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable }; + if (!supportsUsage) return { kind: "unavailable", message: usageCopy.hostUpgradeRequired }; + if (query?.data) { + return { kind: "ready", reports: query.data, isRefreshing: query.isFetching }; + } + if (query?.error) { + return { + kind: "error", + message: query.error instanceof Error ? query.error.message : String(query.error), + }; + } + return { kind: "loading" }; +} + +export interface UsageHost { + serverId: string; + label: string; + isConnected: boolean; + supportsUsage: boolean; +} + +export interface UsageHostGroup { + serverId: string; + label: string; + view: UsageView; +} + +/** One group per connected host, in host order. */ +export function groupUsageByHost( + hosts: readonly UsageHost[], + queries: ReadonlyMap, +): UsageHostGroup[] { + return hosts + .filter((host) => host.isConnected) + .map((host) => ({ + serverId: host.serverId, + label: host.label, + view: resolveUsageView({ + isConnected: true, + supportsUsage: host.supportsUsage, + query: queries.get(host.serverId), + }), + })); +} diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts new file mode 100644 index 0000000000..a7a717b627 --- /dev/null +++ b/packages/app/src/usage/queries.ts @@ -0,0 +1,220 @@ +import { useCallback, useMemo } from "react"; +import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useShallow } from "zustand/shallow"; +import { useFetchQueries, useFetchQuery } from "@/data/query"; +import { + getHostRuntimeStore, + useHostRuntimeConnectionStatuses, + useHostRuntimeIsConnected, + useHosts, +} from "@/runtime/host-runtime"; +import { useSessionStore, type SessionState } from "@/stores/session-store"; +import { usageCopy } from "./copy"; +import { + groupUsageByHost, + replaceReport, + resolveUsagePill, + resolveUsageRefresh, + resolveUsageView, + type UsageHostGroup, + type UsagePill, + type UsageQueryState, + type UsageRefresh, +} from "./model"; +import type { UsageReportEntry, UsageView } from "./types"; + +// The daemon caches each report for five minutes, so re-reading it is cheap. Only +// an explicit refresh passes `forceRefresh` and reaches the source's API. +const REPORTS_STALE_TIME_MS = 60_000; + +function usageReportsQueryKey(serverId: string) { + return ["usage", "reports", serverId] as const; +} + +function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) { + return ["usage", "agent", serverId, agentId, model] as const; +} + +function requireClient(serverId: string) { + const client = getHostRuntimeStore().getClient(serverId); + if (!client) throw new Error(usageCopy.clientUnavailable); + return client; +} + +async function listReports(serverId: string, forceRefresh = false): Promise { + return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports; +} + +function reportQueryKey(serverId: string, reportId: string) { + return ["usage", "report", serverId, reportId] as const; +} + +async function getReport( + serverId: string, + reportId: string, + forceRefresh = false, +): Promise { + return ( + (await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh })) + .reports[0] ?? null + ); +} + +function supportsUsage(session: SessionState | undefined): boolean { + return session?.serverInfo?.features?.usageSources === true; +} + +async function refreshReports(queryClient: QueryClient, serverId: string): Promise { + await queryClient.fetchQuery({ + queryKey: usageReportsQueryKey(serverId), + queryFn: async () => { + const reports = await listReports(serverId, true); + for (const report of reports) + queryClient.setQueryData(reportQueryKey(serverId, report.id), report); + return reports; + }, + staleTime: 0, + }); +} + +function toQueryState(query: { + data: UsageReportEntry[] | undefined; + error: unknown; + isFetching: boolean; +}): UsageQueryState { + return { data: query.data, error: query.error, isFetching: query.isFetching }; +} + +/** Usage reports for one host, as shown on its settings page. */ +export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } { + const queryClient = useQueryClient(); + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); + const query = useFetchQuery({ + queryKey: usageReportsQueryKey(serverId), + queryFn: async () => { + const reports = await listReports(serverId); + for (const report of reports) + queryClient.setQueryData(reportQueryKey(serverId, report.id), report); + return reports; + }, + enabled: isConnected && isSupported, + dataShape: "list", + staleTimeMs: REPORTS_STALE_TIME_MS, + }); + const refresh = useCallback(() => { + void refreshReports(queryClient, serverId).catch(() => undefined); + }, [queryClient, serverId]); + const view = resolveUsageView({ + isConnected, + supportsUsage: isSupported, + query: toQueryState(query), + }); + return { view, refresh }; +} + +/** Usage reports for every connected host, grouped by host. */ +export function useUsageByHost(): { + groups: UsageHostGroup[]; + refresh: (serverId: string) => void; +} { + const queryClient = useQueryClient(); + const hosts = useHosts(); + const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); + const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds); + const supportedServerIds = useSessionStore( + useShallow((state) => serverIds.filter((serverId) => supportsUsage(state.sessions[serverId]))), + ); + const usageHosts = useMemo( + () => + hosts.map((host) => ({ + serverId: host.serverId, + label: host.label, + isConnected: connectionStatuses.get(host.serverId) === "online", + supportsUsage: supportedServerIds.includes(host.serverId), + })), + [connectionStatuses, hosts, supportedServerIds], + ); + const results = useFetchQueries( + usageHosts.map((host) => ({ + queryKey: usageReportsQueryKey(host.serverId), + queryFn: () => listReports(host.serverId), + enabled: host.isConnected && host.supportsUsage, + dataShape: "list", + staleTimeMs: REPORTS_STALE_TIME_MS, + })), + ); + const groups = groupUsageByHost( + usageHosts, + new Map(usageHosts.map((host, index) => [host.serverId, toQueryState(results[index])])), + ); + const refresh = useCallback( + (serverId: string) => { + void refreshReports(queryClient, serverId).catch(() => undefined); + }, + [queryClient], + ); + return { groups, refresh }; +} + +/** + * The usage report for the account an agent is spending. The daemon resolves the + * report ID, which is re-resolved when the model changes (a new key) and when a + * turn completes (the query is paused while the agent runs, and its data is always + * stale, so resuming refetches). The report itself is read from the shared cache. + */ +export function useAgentUsage( + serverId: string, + agentId: string, +): { pill: UsagePill | null; entry: UsageReportEntry | null } { + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); + const { model, isRunning } = useSessionStore( + useShallow((state) => { + const agent = state.sessions[serverId]?.agents?.get(agentId); + return { model: agent?.model ?? null, isRunning: agent?.status === "running" }; + }), + ); + const identity = useFetchQuery({ + queryKey: agentUsageQueryKey(serverId, agentId, model), + queryFn: async () => + (await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId, + enabled: isConnected && isSupported && !isRunning, + dataShape: "value", + staleTimeMs: 0, + }); + const reportId = identity.data ?? null; + const query = useFetchQuery({ + queryKey: reportQueryKey(serverId, reportId ?? "none"), + queryFn: () => getReport(serverId, reportId!), + enabled: isConnected && isSupported && reportId !== null, + dataShape: "value", + staleTimeMs: REPORTS_STALE_TIME_MS, + }); + const entry = isSupported ? (query.data ?? null) : null; + return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry }; +} + +/** + * Forces the source to fetch one report, and only that report. The result replaces + * the report wherever it is cached — its own entry and its host's list — so every + * surface showing it moves together; until then the previous report stays on screen. + */ +export function useReportRefresh( + serverId: string, + reportId: string, +): { refresh: () => void; refreshState: UsageRefresh } { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: () => getReport(serverId, reportId, true), + onSuccess: (report) => { + queryClient.setQueryData(reportQueryKey(serverId, reportId), report); + queryClient.setQueryData(usageReportsQueryKey(serverId), (reports) => + reports ? replaceReport(reports, reportId, report) : reports, + ); + }, + }); + const { mutate } = mutation; + const refresh = useCallback(() => mutate(), [mutate]); + return { refresh, refreshState: resolveUsageRefresh(mutation) }; +} diff --git a/packages/app/src/usage/source-icon.tsx b/packages/app/src/usage/source-icon.tsx new file mode 100644 index 0000000000..779f42f841 --- /dev/null +++ b/packages/app/src/usage/source-icon.tsx @@ -0,0 +1,26 @@ +import { Gauge } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { SvgIcon } from "@/components/provider-icons"; +import type { Theme } from "@/styles/theme"; + +function UsageSourceIconBase({ + svg, + size, + color = "", +}: { + svg: string | null; + size: number; + color?: string; +}) { + if (!svg) return ; + return ; +} + +const ThemedUsageSourceIcon = withUnistyles(UsageSourceIconBase); + +const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** The source's catalog SVG, or a generic gauge when the source ships none. */ +export function UsageSourceIcon({ svg, size }: { svg: string | null; size: number }) { + return ; +} diff --git a/packages/app/src/provider-usage/tone.ts b/packages/app/src/usage/tone.ts similarity index 74% rename from packages/app/src/provider-usage/tone.ts rename to packages/app/src/usage/tone.ts index 5cf6f68bab..4727853c2f 100644 --- a/packages/app/src/provider-usage/tone.ts +++ b/packages/app/src/usage/tone.ts @@ -1,6 +1,6 @@ -import type { ProviderUsageTone } from "./types"; +import type { UsageTone } from "./types"; -export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone { +export function deriveTone(usedPct: number | null | undefined): UsageTone { if (usedPct == null) return "default"; if (usedPct > 90) return "danger"; if (usedPct >= 70) return "warning"; diff --git a/packages/app/src/usage/types.ts b/packages/app/src/usage/types.ts new file mode 100644 index 0000000000..2e2485b2d5 --- /dev/null +++ b/packages/app/src/usage/types.ts @@ -0,0 +1,15 @@ +import type { ProviderUsageTone, UsageReportEntry } from "@getpaseo/protocol/messages"; + +export type { UsageReportEntry }; +export type UsageReport = UsageReportEntry["report"]; +export type UsageTone = ProviderUsageTone; +export type UsageWindow = UsageReport["windows"][number]; +export type UsageBalance = NonNullable[number]; +export type UsageBalanceUnit = UsageBalance["unit"]; + +/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */ +export type UsageView = + | { kind: "unavailable"; message: string } + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; reports: UsageReportEntry[]; isRefreshing: boolean }; diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx new file mode 100644 index 0000000000..96006f7728 --- /dev/null +++ b/packages/app/src/usage/usage-screen.tsx @@ -0,0 +1,57 @@ +import { useCallback } from "react"; +import { useIsFocused } from "@react-navigation/native"; +import { router } from "expo-router"; +import { PageLayout } from "@/components/page-layout"; +import { usageCopy } from "./copy"; +import type { UsageHostGroup } from "./model"; +import { useUsageByHost } from "./queries"; +import { UsageMessage, UsageSection } from "./usage-section"; + +// The screen is reachable by URL, so there may be no history to go back to. +function leaveUsage(): void { + if (router.canGoBack()) { + router.back(); + return; + } + router.replace("/"); +} + +export function UsageScreen() { + const isFocused = useIsFocused(); + return ( + + {isFocused ? : null} + + ); +} + +function UsageScreenContent() { + const { groups, refresh } = useUsageByHost(); + return ( + <> + {groups.length === 0 ? : null} + {groups.map((group) => ( + + ))} + + ); +} + +function HostUsageGroup({ + group, + onRefresh, +}: { + group: UsageHostGroup; + onRefresh: (serverId: string) => void; +}) { + const handleRefresh = useCallback(() => onRefresh(group.serverId), [group.serverId, onRefresh]); + return ( + + ); +} diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx new file mode 100644 index 0000000000..846bc6d6f5 --- /dev/null +++ b/packages/app/src/usage/usage-section.tsx @@ -0,0 +1,103 @@ +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 "@/components/settings/headings/settings-section"; +import { usageCopy } from "./copy"; +import { UsageList } from "./list"; +import type { UsageView } from "./types"; + +export function UsageSection({ + serverId, + title, + view, + onRefresh, + testID, +}: { + serverId: string; + title: string; + view: UsageView; + onRefresh: () => void; + testID?: string; +}) { + const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); + + const refreshButton = useMemo( + () => + view.kind === "unavailable" ? null : ( + + ), + [busy, onRefresh, view.kind], + ); + + return ( + + + + ); +} + +function UsageBody({ + serverId, + view, + onRefresh, +}: { + serverId: string; + view: UsageView; + onRefresh: () => void; +}) { + if (view.kind === "unavailable") { + return ; + } + + if (view.kind === "loading") { + return ; + } + + if (view.kind === "error") { + return ( + + + + ); + } + + if (view.reports.length === 0) { + return ; + } + + return ; +} + +export function UsageMessage({ text }: { text: string }) { + return ( + + {text} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + emptyCard: { + padding: theme.spacing[4], + alignItems: "center", + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, +})); diff --git a/packages/app/src/provider-usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx similarity index 85% rename from packages/app/src/provider-usage/window-bar.tsx rename to packages/app/src/usage/window-bar.tsx index 0bd198521b..9627cb1571 100644 --- a/packages/app/src/provider-usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -2,16 +2,11 @@ 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 { usedPercent } from "./model"; import { deriveTone } from "./tone"; -import type { ProviderUsageTone, ProviderUsageWindow } from "./types"; +import type { UsageTone, UsageWindow } 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) { +function fillToneStyle(tone: UsageTone) { switch (tone) { case "ok": return styles.fillOk; @@ -24,8 +19,8 @@ function fillToneStyle(tone: ProviderUsageTone) { } } -export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) { - const usedPct = resolveUsedPct(window); +export function UsageWindowBar({ window }: { window: UsageWindow }) { + const usedPct = usedPercent(window); const tone = window.tone ?? deriveTone(usedPct); const fillWidth = clampPct(usedPct ?? 0); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index c13d6f25f9..1dc3ff48bb 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -428,6 +428,10 @@ export function buildSchedulesRoute() { return "/schedules" as const; } +export function buildUsageRoute() { + return "/usage" as const; +} + export function buildOpenProjectRoute() { return "/open-project" as const; } diff --git a/packages/app/src/utils/time.test.ts b/packages/app/src/utils/time.test.ts index 292fffbf6d..0ea19548c4 100644 --- a/packages/app/src/utils/time.test.ts +++ b/packages/app/src/utils/time.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { describeCompactTimeAgo, formatCompactTimeAgo, + formatCompactTimeAgoAsProse, formatDuration, formatMessageTimestamp, formatTimeAgo, @@ -63,6 +64,15 @@ describe("describeCompactTimeAgo", () => { }); }); +describe("formatCompactTimeAgoAsProse", () => { + it("puts elapsed labels back into prose and leaves dates alone", () => { + expect(formatCompactTimeAgoAsProse("now")).toBe("just now"); + expect(formatCompactTimeAgoAsProse("3m")).toBe("3m ago"); + expect(formatCompactTimeAgoAsProse("2h")).toBe("2h ago"); + expect(formatCompactTimeAgoAsProse("Jan 15")).toBe("Jan 15"); + }); +}); + describe("formatDuration", () => { it("renders sub-minute durations as whole seconds", () => { expect(formatDuration(0)).toBe("0s"); diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts index 8fa94e8926..668998ddf4 100644 --- a/packages/app/src/utils/time.ts +++ b/packages/app/src/utils/time.ts @@ -91,6 +91,16 @@ export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string return describeCompactTimeAgo(date, now).label; } +/** + * A compact label put back into a sentence, for a tooltip or a line with room for prose: + * "now" → "just now", "5m" → "5m ago", "Jan 15" stays a date. + */ +export function formatCompactTimeAgoAsProse(label: string): string { + if (label === "now") return "just now"; + if (/^\d/.test(label)) return `${label} ago`; + return label; +} + /** * How many local midnights lie between two instants: 0 for the same day, 1 for yesterday. * Counted on the calendar rather than in elapsed time, so six days and 23 hours ago on diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 46194ada89..c691898267 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -94,6 +94,8 @@ import type { RefreshProvidersSnapshotResponseMessage, ProviderDiagnosticResponseMessage, ProviderUsageListResponseMessage, + UsageListReportsResponseMessage, + AgentResolveUsageReportResponseMessage, DaemonGetStatusResponse, DaemonGetPairingOfferResponse, DaemonConfigReloadResponse, @@ -559,6 +561,8 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload" type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; +type UsageListReportsPayload = UsageListReportsResponseMessage["payload"]; +type AgentResolveUsageReportPayload = AgentResolveUsageReportResponseMessage["payload"]; type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; type DiagnosticsPayload = DiagnosticsResponse["payload"]; @@ -5238,6 +5242,34 @@ export class DaemonClient { }); } + async listUsageReports(options?: { + requestId?: string; + forceRefresh?: boolean; + reportIds?: string[]; + }): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "usage.list_reports.request", + forceRefresh: options?.forceRefresh, + reportIds: options?.reportIds, + }, + }); + } + + async resolveAgentUsageReport(options: { + agentId: string; + requestId?: string; + }): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "agent.resolve_usage_report.request", + agentId: options.agentId, + }, + }); + } + async listCommands(options: ListCommandsOptions): Promise; async listCommands(agentId: string, requestId?: string): Promise; async listCommands( diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d0ff366f27..19cf0cb075 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -23,6 +23,11 @@ "source": "./src/server/provider.ts", "default": "./dist/server/provider.js" }, + "./server/usage": { + "types": "./dist/server/usage.d.ts", + "source": "./src/server/usage.ts", + "default": "./dist/server/usage.js" + }, "./server/acp": { "types": "./dist/server/acp.d.ts", "source": "./src/server/acp.ts", diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 8c2fa17737..38197e4cb6 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -10,6 +10,7 @@ const entries = { ".": "shared", "./server": "server", "./server/provider": "server", + "./server/usage": "server", "./server/acp": "server", "./client": "client", "./client/host": "client", diff --git a/packages/plugin/src/server/contracts.ts b/packages/plugin/src/server/contracts.ts index c1cf10fc15..984b9c117b 100644 --- a/packages/plugin/src/server/contracts.ts +++ b/packages/plugin/src/server/contracts.ts @@ -3,6 +3,7 @@ import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { PluginRpcContract } from "../rpc.js"; import type { PluginCleanup } from "../contracts.js"; import type { ProviderRegistration } from "./provider.js"; +import type { UsageSourceRegistration } from "./usage.js"; import type { PluginLifecycleRegistration } from "./lifecycle.js"; export interface PluginHandlerContext { @@ -38,6 +39,7 @@ export interface PluginServerContext extends PluginLifecycleRegistration { ) => ZodInput | Promise>, ): void; registerProvider(provider: ProviderRegistration): void; + registerUsageSource(source: UsageSourceRegistration): void; } export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup; diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index 9e1d78b8bb..dc211f61bf 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -1,3 +1,10 @@ +export type { + UsageSourceRegistration, + UsageReport, + UsageWindow, + UsageBalance, + UsageDetail, +} from "./usage.js"; export type { PluginHandlerContext, PluginServerContext, diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index c65a814753..a72477d19d 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -18,6 +18,7 @@ export const PROVIDER_CAPABILITIES = [ "session.revert.files", "session.subsession", "session.unarchive", + "session.usage_reference", "permission", "permission.tool_policy", "timeline.plugin", @@ -227,6 +228,7 @@ export type ProviderInput = } | { type: "session.prompt"; sessionId: string; prompt: ProviderPrompt } | { type: "session.interrupt"; requestId: string; sessionId: string } + | { type: "session.usage_reference"; requestId: string; sessionId: string } | { type: "session.permission"; sessionId: string; @@ -526,6 +528,11 @@ export type ProviderEvent = | { type: "catalog"; requestId: string; catalog: ProviderCatalog } | { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] } | { type: "request.completed"; requestId: string } + | { + type: "usage_reference"; + requestId: string; + reference: { source: string; input: JsonValue } | null; + } | { type: "request.failed"; requestId: string; error: ProviderError } | { type: "session.opened"; @@ -609,6 +616,8 @@ export function requiredProviderCapabilities(input: ProviderInput): readonly Pro case "session.interrupt": case "session.close": return []; + case "session.usage_reference": + return ["session.usage_reference"]; case "sessions": return ["session.list"]; case "session.open": { @@ -887,6 +896,13 @@ export const ProviderInputSchema: z.ZodType = z.discriminatedUnio z .object({ type: z.literal("session.interrupt"), requestId: idSchema, sessionId: idSchema }) .strict(), + z + .object({ + type: z.literal("session.usage_reference"), + requestId: idSchema, + sessionId: idSchema, + }) + .strict(), z .object({ type: z.literal("session.permission"), @@ -1272,6 +1288,13 @@ export const ProviderEventSchema: z.ZodType = z.discriminatedUnio }) .strip(), z.object({ type: z.literal("request.completed"), requestId: idSchema }).strip(), + z + .object({ + type: z.literal("usage_reference"), + requestId: idSchema, + reference: z.object({ source: idSchema, input: z.json() }).nullable(), + }) + .strip(), z .object({ type: z.literal("request.failed"), requestId: idSchema, error: providerErrorSchema }) .strip(), diff --git a/packages/server/src/services/quota-fetcher/usage.test.ts b/packages/plugin/src/server/usage.test.ts similarity index 60% rename from packages/server/src/services/quota-fetcher/usage.test.ts rename to packages/plugin/src/server/usage.test.ts index 8e37583e30..0d1dd04ac5 100644 --- a/packages/server/src/services/quota-fetcher/usage.test.ts +++ b/packages/plugin/src/server/usage.test.ts @@ -1,5 +1,41 @@ -import { describe, expect, it } from "vitest"; -import { balanceToneFromRemaining, toneFromUsedPct, usedPctOf } from "./usage.js"; +import { describe, expect, it, test } from "vitest"; +import { + balanceToneFromRemaining, + toneFromUsedPct, + usedPctOf, + windowFromUsedPct, +} from "./usage.js"; + +test("shared usage tones preserve the app threshold contract", () => { + expect([null, 69.9, 70, 90, 90.1].map(toneFromUsedPct)).toEqual([ + "default", + "ok", + "warning", + "warning", + "danger", + ]); + expect([null, 0, 0.01].map(balanceToneFromRemaining)).toEqual(["default", "danger", "ok"]); +}); + +test("shared windows retain percentage, reset time, and optional headline", () => { + expect(windowFromUsedPct({ id: "a", label: "A", utilizationPct: 30, headline: true })).toEqual({ + id: "a", + label: "A", + usedPct: 30, + remainingPct: 70, + resetsAt: null, + headline: true, + }); + expect(windowFromUsedPct({ id: "b", label: "B", utilizationPct: null })).toEqual({ + id: "b", + label: "B", + usedPct: null, + remainingPct: null, + resetsAt: null, + }); + expect(usedPctOf(25, 100)).toBe(25); + expect(usedPctOf(25, 0)).toBeNull(); +}); describe("toneFromUsedPct", () => { // Thresholds must match deriveTone in the app's provider-usage/tone.ts, which is what diff --git a/packages/server/src/services/quota-fetcher/usage.ts b/packages/plugin/src/server/usage.ts similarity index 51% rename from packages/server/src/services/quota-fetcher/usage.ts rename to packages/plugin/src/server/usage.ts index fa766f5778..3e34e7acf1 100644 --- a/packages/server/src/services/quota-fetcher/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -1,50 +1,55 @@ -import { z } from "zod"; -import type { - ProviderUsage, - ProviderUsageBalance, - ProviderUsageTone, - ProviderUsageWindow, -} from "../../server/messages.js"; -import type { ProviderApiFetch } from "./provider.js"; +import { createHash } from "node:crypto"; +import type { ZodType } from "zod"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; -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 interface UsageWindow { + id: string; + label: string; + usedPct?: number | null; + remainingPct?: number | null; + resetsAt?: string | null; + runsOutAt?: string | null; + shortfallPct?: number | null; + tone?: "default" | "ok" | "warning" | "danger"; + headline?: boolean; } -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 interface UsageBalance { + id: string; + label: string; + used?: number | null; + remaining?: number | null; + limit?: number | null; + unit: "usd" | "credits" | "requests" | "tokens"; + resetsAt?: string | null; + tone?: UsageWindow["tone"]; +} + +export interface UsageDetail { + id: string; + label: string; + value: string; + tone?: UsageWindow["tone"]; +} + +export interface UsageReport { + status: "available" | "unavailable" | "error"; + planLabel?: string; + windows: UsageWindow[]; + balances?: UsageBalance[]; + details?: UsageDetail[]; + error?: string; +} + +export interface UsageSourceRegistration { + id: string; + label: string; + icon?: string; + input: ZodType; + /** Stable account identity, resolved without fetching usage. */ + identify(input: unknown): Promise<{ key: string; label?: string } | null>; + fetch(input: unknown): Promise; + discover?(): Promise; } export function windowFromUsedPct(input: { @@ -52,19 +57,19 @@ export function windowFromUsedPct(input: { label: string; utilizationPct: number | null | undefined; resetsAt?: string | null; - tone?: ProviderUsageWindow["tone"]; -}): ProviderUsageWindow { + tone?: UsageWindow["tone"]; + headline?: boolean; +}): UsageWindow { const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null; - const window: ProviderUsageWindow = { + const window: UsageWindow = { 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; - } + if (input.tone) window.tone = input.tone; + if (input.headline) window.headline = true; return window; } @@ -76,7 +81,7 @@ export function windowFromUsedPct(input: { * "default" because that is what every provider setting a tone has always sent, and it is * what the bars render today below their thresholds. */ -export function toneFromUsedPct(usedPct: number | null | undefined): ProviderUsageTone { +export function toneFromUsedPct(usedPct: number | null | undefined): UsageWindow["tone"] { if (typeof usedPct !== "number") return "default"; if (usedPct > 90) return "danger"; if (usedPct >= 70) return "warning"; @@ -90,7 +95,7 @@ export function toneFromUsedPct(usedPct: number | null | undefined): ProviderUsa */ export function balanceToneFromRemaining( remaining: number | null | undefined, -): ProviderUsageBalance["tone"] { +): UsageBalance["tone"] { if (typeof remaining !== "number") return "default"; if (remaining <= 0) return "danger"; return "ok"; @@ -105,7 +110,15 @@ export function usedPctOf( return (used / limit) * 100; } -export function toIsoStringOrNull(timestampMs: number): string | null { - const date = new Date(timestampMs); - return Number.isFinite(date.getTime()) ? date.toISOString() : null; +export function hashAccountKey(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function unavailableUsage(): UsageReport { + return { + status: "unavailable", + windows: [], + balances: [], + details: [], + }; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index db72cd3cb6..a716d45893 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1774,6 +1774,18 @@ export const ProviderUsageListRequestMessageSchema = z.object({ requestId: z.string(), }); +export const UsageListReportsRequestMessageSchema = z.object({ + type: z.literal("usage.list_reports.request"), + requestId: z.string(), + reportIds: z.array(z.string()).optional(), + forceRefresh: z.boolean().optional(), +}); +export const AgentResolveUsageReportRequestMessageSchema = z.object({ + type: z.literal("agent.resolve_usage_report.request"), + requestId: z.string(), + agentId: z.string(), +}); + export const ResumeAgentRequestMessageSchema = z.object({ type: z.literal("resume_agent_request"), handle: AgentPersistenceHandleSchema, @@ -3232,6 +3244,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ RefreshProvidersSnapshotRequestMessageSchema, ProviderDiagnosticRequestMessageSchema, ProviderUsageListRequestMessageSchema, + UsageListReportsRequestMessageSchema, + AgentResolveUsageReportRequestMessageSchema, ResumeAgentRequestMessageSchema, ImportAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, @@ -3534,6 +3548,7 @@ export const ServerInfoStatusPayloadSchema = z // COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05. hubAgentRpc: z.boolean().optional(), providersSnapshot: z.boolean().optional(), + usageSources: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. providersSnapshotCwd: z.boolean().optional(), // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. @@ -6228,6 +6243,32 @@ export const ProviderUsageListResponseMessageSchema = z.object({ }), }); +export const UsageReportSchema = z.object({ + status: ProviderUsageStatusSchema, + planLabel: z.string().optional(), + windows: z.array(ProviderUsageWindowSchema.extend({ headline: z.boolean().optional() })), + balances: z.array(ProviderUsageBalanceSchema).optional(), + details: z.array(ProviderUsageDetailSchema).optional(), + error: z.string().optional(), +}); +export const UsageReportEntrySchema = z.object({ + id: z.string(), + account: z.object({ label: z.string().optional() }), + fetchedAt: z.string(), + sourceId: z.string(), + sourceLabel: z.string(), + icon: z.string().optional(), + report: UsageReportSchema, +}); +export const UsageListReportsResponseMessageSchema = z.object({ + type: z.literal("usage.list_reports.response"), + payload: z.object({ requestId: z.string(), reports: z.array(UsageReportEntrySchema) }), +}); +export const AgentResolveUsageReportResponseMessageSchema = z.object({ + type: z.literal("agent.resolve_usage_report.response"), + payload: z.object({ requestId: z.string(), reportId: z.string().nullable() }), +}); + const AgentSlashCommandSchema = z.object({ name: z.string(), description: z.string(), @@ -6913,6 +6954,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ RefreshProvidersSnapshotResponseMessageSchema, ProviderDiagnosticResponseMessageSchema, ProviderUsageListResponseMessageSchema, + UsageListReportsResponseMessageSchema, + AgentResolveUsageReportResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, TerminalsChangedSchema, @@ -7091,6 +7134,12 @@ export type ProviderDiagnosticResponseMessage = z.infer< typeof ProviderDiagnosticResponseMessageSchema >; export type ProviderUsageTone = z.infer; +export type UsageReport = z.infer; +export type UsageReportEntry = z.infer; +export type UsageListReportsResponseMessage = z.infer; +export type AgentResolveUsageReportResponseMessage = z.infer< + typeof AgentResolveUsageReportResponseMessageSchema +>; export type ProviderUsageStatus = z.infer; export type ProviderUsage = z.infer; export type ProviderUsageWindow = z.infer; diff --git a/packages/server/package.json b/packages/server/package.json index 191a9d3060..047c7b72a1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -73,7 +73,7 @@ "clean": "node ../../scripts/clean-package-dist.mjs", "build": "npm run build:lib && npm run build:scripts", "build:clean": "npm run clean && npm run build", - "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true});\"", + "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).includes('node_modules')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui", "start": "node dist/scripts/supervisor-entrypoint.js", @@ -144,6 +144,7 @@ "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", + "yaml": "2.9.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index e2fb9779b9..d42d98aaf7 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -660,6 +660,11 @@ export interface AgentPermissionResult { followUpPrompt?: AgentPromptInput; } +export interface UsageReference { + source: string; + input: JsonValue; +} + export interface AgentSession { readonly provider: AgentProvider; readonly id: string | null; @@ -668,6 +673,8 @@ export interface AgentSession { /** New provider-owned rows to commit on registration. streamHistory must also * replay them at their original timestamps; restored sessions omit old rows. */ readonly initialTimeline?: ImportedTimelineEntry[]; + /** Resolved at fetch time because model and credentials may change during a session. */ + getUsageReference?(): Promise; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise; diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 47d3d67cf9..6aa32e2731 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -396,6 +396,24 @@ describe("PluginAgentClientRegistry", () => { await registry.shutdown(); }); + test("returns no usage reference when a plugin provider lacks the capability", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + try { + expect(await session.getUsageReference?.()).toBeNull(); + expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false); + } finally { + await session.close(); + registry.replace([]); + } + }); + test("terminalizes an active turn exactly once when its plugin provider is removed", async () => { const harness = createProviderHarness({ completeTurn: false }); const registry = new PluginAgentClientRegistry(createTestLogger()); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 1977c787e1..2ebeb3c797 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -51,6 +51,7 @@ import type { ProviderRefreshContext, SteerActiveTurnOptions, SteerResult, + UsageReference, } from "./agent-sdk-types.js"; import { isDefaultAgentCreateConfigUnattended, @@ -105,6 +106,20 @@ function providerError(error: ProviderError): Error { }); } +function isProviderRequestReply( + event: ProviderEvent, +): event is Extract< + ProviderEvent, + { type: "request.completed" | "catalog" | "sessions" | "usage_reference" } +> { + return ( + event.type === "request.completed" || + event.type === "catalog" || + event.type === "sessions" || + event.type === "usage_reference" + ); +} + class ProviderRuntime { private connection: ProviderConnection | null = null; private connecting: Promise | null = null; @@ -356,11 +371,7 @@ class ProviderRuntime { this.failRequest(event); return; } - if ( - event.type === "request.completed" || - event.type === "catalog" || - event.type === "sessions" - ) { + if (isProviderRequestReply(event)) { this.finishRequest(event); return; } @@ -515,7 +526,10 @@ class ProviderRuntime { } private finishRequest( - event: Extract, + event: Extract< + ProviderEvent, + { type: "request.completed" | "catalog" | "sessions" | "usage_reference" } + >, ): void { const request = this.requests.get(event.requestId); if (!request) return; @@ -554,6 +568,17 @@ class ProviderRuntimeSession { return this.capabilities; } + async getUsageReference(): Promise { + if (!this.capabilities.includes("session.usage_reference")) return null; + const event = await this.runtime.complete({ + type: "session.usage_reference", + requestId: randomUUID(), + sessionId: this.providerSessionId, + }); + if (event.type !== "usage_reference") throw new Error("Invalid usage reference response"); + return event.reference; + } + onEvent(listener: (event: ProviderEvent) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -1063,6 +1088,10 @@ class PluginAgentSession implements AgentSession { return agentCapabilities(this.bridge.negotiatedCapabilities); } + getUsageReference(): Promise { + return this.bridge.getUsageReference(); + } + get features(): AgentFeature[] { return this.bridge.config.settings.map((setting) => setting.type === "toggle" diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 89302bc68e..7a1d2a7292 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -458,6 +458,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession } }, getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()), + getUsageReference: inner.getUsageReference?.bind(inner), getAvailableModes: () => inner.getAvailableModes(), getCurrentMode: () => inner.getCurrentMode(), setMode: (modeId) => inner.setMode(modeId), diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 9d106a2c1a..0394900d3b 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -222,6 +222,13 @@ function createSessionWithConfig( ); } +test("ACP usage reference uses the provider ID", async () => { + for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) { + const session = createSessionWithConfig({ provider }); + expect(await session.getUsageReference()).toEqual({ source: provider, input: {} }); + } +}); + function createKiroSession( options: { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number } = {}, logger: ReturnType = createTestLogger(), diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index d2e0a1beb6..019725ce3f 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1633,6 +1633,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { readonly provider: string; readonly capabilities: AgentCapabilityFlags; + async getUsageReference() { + return { source: this.provider, input: {} }; + } + private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly defaultCommand: [string, ...string[]]; @@ -1676,6 +1680,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private connection: ClientSideConnection | null = null; private agentCapabilities: ACPAgentCapabilities | null = null; private sessionId: string | null = null; + private readonly earlySessionUpdates: SessionNotification[] = []; private currentMode: string | null = null; private availableModes: AgentMode[]; private currentModel: string | null = null; @@ -1752,6 +1757,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionId = response.sessionId; this.bootstrapThreadEventPending = true; this.applySessionState(response); + for (const update of this.earlySessionUpdates.splice(0)) { + await this.sessionUpdate(update); + } await this.applyConfiguredOverrides(); } catch (error) { await this.closeAfterInitializationFailure(error); @@ -2521,6 +2529,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { }, "provider.acp.raw_event", ); + if (!this.sessionId) { + this.earlySessionUpdates.push(params); + return; + } if (params.sessionId !== this.sessionId) { return; } diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index c908e17cf5..131c52c5f7 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -25,6 +25,7 @@ import type { } from "../../agent-sdk-types.js"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js"; +import { buildProviderRegistry } from "../../provider-registry.js"; interface TestClaudeSession { translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; @@ -527,6 +528,56 @@ describe("ClaudeAgentClient.fetchCatalog", () => { describe("ClaudeAgentClient binary resolution", () => { const logger = createTestLogger(); + test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => { + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + const session = await client.createSession( + { provider: "claude", cwd: process.cwd() }, + { env: { CLAUDE_CONFIG_DIR: "/accounts/second" } }, + ); + expect(await session.getUsageReference?.()).toEqual({ + source: "claude", + input: { configDir: "/accounts/second" }, + }); + await session.close(); + }); + + test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])( + "Claude usage reference is null with %s override", + async (name) => { + const client = new ClaudeAgentClient({ + logger, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession( + { provider: "claude", cwd: process.cwd() }, + { env: { [name]: "override" } }, + ); + expect(await session.getUsageReference?.()).toBeNull(); + await session.close(); + }, + ); + + test("Claude custom alias keeps its CLAUDE_CONFIG_DIR usage reference", async () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + "work-claude": { + extends: "claude", + label: "Work Claude", + env: { CLAUDE_CONFIG_DIR: "/accounts/work" }, + }, + }, + }); + const session = await registry["work-claude"].createClient(logger).createSession({ + provider: "work-claude", + cwd: process.cwd(), + }); + expect(await session.getUsageReference?.()).toEqual({ + source: "claude", + input: { configDir: "/accounts/work" }, + }); + await session.close(); + }); + test("resolves the installed Claude Code version", async () => { await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 04fef2b1f6..38476490b3 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; import path from "node:path"; +import { homedir } from "node:os"; import { type AgentDefinition, type CanUseTool, @@ -2041,6 +2042,15 @@ class ClaudeAgentSession implements AgentSession { readonly provider = "claude" as const; readonly capabilities = CLAUDE_CAPABILITIES; + async getUsageReference() { + const env = this.buildSdkEnv(); + if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null; + return { + source: "claude", + input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") }, + }; + } + private readonly config: ClaudeAgentConfig; private readonly launchEnv?: Record; private readonly agentId?: string; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index a9e0b77db1..029d0ff0d3 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -171,6 +171,44 @@ function createSession( return session; } +test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => { + const session = new CodexAppServerAgentSession( + createConfig(), + null, + createTestLogger(), + () => { + throw new Error("unused"); + }, + {}, + false, + false, + false, + undefined, + "interactive", + { CODEX_HOME: "/accounts/second" }, + ); + expect(await session.getUsageReference()).toEqual({ + source: "codex", + input: { codexHome: "/accounts/second" }, + }); + const custom = new CodexAppServerAgentSession( + createConfig(), + null, + createTestLogger(), + () => { + throw new Error("unused"); + }, + {}, + false, + false, + false, + undefined, + "interactive", + { CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" }, + ); + expect(await custom.getUsageReference()).toBeNull(); +}); + function createProviderWithFakeAppServer( appServer: FakeCodexAppServer, options?: { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index c21f39d747..015a9aeaf9 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3453,6 +3453,7 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly autoReviewEnabled: boolean = false, private readonly agentId?: string, private readonly initialResumePurpose: "interactive" | "history" = "interactive", + private readonly usageEnv: NodeJS.ProcessEnv = process.env, ) { this.logger = logger.child({ module: "agent", @@ -3481,6 +3482,17 @@ export class CodexAppServerAgentSession implements AgentSession { } } + async getUsageReference() { + if (this.usageEnv.OPENAI_BASE_URL) return null; + return { + source: "codex", + input: { + codexHome: + this.usageEnv.CODEX_HOME || path.join(this.usageEnv.HOME || os.homedir(), ".codex"), + }, + }; + } + get id(): string | null { return this.currentThreadId; } @@ -7144,6 +7156,8 @@ export class CodexAppServerAgentClient implements AgentClient { goalsEnabled, autoReviewEnabled, launchContext?.agentId, + "interactive", + buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env), ); await session.connect(); return session; @@ -7176,6 +7190,7 @@ export class CodexAppServerAgentClient implements AgentClient { autoReviewEnabled, launchContext?.agentId, options?.purpose ?? "interactive", + buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env), ); await session.connect(); return session; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts index b53be4c5a3..2411773a7e 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts @@ -13,9 +13,7 @@ describe("GenericACPAgentClient slash commands", () => { const client = new GenericACPAgentClient({ logger: createTestLogger(), command, - // Long enough that the update always lands inside the wait, however slowly - // the fake agent is scheduled. - initialCommandsWaitTimeoutMs: 60_000, + initialCommandsWaitTimeoutMs: 1_500, }); const session = await client.createSession({ provider: "acp", cwd }); try { @@ -83,20 +81,26 @@ rl.on("line", (line) => { } if (message.method === "session/new") { - write({ id: message.id, result: { sessionId: "session-1" } }); if (mode === "commands-after-session-new") { - setTimeout(() => { - write({ - method: "session/update", - params: { - sessionId: "session-1", - update: { - sessionUpdate: "available_commands_update", - availableCommands: [{ name: "review", description: "Review the diff" }], + process.stdout.write( + [ + { id: message.id, result: { sessionId: "session-1" } }, + { + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "review", description: "Review the diff" }], + }, }, }, - }); - }, 0); + ] + .map((entry) => JSON.stringify({ jsonrpc: "2.0", ...entry }) + "\\n") + .join(""), + ); + } else { + write({ id: message.id, result: { sessionId: "session-1" } }); } return; } diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 4c75aafc29..665dfee5ad 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; import { setImmediate as waitForImmediate } from "node:timers/promises"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { AgentStreamEvent } from "../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; @@ -144,6 +148,79 @@ function createToolCatalog(): PaseoToolCatalog { } describe("OMP agent client and session", () => { + test("usage reference reads current get_state provider and account on each call", async () => { + const home = mkdtempSync(join(tmpdir(), "paseo-omp-reference-")); + const agentDir = join(home, "agent"); + mkdirSync(agentDir); + // Upstream: packages/ai/src/auth/sqlite-credential-store.ts:576-581,711-724. + const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as { + DatabaseSync: new (path: string) => { + exec(sql: string): void; + prepare(sql: string): { run(...args: unknown[]): void }; + close(): void; + }; + }; + const db = new DatabaseSync(join(agentDir, "agent.db")); + db.exec(` + CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER NOT NULL); + CREATE INDEX idx_cache_expires ON cache(expires_at); + CREATE TABLE auth_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT NOT NULL, + disabled_cause TEXT DEFAULT NULL, + identity_key TEXT DEFAULT NULL, + created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), + updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)) + ); + `); + const insert = db.prepare( + "INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, 'oauth', ?)", + ); + insert.run( + "openai-codex", + JSON.stringify({ + access: "codex-fixture", + accountId: "codex-account", + expires: Date.now() + 60_000, + }), + ); + insert.run( + "anthropic", + JSON.stringify({ + access: "claude-fixture", + accountId: "claude-account", + expires: Date.now() + 60_000, + }), + ); + db.close(); + try { + const omp = new OmpHarness(); + await omp.start({}, undefined, { + OMP_PROFILE: "", + PI_CODING_AGENT_DIR: agentDir, + XDG_DATA_HOME: "", + }); + const runtime = omp.runtime(); + runtime.state = { ...runtime.state, model: { provider: "openai-codex", id: "codex-model" } }; + const requestsBefore = runtime.getStateRequestCount; + expect(await omp.getUsageReference()).toEqual({ + source: "codex", + input: { accessToken: "codex-fixture", accountId: "codex-account" }, + }); + expect(runtime.getStateRequestCount).toBe(requestsBefore + 1); + runtime.state = { ...runtime.state, model: { provider: "anthropic", id: "claude-model" } }; + expect(await omp.getUsageReference()).toEqual({ + source: "claude", + input: { accessToken: "claude-fixture" }, + }); + expect(runtime.getStateRequestCount).toBe(requestsBefore + 2); + await omp.close(); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); test("owns launch configuration and registers native host tools", async () => { const omp = new OmpHarness(); await omp.start({ modeId: "ask" }, createToolCatalog()); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 954c8b286a..5555fa130e 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -108,6 +108,7 @@ import { mapOmpRpcUiPermissionRequest, } from "./rpc-ui-permission-mapper.js"; import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js"; +import { resolveOmpUsageReference } from "./usage-reference.js"; const OMP_PROVIDER = "omp"; const QUESTION_RESPONSE_HEADER = "Response"; @@ -186,6 +187,7 @@ interface OmpAgentSessionOptions { noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; paseoTools?: PaseoToolCatalog; + usageEnv?: NodeJS.ProcessEnv; /** * When false (resumed sessions), replayed session events are dropped until * the first prompt or agent_start so history is not re-emitted as live @@ -916,6 +918,7 @@ export class OmpAgentSession implements AgentSession { this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; this.paseoTools = options.paseoTools; + this.usageEnv = options.usageEnv ?? process.env; this.live = options.live ?? true; this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler(); this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler(); @@ -964,6 +967,12 @@ export class OmpAgentSession implements AgentSession { private readonly config: AgentSessionConfig; private readonly logger: Logger; private readonly paseoTools?: PaseoToolCatalog; + private readonly usageEnv: NodeJS.ProcessEnv; + + async getUsageReference() { + const state = await this.runtimeSession.getState(); + return resolveOmpUsageReference(state.sessionId, state.model?.provider ?? "", this.usageEnv); + } get id(): string | null { return this.state.sessionId; @@ -2304,6 +2313,7 @@ export class OmpAgentClient implements AgentClient { noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, paseoTools: launchContext?.paseoTools, + usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, }); } catch (error) { await runtimeSession.close().catch(() => undefined); @@ -2346,6 +2356,7 @@ export class OmpAgentClient implements AgentClient { noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, paseoTools: launchContext?.paseoTools, + usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, live: false, }); } catch (error) { diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 4f2bae8838..7c99fa772c 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -93,10 +93,11 @@ export class OmpHarness { async start( config: Partial = {}, paseoTools?: PaseoToolCatalog, + env?: Record, ): Promise { const session = await this.client.createSession( { provider: "omp", cwd: CWD, ...config }, - paseoTools ? { paseoTools } : undefined, + paseoTools || env ? { paseoTools, env } : undefined, ); if (!(session instanceof OmpAgentSession)) { throw new Error("OMP client returned a non-OMP session"); @@ -482,6 +483,10 @@ export class OmpHarness { return this.omp.latestSession(); } + async getUsageReference() { + return this.requireSession().getUsageReference(); + } + runningToolCallIds(): string[] { const statusByCall = new Map(); for (const item of this.timeline()) { diff --git a/packages/server/src/server/agent/providers/omp/usage-reference.test.ts b/packages/server/src/server/agent/providers/omp/usage-reference.test.ts new file mode 100644 index 0000000000..659c0afcea --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/usage-reference.test.ts @@ -0,0 +1,284 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +import { afterEach, expect, test } from "vitest"; +import { resolveOmpUsageReference } from "./usage-reference.js"; + +const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as { + DatabaseSync: new (path: string) => { + exec(sql: string): void; + prepare(sql: string): { run(...args: unknown[]): void }; + close(): void; + }; +}; +// Upstream: packages/ai/src/auth/sqlite-credential-store.ts:576-581,711-724. +const OMP_AUTH_SCHEMA = ` + CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER NOT NULL); + CREATE INDEX idx_cache_expires ON cache(expires_at); + CREATE TABLE auth_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT NOT NULL, + disabled_cause TEXT DEFAULT NULL, + identity_key TEXT DEFAULT NULL, + created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), + updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)) + ); +`; +const dirs: string[] = []; +const future = Date.now() + 3_600_000; +function fixture() { + const home = mkdtempSync(join(tmpdir(), "omp-usage-")); + dirs.push(home); + const agentDir = join(home, "agent"); + mkdirSync(agentDir); + const dbFile = join(agentDir, "agent.db"); + const db = new DatabaseSync(dbFile); + db.exec(OMP_AUTH_SCHEMA); + const env = { OMP_PROFILE: "", PI_CODING_AGENT_DIR: agentDir, XDG_DATA_HOME: "" }; + const row = ( + id: number, + provider = "openai-codex", + options: { disabled?: boolean; expires?: number; type?: string } = {}, + ) => + db + .prepare( + "INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)", + ) + .run( + id, + provider, + options.type ?? "oauth", + JSON.stringify({ + access: `fixture-token-${id}`, + expires: options.expires ?? future, + accountId: `account-${id}`, + }), + options.disabled ? "disabled" : null, + ); + const sticky = (id: number, expires = Math.floor(Date.now() / 1000) + 100) => + db + .prepare("INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?)") + .run( + "session:sticky:openai-codex:session-1", + JSON.stringify({ type: "oauth", index: 0, credentialId: id }), + expires, + ); + const read = (override = {}) => + resolveOmpUsageReference("session-1", "openai-codex", { ...env, ...override }); + return { db, dbFile, agentDir, row, sticky, read }; +} +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); +test("sticky id selects the right OAuth row and leaves SQLite byte-identical", () => { + const f = fixture(); + f.row(1); + f.row(2); + f.sticky(2); + f.db.close(); + const before = readFileSync(f.dbFile); + expect(f.read()).toEqual({ + source: "codex", + input: { accessToken: "fixture-token-2", accountId: "account-2" }, + }); + expect(f.read()).toEqual({ + source: "codex", + input: { accessToken: "fixture-token-2", accountId: "account-2" }, + }); + expect(readFileSync(f.dbFile)).toEqual(before); +}); +test("single OAuth row resolves without sticky", () => { + const f = fixture(); + f.row(1); + f.db.close(); + expect(f.read()).toEqual({ + source: "codex", + input: { accessToken: "fixture-token-1", accountId: "account-1" }, + }); +}); +test("multiple rows without sticky are ambiguous", () => { + const f = fixture(); + f.row(1); + f.row(2); + f.db.close(); + expect(f.read()).toBeNull(); +}); +test("fallback counts only valid OAuth rows", () => { + const f = fixture(); + f.row(1); + f.row(2, "openai-codex", { expires: Date.now() - 1 }); + f.row(3, "openai-codex", { disabled: true }); + f.db + .prepare( + "INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)", + ) + .run(4, "openai-codex", "oauth", "invalid-json", null); + f.db.close(); + expect(f.read()).toEqual({ + source: "codex", + input: { accessToken: "fixture-token-1", accountId: "account-1" }, + }); +}); +test("disabled and expired rows do not resolve", () => { + const a = fixture(); + a.row(1, "openai-codex", { disabled: true }); + a.sticky(1); + a.db.close(); + expect(a.read()).toBeNull(); + const b = fixture(); + b.row(1, "openai-codex", { expires: Date.now() - 1 }); + b.db.close(); + expect(b.read()).toBeNull(); +}); +test("expired sticky falls back to a single row", () => { + const f = fixture(); + f.row(1); + f.sticky(1, 1); + f.db.close(); + expect(f.read()?.source).toBe("codex"); +}); +test("env key does not displace OAuth with or without sticky", () => { + const f = fixture(); + f.row(1); + f.db.close(); + expect(f.read({ OPENAI_CODEX_OAUTH_TOKEN: "fixture-key" })?.source).toBe("codex"); + const g = fixture(); + g.row(1); + g.sticky(1); + g.db.close(); + expect(g.read({ OPENAI_CODEX_OAUTH_TOKEN: "fixture-key" })?.source).toBe("codex"); +}); +test("broker environment and config URLs block attribution", () => { + const f = fixture(); + f.row(1); + f.db.close(); + expect(f.read({ OMP_AUTH_BROKER_URL: "https://broker.invalid" })).toBeNull(); + writeFileSync( + join(f.agentDir, "config.yml"), + "auth:\n broker:\n url: https://broker.invalid\n", + ); + expect(f.read()).toBeNull(); +}); +test("broker config.yaml and flat keys block attribution with config.yml precedence", () => { + const f = fixture(); + f.row(1); + f.db.close(); + writeFileSync(join(f.agentDir, "config.yaml"), '"auth.broker.url": https://broker.invalid\n'); + expect(f.read()).toBeNull(); + writeFileSync(join(f.agentDir, "config.yml"), "auth:\n broker: {}\n"); + expect(f.read()).toEqual({ + source: "codex", + input: { accessToken: "fixture-token-1", accountId: "account-1" }, + }); + writeFileSync(join(f.agentDir, "config.yml"), '"auth.broker.url": https://broker.invalid\n'); + expect(f.read()).toBeNull(); +}); +test("models config API key overrides sticky OAuth", () => { + const f = fixture(); + f.row(1); + f.sticky(1); + f.db.close(); + writeFileSync( + join(f.agentDir, "models.yml"), + "providers:\n openai-codex:\n apiKey: fixture-key\n", + ); + expect(f.read()).toBeNull(); +}); +test("provider mismatch and malformed sticky fail closed", () => { + const f = fixture(); + f.row(1, "anthropic"); + f.sticky(1); + f.db.close(); + expect(f.read()).toBeNull(); +}); + +test("Anthropic OAuth maps to Claude and changes are read on each request", () => { + const f = fixture(); + f.row(1, "anthropic"); + f.db.close(); + expect( + resolveOmpUsageReference("session-1", "anthropic", { + OMP_PROFILE: "", + PI_CODING_AGENT_DIR: f.agentDir, + }), + ).toEqual({ + source: "claude", + input: { accessToken: "fixture-token-1" }, + }); + writeFileSync( + join(f.agentDir, "config.yml"), + "auth:\n broker:\n url: https://broker.invalid\n", + ); + expect( + resolveOmpUsageReference("session-1", "anthropic", { + OMP_PROFILE: "", + PI_CODING_AGENT_DIR: f.agentDir, + }), + ).toBeNull(); +}); + +test("named profile ignores agent override and uses existing XDG profile data", () => { + const f = fixture(); + f.db.close(); + const root = mkdtempSync(join(tmpdir(), "omp-profile-")); + dirs.push(root); + const data = join(root, "xdg", "omp", "profiles", "work"); + mkdirSync(data, { recursive: true }); + const profileDb = new DatabaseSync(join(data, "agent.db")); + profileDb.exec(OMP_AUTH_SCHEMA); + profileDb + .prepare( + "INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)", + ) + .run( + 1, + "openai-codex", + "oauth", + JSON.stringify({ access: "profile-token", expires: future }), + null, + ); + profileDb.close(); + expect( + resolveOmpUsageReference( + "session-1", + "openai-codex", + { + OMP_PROFILE: "work", + PI_PROFILE: "other", + PI_CODING_AGENT_DIR: f.agentDir, + PI_CONFIG_DIR: join(root, "config"), + XDG_DATA_HOME: join(root, "xdg"), + }, + "linux", + ), + ).toEqual({ source: "codex", input: { accessToken: "profile-token" } }); +}); + +test("missing database returns null without creating a file", () => { + const home = mkdtempSync(join(tmpdir(), "omp-missing-db-")); + dirs.push(home); + const agentDir = join(home, "agent"); + mkdirSync(agentDir); + expect( + resolveOmpUsageReference("session-1", "openai-codex", { + OMP_PROFILE: "", + PI_CODING_AGENT_DIR: agentDir, + XDG_DATA_HOME: "", + }), + ).toBeNull(); + expect(existsSync(join(agentDir, "agent.db"))).toBe(false); +}); + +test("missing cache or credentials table returns null", () => { + const first = fixture(); + first.db.exec("DROP TABLE cache"); + first.db.close(); + expect(first.read()).toBeNull(); + const second = fixture(); + second.db.exec("DROP TABLE auth_credentials"); + second.db.close(); + expect(second.read()).toBeNull(); +}); diff --git a/packages/server/src/server/agent/providers/omp/usage-reference.ts b/packages/server/src/server/agent/providers/omp/usage-reference.ts new file mode 100644 index 0000000000..384e59697e --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/usage-reference.ts @@ -0,0 +1,169 @@ +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; + +import { z } from "zod"; +import { parse as parseYaml } from "yaml"; +import type { UsageReference } from "../../agent-sdk-types.js"; +import { resolveOmpDiagnosticPaths } from "./provider-config.js"; + +const stickySchema = z.object({ type: z.string(), credentialId: z.number().int().positive() }); +const rowSchema = z.object({ + id: z.number().int().positive(), + provider: z.string(), + credential_type: z.string(), + data: z.string(), + disabled_cause: z.string().nullable(), +}); +const oauthSchema = z.object({ + access: z.string().min(1), + expires: z.number(), + accountId: z.string().optional(), + orgId: z.string().optional(), +}); +const configSchema = z + .object({ + "auth.broker.url": z.string().optional(), + auth: z + .object({ broker: z.object({ url: z.string().optional() }).passthrough().optional() }) + .passthrough() + .optional(), + }) + .passthrough(); +const modelsSchema = z + .object({ + providers: z + .record(z.string(), z.object({ apiKey: z.unknown().optional() }).passthrough()) + .optional(), + }) + .passthrough(); + +interface UsageDb { + prepare(sql: string): { get(...args: unknown[]): unknown; all(...args: unknown[]): unknown[] }; + close(): void; +} +const localRequire = createRequire(import.meta.url); + +function readYaml(file: string): unknown { + try { + return parseYaml(readFileSync(file, "utf8")); + } catch { + return undefined; + } +} + +function hasKeyOverride(provider: string, env: NodeJS.ProcessEnv, agentDir: string): boolean { + // Upstream: packages/ai/src/auth-broker/discover.ts:87-110,197-225,321-329. + const configFile = ["config.yml", "config.yaml"] + .map((name) => join(agentDir, name)) + .find((file) => existsSync(file)); + const config = configSchema.safeParse(configFile ? readYaml(configFile) : undefined); + if ( + env.OMP_AUTH_BROKER_URL || + (config.success && (config.data.auth?.broker?.url ?? config.data["auth.broker.url"])) + ) + return true; + // Upstream: packages/ai/src/auth/cascade.ts:22-84. + const models = modelsSchema.safeParse(readYaml(join(agentDir, "models.yml"))); + return models.success && models.data.providers?.[provider]?.apiKey !== undefined; +} + +function readCredential( + db: UsageDb, + provider: string, + sessionId: string, +): z.infer | null { + // Upstream: packages/ai/src/auth/affinity.ts:83-88,115-132; sqlite-credential-store.ts:447-448,711-722. + const cache = db + .prepare( + "SELECT value FROM cache WHERE key = ? AND expires_at > CAST(strftime('%s','now') AS INTEGER)", + ) + .get(`session:sticky:${provider}:${sessionId}`) as { value?: unknown } | undefined; + if (cache) { + const sticky = stickySchema.safeParse(JSON.parse(String(cache.value))); + if (!sticky.success || sticky.data.type !== "oauth") return null; + return parseOAuthCredential( + db + .prepare( + "SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE id = ?", + ) + .get(sticky.data.credentialId), + provider, + ); + } + // Upstream: packages/ai/src/auth/cascade.ts:276-345 selects OAuth before env or stored API keys. + const rows = db + .prepare( + "SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE provider = ? AND credential_type = 'oauth' AND disabled_cause IS NULL", + ) + .all(provider); + const validRows = rows + .map((row) => parseOAuthCredential(row, provider)) + .filter((row): row is z.infer => row !== null); + return validRows.length === 1 ? validRows[0] : null; +} + +function parseOAuthCredential( + value: unknown, + provider: string, +): z.infer | null { + const row = rowSchema.safeParse(value); + if ( + !row.success || + row.data.provider !== provider || + row.data.credential_type !== "oauth" || + row.data.disabled_cause !== null + ) + return null; + try { + // Upstream: packages/ai/src/auth/sqlite-credential-store.ts:117-155 stores OAuth data as JSON without type. + const oauth = oauthSchema.safeParse(JSON.parse(row.data.data)); + return oauth.success && oauth.data.expires > Date.now() ? oauth.data : null; + } catch { + return null; + } +} + +/** Read OMP's current credential attribution without changing its store. */ +export function resolveOmpUsageReference( + sessionId: string, + provider: string, + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, +): UsageReference | null { + let source: string; + if (provider === "openai-codex") source = "codex"; + else if (provider === "anthropic") source = "claude"; + else return null; + if (!sessionId) return null; + // Upstream: packages/utils/src/dirs.ts:82-94,330-343,583-585 (profile, override, agent dir). + const paths = resolveOmpDiagnosticPaths(env, undefined, platform); + if (hasKeyOverride(provider, env, paths.agentDir)) return null; + let db: UsageDb; + try { + const sqlite = localRequire("node:sqlite") as { + DatabaseSync: new (path: string, options: { readOnly: boolean }) => UsageDb; + }; + db = new sqlite.DatabaseSync(paths.agentDb, { readOnly: true }); + } catch { + return null; + } + try { + const oauth = readCredential(db, provider, sessionId); + if (!oauth) return null; + if (source === "claude") { + return { source, input: { accessToken: oauth.access } }; + } + return { + source, + input: { + accessToken: oauth.access, + ...(oauth.accountId ? { accountId: oauth.accountId } : {}), + }, + }; + } catch { + return null; + } finally { + db.close(); + } +} diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 1ba8b282a9..9406ddbce4 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -333,6 +333,94 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { model: TEST_MODEL, }); + test("usage reference follows the active OpenCode model and OAuth account", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient( + logger, + { + env: { + OPENCODE_AUTH_CONTENT: JSON.stringify({ + openai: { type: "oauth", access: "oauth-token", accountId: "acct-1" }, + "opencode-go": { type: "api", key: "go-key" }, + }), + }, + }, + { serverManager: runtime, createClient: runtime.createClient }, + ); + const session = await client.createSession(buildConfig(cwd)); + await session.setModel?.("openai/gpt-5"); + expect(await session.getUsageReference?.()).toEqual({ + source: "codex", + input: { accessToken: "oauth-token", accountId: "acct-1" }, + }); + await session.setModel?.("opencode-go/qwen"); + expect(await session.getUsageReference?.()).toEqual({ + source: "opencode-go", + input: { apiKey: "go-key" }, + }); + await session.setModel?.("other/model"); + expect(await session.getUsageReference?.()).toBeNull(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + const sessionId = (await session.getRuntimeInfo()).sessionId; + openCode.emitEvent({ + type: "message.updated", + properties: { + info: { + id: "msg_changed_model", + sessionID: sessionId, + role: "user", + model: { providerID: "openai", modelID: "gpt-5" }, + }, + }, + } as OpenCodeEvent); + await vi.waitFor(async () => + expect(await session.getUsageReference?.()).toEqual({ + source: "codex", + input: { accessToken: "oauth-token", accountId: "acct-1" }, + }), + ); + expect(events).toContainEqual({ + type: "model_changed", + provider: "opencode", + runtimeInfo: { provider: "opencode", sessionId, model: "openai/gpt-5", modeId: null }, + }); + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + + test("usage reference ignores another auth entry with a different type", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + runtime.enqueueClient(new TestOpenCodeClient()); + const client = new OpenCodeAgentClient( + logger, + { + env: { + OPENCODE_AUTH_CONTENT: JSON.stringify({ + openai: { type: "api", key: "other-key" }, + "opencode-go": { type: "api", key: "go-key" }, + }), + }, + }, + { serverManager: runtime, createClient: runtime.createClient }, + ); + const session = await client.createSession(buildConfig(cwd)); + try { + await session.setModel?.("opencode-go/qwen"); + expect(await session.getUsageReference?.()).toEqual({ + source: "opencode-go", + input: { apiKey: "go-key" }, + }); + } finally { + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }); + test("creates a session with valid id and provider", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 2276e85786..e92a424ee6 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -12,6 +12,8 @@ import { type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { randomBytes } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; @@ -56,6 +58,7 @@ import { type SteerResult, type ToolCallDetail, type ToolCallTimelineItem, + type UsageReference, } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; import { @@ -1500,6 +1503,7 @@ export class OpenCodeAgentClient implements AgentClient { false, unbindBridge, connectServer, + { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, ); } catch (error) { await connection.release(); @@ -1554,6 +1558,7 @@ export class OpenCodeAgentClient implements AgentClient { registeredAcquisition !== null, unbindBridge, connectServer, + { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, ); } catch (error) { await connection.release(); @@ -3279,7 +3284,7 @@ function isOpenCodeTerminalEvent(event: OpenCodeEvent, sessionId: string): boole } function isOpenCodeProviderInternalEvent(event: AgentStreamEvent): boolean { - return event.type === "provider_subagent"; + return event.type === "provider_subagent" || event.type === "model_changed"; } function readOpenCodeChildSessionInfo(value: unknown): OpenCodeChildSessionInfo | null { @@ -3360,6 +3365,67 @@ interface OpenCodeServerConnection { release: () => Promise; } +const openCodeOAuthAuthSchema = z + .object({ + openai: z + .object({ + type: z.literal("oauth"), + access: z.string().min(1), + accountId: z.string().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); +const openCodeGoAuthSchema = z + .object({ + "opencode-go": z + .object({ type: z.literal("api"), key: z.string().min(1) }) + .passthrough() + .optional(), + }) + .passthrough(); + +async function readOpenCodeUsageAuth(env: NodeJS.ProcessEnv): Promise { + try { + const content = + env.OPENCODE_AUTH_CONTENT ?? + (await fs.readFile( + path.join( + env.XDG_DATA_HOME || path.join(env.HOME || os.homedir(), ".local", "share"), + "opencode", + "auth.json", + ), + "utf8", + )); + return JSON.parse(content); + } catch { + return null; + } +} + +function resolveOpenCodeUsageReference(model: string, auth: unknown): UsageReference | null { + if (!auth) return null; + if (model.startsWith("openai/")) { + const parsed = openCodeOAuthAuthSchema.safeParse(auth); + if (!parsed.success) return null; + const credential = parsed.data.openai; + if (!credential) return null; + return { + source: "codex", + input: { + accessToken: credential.access, + ...(credential.accountId ? { accountId: credential.accountId } : {}), + }, + }; + } + const parsed = openCodeGoAuthSchema.safeParse(auth); + if (!parsed.success) return null; + const credential = parsed.data["opencode-go"]; + if (!credential) return null; + return { source: "opencode-go", input: { apiKey: credential.key } }; +} + class OpenCodeAgentSession implements AgentSession { readonly provider = "opencode" as const; readonly capabilities = OPENCODE_CAPABILITIES; @@ -3448,6 +3514,7 @@ class OpenCodeAgentSession implements AgentSession { private readonly externallyDriven = false, releaseBridge?: () => void, connectServer?: () => Promise, + private readonly usageEnv: NodeJS.ProcessEnv = process.env, ) { this.config = config; this.server = { client, events, url: serverUrl ?? "", release: releaseServer }; @@ -3552,6 +3619,13 @@ class OpenCodeAgentSession implements AgentSession { }; } + async getUsageReference(): Promise { + const model = this.config.model; + if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null; + const auth = await readOpenCodeUsageAuth(this.usageEnv); + return resolveOpenCodeUsageReference(model, auth); + } + async setModel(modelId: string | null): Promise { const normalizedModelId = typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; @@ -5346,6 +5420,7 @@ class OpenCodeAgentSession implements AgentSession { private async translateEvent(event: OpenCodeEvent): Promise { const eventSessionId = getOpenCodeEventSessionId(event); + const runtimeModelChanged = this.syncRuntimeModel(event, eventSessionId); if ( event.type !== "session.created" && eventSessionId && @@ -5362,6 +5437,12 @@ class OpenCodeAgentSession implements AgentSession { } } const translated = translateOpenCodeEvent(event, this.createTranslationState()); + if (runtimeModelChanged) + translated.push({ + type: "model_changed", + provider: this.provider, + runtimeInfo: await this.getRuntimeInfo(), + }); this.appendProviderSubagentEvents(event, translated); const events: AgentStreamEvent[] = []; @@ -5402,6 +5483,20 @@ class OpenCodeAgentSession implements AgentSession { return events; } + private syncRuntimeModel(event: OpenCodeEvent, eventSessionId: string | null): boolean { + if (event.type !== "message.updated" || eventSessionId !== this.sessionId) return false; + const info = event.properties.info; + let model: string | undefined; + if (info.role === "assistant") model = resolveOpenCodeModelLookupKeyFromAssistantMessage(info); + if (info.role === "user" && info.model) + model = buildOpenCodeModelLookupKey(info.model.providerID, info.model.modelID); + if (!model || model === this.config.model) return false; + this.config.model = model; + this.selectedModelContextWindowMaxTokens = + this.resolveConfiguredModelContextWindowMaxTokens(model); + return true; + } + private async tryAutoApproveToolPermission( request: AgentPermissionRequest, directory: string, diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 969b221f02..7308185db0 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -23,7 +23,7 @@ import { PiRpcAgentSession, transformPiModels, } from "./agent.js"; -import { FakePi } from "./test-utils/fake-pi.js"; +import { FakePi, type FakePiSession } from "./test-utils/fake-pi.js"; import { createPiExtensionHost } from "./extensions/index.js"; import { PiExtensionHost } from "./extensions/host.js"; import type { PiModel, PiThinkingLevel } from "./rpc-types.js"; @@ -202,6 +202,71 @@ async function createSession( return { pi, session, events }; } +test("Pi usage reference follows the current OAuth model and agent directory", async () => { + const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-usage-")); + try { + writeFileSync( + path.join(agentDir, "auth.json"), + JSON.stringify({ + "openai-codex": { + type: "oauth", + access: "codex-token", + accountId: "account-1", + refresh: "refresh", + expires: Date.now() + 60_000, + }, + anthropic: { + type: "oauth", + access: "claude-token", + refresh: "refresh", + expires: Date.now() + 60_000, + }, + }), + ); + const pi = new FakePi(); + let runtime!: FakePiSession; + pi.queueSessionSetup((sessionRuntime) => { + runtime = sessionRuntime; + runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" }; + }); + const session = await createClient(pi).createSession(createConfig(), { + env: { PI_CODING_AGENT_DIR: agentDir }, + }); + expect(await session.getUsageReference?.()).toEqual({ + source: "codex", + input: { accessToken: "codex-token", accountId: "account-1" }, + }); + runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" }; + expect(await session.getUsageReference?.()).toEqual({ + source: "claude", + input: { accessToken: "claude-token" }, + }); + writeFileSync( + path.join(agentDir, "auth.json"), + JSON.stringify({ + "openai-codex": { type: "oauth", access: "codex-token" }, + anthropic: { type: "api_key", key: "api-key" }, + }), + ); + runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" }; + expect(await session.getUsageReference?.()).toEqual({ + source: "codex", + input: { accessToken: "codex-token" }, + }); + runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" }; + writeFileSync( + path.join(agentDir, "auth.json"), + JSON.stringify({ anthropic: { type: "api_key", key: "api-key" } }), + ); + expect(await session.getUsageReference?.()).toBeNull(); + runtime.state.model = { provider: "other", id: "other", name: "Other" }; + expect(await session.getUsageReference?.()).toBeNull(); + await session.close(); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } +}); + test("forwards launch-context env to the Pi process launch", async () => { const pi = new FakePi(); const client = createClient(pi); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 5fd7b051ef..5d7966367e 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join, resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; @@ -242,6 +243,7 @@ interface PiRpcAgentSessionOptions { extensionTimeoutMs?: number; logger: Logger; usagePollScheduler?: PiUsagePollScheduler; + usageEnv?: Record; } interface PiResumeConfig { @@ -528,6 +530,27 @@ function resolvePiAgentDir(env: Record | undefined): string { return resolvePath(configured); } +const piCodexUsageAuthSchema = z + .object({ + "openai-codex": z + .object({ + type: z.literal("oauth"), + access: z.string().min(1), + accountId: z.string().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); +const piClaudeUsageAuthSchema = z + .object({ + anthropic: z + .object({ type: z.literal("oauth"), access: z.string().min(1) }) + .passthrough() + .optional(), + }) + .passthrough(); + function readPiGlobalMcpConfig(env: Record | undefined): Record { const globalConfigPath = join(resolvePiAgentDir(env), "mcp.json"); if (!existsSync(globalConfigPath)) { @@ -1122,6 +1145,35 @@ export class PiRpcAgentSession implements AgentSession { readonly provider: AgentProvider; readonly capabilities: AgentCapabilityFlags; + async getUsageReference() { + await this.refreshState(); + const provider = this.state.model?.provider; + let source: string | null = null; + if (provider === "openai-codex") source = "codex"; + if (provider === "anthropic") source = "claude"; + if (!source || !provider) return null; + try { + const auth: unknown = JSON.parse( + await readFile(join(resolvePiAgentDir(this.usageEnv), "auth.json"), "utf8"), + ); + if (provider === "openai-codex") { + const credential = piCodexUsageAuthSchema.parse(auth)["openai-codex"]; + if (!credential) return null; + return { + source, + input: { + accessToken: credential.access, + ...(credential.accountId ? { accountId: credential.accountId } : {}), + }, + }; + } + const credential = piClaudeUsageAuthSchema.parse(auth).anthropic; + return credential ? { source, input: { accessToken: credential.access } } : null; + } catch { + return null; + } + } + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly activeToolCalls = new Map(); private readonly pendingExtensionUiRequests = new Map(); @@ -1149,6 +1201,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly currentModeId: string | null; private readonly logger: Logger; private readonly usagePoller: PiUsagePoller; + private readonly usageEnv?: Record; private closed = false; private readonly closeController = new AbortController(); private readonly pendingExtensionHydrations = new Set>(); @@ -1168,6 +1221,7 @@ export class PiRpcAgentSession implements AgentSession { this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS; this.logger = options.logger; this.extensionHost = createPiExtensionHost(this.logger); + this.usageEnv = options.usageEnv; this.usagePoller = new PiUsagePoller({ scheduler: options.usagePollScheduler, readStats: () => this.runtimeSession.getSessionStats(), @@ -2461,6 +2515,7 @@ export class PiRpcAgentClient implements AgentClient { extensionTimeoutMs: this.providerParams.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, + usageEnv: mcpEnv, }); } catch (error) { await runtimeSession.close().catch(() => undefined); @@ -2524,6 +2579,7 @@ export class PiRpcAgentClient implements AgentClient { extensionTimeoutMs: this.providerParams.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, + usageEnv: mcpEnv, }); } catch (error) { await runtimeSession.close().catch(() => undefined); diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 7ce3947fa8..fa260011c3 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -149,6 +149,8 @@ const INBOUND_PERMISSION = { "project.rename.request": "workspace.manage", project_icon_request: "workspace.read", "provider.usage.list.request": "daemon.read", + "usage.list_reports.request": "daemon.read", + "agent.resolve_usage_report.request": "daemon.read", provider_diagnostic_request: "daemon.read", pull_request_timeline_request: "workspace.read", "push.unregister.request": "workspace.read", @@ -373,6 +375,8 @@ const OUTBOUND_PERMISSION = { "project.update": "workspace.read", project_icon_response: "workspace.read", "provider.usage.list.response": "daemon.read", + "usage.list_reports.response": "daemon.read", + "agent.resolve_usage_report.response": "daemon.read", provider_diagnostic_response: "daemon.read", providers_snapshot_update: ["daemon.read", "hub.execute"], pull_request_timeline_response: "workspace.read", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index cfa955eac5..695d7b25cb 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -231,6 +231,7 @@ import { } from "./hub/relationship-remote.js"; import { DaemonExecutions } from "./hub/daemon-executions.js"; import { PluginService } from "./plugins/index.js"; +import { BuiltinPluginLoader } from "./plugins/builtin/index.js"; import { ManagedPluginSources } from "./plugins/managed-source.js"; const MCP_DEBUG_BATCH_LIMIT = 10; @@ -476,6 +477,7 @@ export interface PaseoDaemon { } export interface PaseoDaemonDependencies { + builtinPlugins?: BuiltinPluginLoader; hubRelationshipRemote?: HubRelationshipRemote; hubRelationshipClock?: HubRelationshipClock; hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy; @@ -486,6 +488,10 @@ export interface PaseoDaemonDependencies { }; } +function resolveBuiltinPluginLoader(dependencies: PaseoDaemonDependencies): BuiltinPluginLoader { + return dependencies.builtinPlugins ?? new BuiltinPluginLoader(); +} + function createBootstrapManagedProcessRegistry( config: Pick, logger: Logger, @@ -611,6 +617,7 @@ export async function createPaseoDaemon( const browserToolsBroker = new BrowserToolsBroker({}); const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, { managedSources: new ManagedPluginSources(config.paseoHome), + builtinPlugins: resolveBuiltinPluginLoader(dependencies), settingsDirectory: path.join(config.paseoHome, "plugin-settings"), }); diff --git a/packages/server/src/server/plugins/builtin/index.test.ts b/packages/server/src/server/plugins/builtin/index.test.ts new file mode 100644 index 0000000000..6160c8f855 --- /dev/null +++ b/packages/server/src/server/plugins/builtin/index.test.ts @@ -0,0 +1,134 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, expect, test } from "vitest"; +import { compilePlugin } from "../compiler.js"; +import { readPluginManifest } from "../manifest.js"; +import { DaemonClient } from "../../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader, builtinPlugins, resolveBuiltinPluginsRoot } from "./index.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function fixture(root: string, id: string, client = false): Promise { + const directory = path.join(root, id); + await mkdir(directory, { recursive: true }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id, requirements: { paseo: ">=0.9.2" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + "export default function contribute() { return () => {}; }", + ); + if (client) { + await writeFile( + path.join(directory, "index.client.tsx"), + "export default function contribute() { const marker = 'builtin-client-marker'; void marker; return () => {}; }", + ); + } + return directory; +} + +test("listed built-ins resolve to matching manifests and compile", async () => { + const root = resolveBuiltinPluginsRoot(); + for (const id of builtinPlugins) { + const directory = path.join(root, id); + expect((await readPluginManifest(directory)).id).toBe(id); + const findEntry = async (names: string[]) => { + for (const name of names) { + const entry = path.join(directory, name); + if ((await stat(entry).catch(() => null))?.isFile()) return entry; + } + return null; + }; + const bundles = await compilePlugin({ + server: await findEntry(["index.server.ts", "index.server.tsx"]), + client: await findEntry(["index.client.ts", "index.client.tsx"]), + }); + expect(bundles.serverBundle).toBeTruthy(); + } +}); + +test("listed client bundle is published while plugins are disabled; unlisted directory is inert", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-fixture-")); + roots.push(root); + await fixture(root, "listed", true); + await fixture(root, "unlisted", true); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["listed"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await client.connect(); + expect(await client.listPlugins()).toEqual([]); + const catalog = await client.getPluginCatalog(); + expect(catalog.map(({ id }) => id)).toEqual(["listed"]); + expect(catalog[0]?.clientBundle).toContain("builtin-client-marker"); + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); + +test("directory, Git, and npm installs reject a built-in ID", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-install-")); + roots.push(root); + const directory = await fixture(root, "reserved"); + const gitDirectory = await fixture(root, "git-source"); + await writeFile( + path.join(gitDirectory, "paseo-plugin.json"), + JSON.stringify({ id: "reserved", requirements: { paseo: ">=0.9.2" } }), + ); + execFileSync("git", ["init", "-q", gitDirectory]); + execFileSync("git", [ + "-C", + gitDirectory, + "-c", + "user.name=Paseo Tests", + "-c", + "user.email=paseo@example.test", + "add", + ".", + ]); + execFileSync("git", [ + "-C", + gitDirectory, + "-c", + "user.name=Paseo Tests", + "-c", + "user.email=paseo@example.test", + "commit", + "-qm", + "fixture", + ]); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["reserved"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await client.connect(); + await expect(client.installDirectoryPlugin(directory)).rejects.toThrow( + /reserved for a built-in/, + ); + await expect( + client.installPluginSource({ source: pathToFileURL(gitDirectory).href }), + ).rejects.toThrow(/reserved for a built-in/); + await expect( + client.installPluginSource({ source: "npm:unused-fixture", id: "reserved" }), + ).rejects.toThrow(/reserved for a built-in/); + expect(await client.listPlugins()).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts new file mode 100644 index 0000000000..de9b66131e --- /dev/null +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -0,0 +1,46 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const builtinPlugins = [ + "claude-usage-source", + "codex-usage-source", + "copilot-usage-source", + "cursor-usage-source", + "grok-usage-source", + "kimi-usage-source", + "minimax-usage-source", + "opencode-go-usage-source", + "zai-usage-source", +] as const; + +export function resolveBuiltinPluginsRoot(moduleUrl: string | URL = import.meta.url): string { + const moduleDir = path.dirname(fileURLToPath(moduleUrl)); + const candidates = [ + path.resolve(moduleDir, "..", "..", "..", "builtin-plugins"), + path.resolve(moduleDir, "..", "..", "..", "..", "..", "..", "plugins"), + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]!; +} + +export interface BuiltinPlugin { + id: string; + directory: string; +} + +export class BuiltinPluginLoader { + readonly ids: ReadonlySet; + + constructor( + private readonly root = resolveBuiltinPluginsRoot(), + private readonly list: readonly string[] = builtinPlugins, + ) { + this.ids = new Set(list); + } + + async load(start: (plugin: BuiltinPlugin) => Promise): Promise { + for (const id of this.list) { + await start({ id, directory: path.join(this.root, id) }); + } + } +} diff --git a/packages/server/src/server/plugins/bundle-evaluator.ts b/packages/server/src/server/plugins/bundle-evaluator.ts new file mode 100644 index 0000000000..2941d4c4ff --- /dev/null +++ b/packages/server/src/server/plugins/bundle-evaluator.ts @@ -0,0 +1,38 @@ +import { createRequire } from "node:module"; +import * as pluginSharedRuntime from "@getpaseo/plugin"; +import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider"; +import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp"; +import * as pluginUsageRuntime from "@getpaseo/plugin/server/usage"; +import type { PluginServerContribution } from "@getpaseo/plugin/server"; +import * as zod from "zod"; +import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js"; + +const nodeRequire = createRequire(import.meta.url); + +function runtimeRequire(name: string): unknown { + if (isPluginClientOnlySdkSpecifier(name)) { + throw new Error(`${name} is available only in plugin client code`); + } + if (name === "@getpaseo/plugin") return pluginSharedRuntime; + if (name === "@getpaseo/plugin/server") return {}; + if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime; + if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime; + if (name === "@getpaseo/plugin/server/usage") return pluginUsageRuntime; + if (name === "zod") return zod; + if (name === "@getpaseo/plugin/client/host") + throw new Error(`${name} is private to the app host`); + return nodeRequire(name); +} + +export function evaluateBundle(bundle: string): PluginServerContribution { + const evaluate: (source: string) => unknown = globalThis.eval; + const factory = evaluate(bundle); + if (typeof factory !== "function") throw new Error("Plugin server bundle is not executable"); + const exports = factory(runtimeRequire); + const setup = + exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined; + if (typeof setup !== "function") { + throw new Error("Plugin server bundle must default export a function"); + } + return setup as PluginServerContribution; +} diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index ca5ae9f30c..80dbb1b7b0 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -10,6 +10,7 @@ import { } from "./plugin-sdk-specifiers.js"; const nodeRequire = createRequire(import.meta.url); +export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"]; const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH"; // esbuild resolves its own platform binary via require.resolve() the first time its @@ -403,7 +404,7 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom "react-native", "zod", ] - : [...PLUGIN_SDK_SPECIFIERS, "zod"], + : SERVER_HOST_MODULES, plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], metafile: true, logLevel: "silent", diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 2de5903096..4b76a4af25 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -17,6 +17,12 @@ import { runGitCommand } from "../../utils/run-git-command.js"; const roots: string[] = []; type TestPluginRuntime = NonNullable[3]["runtime"]>; +const emptyUsageRuntime = { + getUsageSourceRegistrations: () => [], + fetchUsage: async () => undefined, + discoverUsage: async () => [], +} satisfies Pick; + async function createPlugin(id: string, source: string): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-")); roots.push(directory); @@ -104,6 +110,7 @@ function createPausedRuntime() { }); const running = new Set(); const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -136,6 +143,7 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) { const starts: string[] = []; const running = new Set(); const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -235,6 +243,7 @@ describe("PluginService", () => { ]; const cleared: string[] = []; const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [], invoke: async () => undefined, getLogs: () => entries, @@ -620,6 +629,7 @@ export default function contribute(server: PluginServerContext) { const events: string[] = []; const running = new Set(); const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -703,6 +713,7 @@ export default function contribute(server: PluginServerContext) { const starts: string[] = []; let failNextStart = true; const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -766,6 +777,7 @@ export default function contribute(server: PluginServerContext) { const running = new Set(); let starts = 0; const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index c1e404bb07..f11b7c50a4 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -23,8 +23,12 @@ import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-sou import { readPluginManifest } from "./manifest.js"; import { runPluginBuild } from "./preparation.js"; import { PluginRuntime } from "./runtime.js"; +import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js"; import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; import { readPluginProviderIcon } from "./provider-icon.js"; +import { UsageSourceRegistry } from "./usage-sources/index.js"; +import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js"; +import type { UsageReference } from "../agent/agent-sdk-types.js"; const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); @@ -36,10 +40,15 @@ interface PluginRuntimePort { getLogs(pluginId: string): PluginLogEntry[]; clearLogs(pluginId: string): void; getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[]; + getUsageSourceRegistrations(pluginId: string): readonly PluginUsageSourceMetadata[]; + identifyUsage: PluginRuntime["identifyUsage"]; + fetchUsage: PluginRuntime["fetchUsage"]; + discoverUsage: PluginRuntime["discoverUsage"]; connectProvider: PluginRuntime["connectProvider"]; getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"]; validatePlugin?(path: string): Promise; startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; + startBuiltinPlugin?(plugin: BuiltinPlugin): Promise; stopPluginById(pluginId: string): Promise; stopAll(): Promise; subscribe(listener: (pluginId: string, error?: string) => void): () => void; @@ -50,6 +59,7 @@ interface PluginServiceDependencies { settingsDirectory?: string; runtime?: PluginRuntimePort; managedSources?: ManagedPluginSources; + builtinPlugins?: BuiltinPluginLoader; } function resolvePluginStatus(input: { @@ -64,10 +74,14 @@ function resolvePluginStatus(input: { export class PluginService { private readonly runtime: PluginRuntimePort; private readonly managedSources: ManagedPluginSources | null; + private readonly builtinPluginIds: ReadonlySet; + private readonly builtinPlugins: BuiltinPluginLoader; private readonly logger: pino.Logger; private readonly errors = new Map(); private readonly listeners = new Set<(pluginId: string) => void>(); private readonly providers = new Map(); + private readonly usageSources = new UsageSourceRegistry(); + private readonly usageSourceIdsByPlugin = new Map(); private readonly providerIdsByPlugin = new Map(); private readonly providerListeners = new Set<() => void>(); private lifecycle = Promise.resolve(); @@ -91,8 +105,11 @@ export class PluginService { }, }); this.managedSources = dependencies.managedSources ?? null; + this.builtinPlugins = dependencies.builtinPlugins ?? new BuiltinPluginLoader(undefined, []); + this.builtinPluginIds = this.builtinPlugins.ids; this.runtime.subscribe((pluginId, error) => { this.removeProviderRegistrations(pluginId); + this.removeUsageSources(pluginId); if (error) this.errors.set(pluginId, error); this.notify(pluginId); }); @@ -127,6 +144,22 @@ export class PluginService { return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id)); } + listUsageReports(options?: { + forceRefresh?: boolean; + reportIds?: string[]; + references?: UsageReference[]; + }) { + return this.usageSources.listReports(options); + } + + resolveUsageReference(reference: UsageReference) { + return this.usageSources.resolveReference(reference); + } + + listLegacyUsage() { + return this.usageSources.listLegacyUsage(); + } + subscribeProviderRegistrations(listener: () => void): () => void { this.providerListeners.add(listener); return () => this.providerListeners.delete(listener); @@ -137,6 +170,15 @@ export class PluginService { this.started = true; const config = this.configStore.get(); this.globalStartsBlocked = config.pluginsEnabled !== true; + await this.builtinPlugins.load(async (plugin) => { + try { + await this.runtime.startBuiltinPlugin?.(plugin); + await this.publishProviderRegistrations(plugin.id, plugin.directory); + this.publishUsageSources(plugin.id); + } catch (error) { + this.logger.error({ err: error, pluginId: plugin.id }, "Failed to start built-in plugin"); + } + }); if (config.pluginsEnabled === true) { for (const [pluginId, source] of Object.entries(config.plugins ?? {})) { if (source.enabled === false) continue; @@ -186,7 +228,9 @@ export class PluginService { } getLogs(pluginId: string): PluginLogEntry[] { - this.requireSource(pluginId); + if (!this.builtinPluginIds.has(pluginId)) { + this.requireSource(pluginId); + } return this.runtime.getLogs(pluginId); } @@ -200,6 +244,9 @@ export class PluginService { const manifest = await readPluginManifest(directory); assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); const pluginId = PluginIdSchema.parse(input.id ?? manifest.id); + if (this.builtinPluginIds.has(pluginId)) { + throw new Error(`Plugin ID "${pluginId}" is reserved for a built-in plugin`); + } if (this.configStore.get().plugins?.[pluginId]) { throw new Error( `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, @@ -229,6 +276,9 @@ export class PluginService { id?: string; ref?: string; }): Promise { + if (input.id && this.builtinPluginIds.has(input.id)) { + throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`); + } const directDirectory = path.resolve(input.source); const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source); const directInfo = await stat(directDirectory).catch(() => null); @@ -254,6 +304,9 @@ export class PluginService { try { await this.checkRequirements(candidate.directory); pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId); + if (this.builtinPluginIds.has(pluginId)) { + throw new Error(`Plugin ID "${pluginId}" is reserved for a built-in plugin`); + } if (this.configStore.get().plugins?.[pluginId]) { throw new Error( `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, @@ -428,7 +481,7 @@ export class PluginService { private handleGlobalSwitch(enabled: boolean): void { if (!enabled) { this.globalStartsBlocked = true; - const stopping = this.stopAll(); + const stopping = this.stopConfiguredPlugins(); for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id); void this.enqueue(async () => { await stopping; @@ -481,9 +534,11 @@ export class PluginService { await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); try { await this.publishProviderRegistrations(pluginId, sourcePath); + this.publishUsageSources(pluginId); } catch (error) { try { this.removeProviderRegistrations(pluginId); + this.removeUsageSources(pluginId); } finally { await this.runtime.stopPluginById(pluginId); } @@ -493,16 +548,65 @@ export class PluginService { private stopPlugin(pluginId: string): Promise { this.removeProviderRegistrations(pluginId); + this.removeUsageSources(pluginId); return this.runtime.stopPluginById(pluginId); } + private async stopConfiguredPlugins(): Promise { + const stopping = Object.keys(this.configStore.get().plugins ?? {}).map((id) => + this.stopPlugin(id), + ); + await Promise.all(stopping); + } + private async stopAll(): Promise { for (const pluginId of this.providerIdsByPlugin.keys()) { this.removeProviderRegistrations(pluginId); } + for (const pluginId of this.usageSourceIdsByPlugin.keys()) this.removeUsageSources(pluginId); await this.runtime.stopAll(); } + private publishUsageSources(pluginId: string): void { + const metadata = this.runtime.getUsageSourceRegistrations(pluginId); + const registered: string[] = []; + try { + for (const source of metadata) { + this.usageSources.register({ + id: source.id, + label: source.label, + icon: source.icon, + discover: async () => { + if (!source.discover) return []; + const result = await this.runtime.discoverUsage(pluginId, source.id); + if (!Array.isArray(result)) + throw new Error(`Invalid usage discovery from ${source.id}`); + return result; + }, + identify: (input) => + this.runtime.identifyUsage(pluginId, source.id, input) as Promise<{ + key: string; + label?: string; + } | null>, + fetch: (input) => { + return this.runtime.fetchUsage(pluginId, source.id, input); + }, + }); + registered.push(source.id); + } + } catch (error) { + for (const id of registered) this.usageSources.unregister(id); + throw error; + } + this.usageSourceIdsByPlugin.set(pluginId, registered); + } + + private removeUsageSources(pluginId: string): void { + for (const id of this.usageSourceIdsByPlugin.get(pluginId) ?? []) + this.usageSources.unregister(id); + this.usageSourceIdsByPlugin.delete(pluginId); + } + private async publishProviderRegistrations( pluginId: string, pluginDirectory: string, diff --git a/packages/server/src/server/plugins/internal-child.ts b/packages/server/src/server/plugins/internal-child.ts new file mode 100644 index 0000000000..a80a8c5ec5 --- /dev/null +++ b/packages/server/src/server/plugins/internal-child.ts @@ -0,0 +1,66 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { PluginServerContribution } from "@getpaseo/plugin/server"; +import { createPluginWorker, type PluginWorkerChannel } from "./plugin-process.js"; +import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; + +export class InternalPluginChild extends EventEmitter { + connected = true; + killed = false; + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + private readonly worker: ReturnType; + private readonly workerMessages = new Set<(message: PluginProcessRequest) => void>(); + private closing: Promise | null = null; + + constructor(contribute: PluginServerContribution) { + super(); + const channel: PluginWorkerChannel = { + send: (message: PluginProcessMessage, callback?: () => void) => { + const copy = structuredClone(message); + queueMicrotask(() => { + if (this.connected) this.emit("message", copy); + callback?.(); + }); + }, + onMessage: (handler) => { + this.workerMessages.add(handler); + return () => this.workerMessages.delete(handler); + }, + disconnect: () => this.disconnect(), + }; + this.worker = createPluginWorker({ channel, contribute }); + } + + send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean { + if (!this.connected) { + callback?.(new Error("Plugin child is disconnected")); + return false; + } + const copy = structuredClone(message); + queueMicrotask(() => { + for (const handler of this.workerMessages) handler(copy); + callback?.(null); + }); + return true; + } + + kill(): boolean { + if (this.killed) return false; + this.killed = true; + this.disconnect(); + return true; + } + + disconnect(): void { + if (this.closing) return; + this.closing = Promise.resolve() + .then(() => this.worker.shutdown()) + .finally(() => { + this.connected = false; + this.stdout.end(); + this.stderr.end(); + this.emit("close", 0, null); + }); + } +} diff --git a/packages/server/src/server/plugins/internal-seam.e2e.test.ts b/packages/server/src/server/plugins/internal-seam.e2e.test.ts new file mode 100644 index 0000000000..a3771f4ac5 --- /dev/null +++ b/packages/server/src/server/plugins/internal-seam.e2e.test.ts @@ -0,0 +1,82 @@ +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; + +const directory = fileURLToPath(new URL("./test-fixtures/internal-seam/", import.meta.url)); +const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url)); + +test("built-in and directory plugins share RPC and lifecycle behavior while built-ins stay hidden and enabled", async () => { + const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-internal-seam-")); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.8.0", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(fixtureRoot, ["internal-seam"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await client.connect(); + expect(await client.listPlugins()).toEqual([]); + expect(await client.invokePluginRpc("internal-seam", "state", {})).toEqual({ workspaces: 0 }); + expect((await client.getPluginLogs("internal-seam")).map(({ message }) => message)).toContain( + "[paseo] Plugin ready", + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory, "directory-seam"); + expect((await client.listPlugins()).map(({ id }) => id)).toEqual(["directory-seam"]); + expect(await client.invokePluginRpc("directory-seam", "state", {})).toEqual({ workspaces: 0 }); + await client.createWorkspace({ + idempotencyKey: "internal-seam-workspace", + source: { kind: "directory", path: workspaceDirectory }, + }); + await expect + .poll(() => client.invokePluginRpc("internal-seam", "state", {})) + .toEqual({ workspaces: 1 }); + await expect + .poll(() => client.invokePluginRpc("directory-seam", "state", {})) + .toEqual({ workspaces: 1 }); + await client.patchDaemonConfig({ pluginsEnabled: false }); + expect(await client.invokePluginRpc("internal-seam", "state", {})).toEqual({ workspaces: 1 }); + expect(await client.listPlugins()).toMatchObject([ + { id: "directory-seam", status: "disabled" }, + ]); + } finally { + await client.close(); + await daemon.close(); + await rm(workspaceDirectory, { recursive: true, force: true }); + } +}, 60_000); + +test("a failing built-in does not stop the daemon or the next built-in", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-builtin-failure-")); + const failingDirectory = path.join(root, "failing-builtin"); + await mkdir(failingDirectory); + await writeFile( + path.join(failingDirectory, "paseo-plugin.json"), + JSON.stringify({ id: "failing-builtin", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(failingDirectory, "index.server.ts"), + "export default function contribute() { throw new Error('fixture startup failure'); }", + ); + await cp(directory, path.join(root, "internal-seam"), { recursive: true }); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.8.0", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["failing-builtin", "internal-seam"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await client.connect(); + expect(await client.invokePluginRpc("internal-seam", "state", {})).toEqual({ workspaces: 0 }); + expect(await client.listPlugins()).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 0066fad500..16ee512c37 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -15,12 +15,20 @@ export interface PluginProviderMetadata { iconPath?: string; } +export interface PluginUsageSourceMetadata { + id: string; + label: string; + icon?: string; + discover: boolean; +} + export type PluginProcessRequest = | { type: "initialize"; pluginId: string; bundle: string; appVersion: string; + pluginDirectory: string; settingsDirectory?: string; } | { @@ -31,6 +39,9 @@ export type PluginProcessRequest = } | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } | { type: "hook.cancel"; requestId: string } + | { type: "usage.identify"; requestId: string; sourceId: string; input: unknown } + | { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown } + | { type: "usage.discover"; requestId: string; sourceId: string } | { type: "invoke"; requestId: string; method: string; input: unknown } | { type: "provider.connect"; @@ -56,6 +67,7 @@ export type PluginProcessMessage = type: "ready"; methods: string[]; providers: PluginProviderMetadata[]; + usageSources?: PluginUsageSourceMetadata[]; hooks?: { events: string[]; before: string[] }; } | { type: "result"; requestId: string; output: unknown } @@ -91,6 +103,14 @@ const providerMetadataSchema = z hasCatalogCacheKey: z.boolean().optional(), }) .strict(); +const usageSourceMetadataSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + icon: z.string().optional(), + discover: z.boolean(), + }) + .strict(); const providerConnectRequestSchema = z .object({ versions: z.array(z.number().int().positive()), @@ -111,6 +131,7 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis pluginId: z.string().min(1), bundle: z.string(), appVersion: z.string(), + pluginDirectory: z.string(), settingsDirectory: z.string().optional(), }) .strict(), @@ -141,6 +162,25 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis }) .strict(), z.object({ type: z.literal("hook.cancel"), requestId: z.string() }).strict(), + z + .object({ + type: z.literal("usage.identify"), + requestId: z.string(), + sourceId: z.string(), + input: z.unknown(), + }) + .strict(), + z + .object({ + type: z.literal("usage.fetch"), + requestId: z.string(), + sourceId: z.string(), + input: z.unknown(), + }) + .strict(), + z + .object({ type: z.literal("usage.discover"), requestId: z.string(), sourceId: z.string() }) + .strict(), z .object({ type: z.literal("invoke"), @@ -182,6 +222,7 @@ export const PluginProcessMessageSchema: z.ZodType = z.dis type: z.literal("ready"), methods: z.array(z.string()), providers: z.array(providerMetadataSchema), + usageSources: z.array(usageSourceMetadataSchema).optional(), hooks: hooksSchema.optional(), }) .strict(), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 1cc0f571a1..1ed1bd6fc3 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,15 +1,14 @@ import { PluginHookHandlers } from "./lifecycle/index.js"; +import { evaluateBundle } from "./bundle-evaluator.js"; import { PluginProcessRequestSchema, type PluginProcessMessage, type PluginProcessRequest, } from "./plugin-process-protocol.js"; -import { createRequire } from "node:module"; -import * as pluginSharedRuntime from "@getpaseo/plugin"; -import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider"; -import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp"; +import type { UsageSourceRegistration } from "@getpaseo/plugin/server/usage"; import type { SettingsDefinition, PluginRpcContract } from "@getpaseo/plugin"; -import type { PluginHandlerContext } from "@getpaseo/plugin/server"; +import type { PluginHandlerContext, PluginServerContribution } from "@getpaseo/plugin/server"; +import { fileURLToPath } from "node:url"; import type { ZodType } from "zod"; import { ProviderEventSchema, @@ -19,341 +18,345 @@ import { import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { createPluginDaemonTransportFactory } from "./daemon-transport.js"; -import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js"; import { createPluginClientId } from "./plugin-session-identity.js"; import { PluginSettingsStore } from "./settings/index.js"; -let settingsStore: PluginSettingsStore | null = null; -function registerSettings(definition: SettingsDefinition) { - if (!settingsStore) throw new Error("Plugin settings storage is unavailable"); - const handlers = settingsStore.register(definition); - register(handlers.read.contract, handlers.read.handle); - register(handlers.write.contract, (input) => - handlers.write.handle(handlers.write.contract.input.parse(input)), - ); - register(handlers.reset.contract, (input) => - handlers.reset.handle(handlers.reset.contract.input.parse(input)), - ); - return handlers.settings; -} - -type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise; - -interface RegisteredRpc { - contract: PluginRpcContract; - handler: RpcHandler; -} - -const hooks = new PluginHookHandlers(() => { - send({ type: "hooks.changed", hooks: hooks.catalog() }); -}); -const handlers = new Map(); -const providers = new Map(); -const providerConnections = new Map< - string, - { connection: ProviderConnection; unsubscribe: () => void; closing?: Promise } ->(); -const pendingProviderConnections = new Map(); -let cleanup: (() => void | Promise) | null = null; -let daemonClient: DaemonClient | null = null; -let paseo: PaseoApi | null = null; -let stopping = false; -const nodeRequire = createRequire(import.meta.url); - -function send(message: PluginProcessMessage): void { - process.send?.(message); -} - -function sendAndWait(message: PluginProcessMessage): Promise { - return new Promise((resolve) => { - if (!process.send) { - resolve(); - return; - } - process.send(message, () => resolve()); - }); -} - +import { readPluginProviderIcon } from "./provider-icon.js"; function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function jsonTransportValue(value: Value): Value { - const encoded = JSON.stringify(value); - if (encoded === undefined) throw new Error("Provider value is not JSON-serializable"); - return JSON.parse(encoded) as Value; +export interface PluginWorkerChannel { + send(message: PluginProcessMessage, callback?: () => void): void; + onMessage(handler: (message: PluginProcessRequest) => void): () => void; + disconnect(): void; } -function validateMethod(method: string): string { - const normalized = method.trim(); - if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) { - throw new Error(`Invalid plugin RPC method: ${method}`); +export function createPluginWorker(options: { + channel: PluginWorkerChannel; + contribute: PluginServerContribution; +}): { shutdown(): Promise } { + const { channel, contribute } = options; + let settingsStore: PluginSettingsStore | null = null; + function registerSettings(definition: SettingsDefinition) { + if (!settingsStore) throw new Error("Plugin settings storage is unavailable"); + const handlers = settingsStore.register(definition); + register(handlers.read.contract, handlers.read.handle); + register(handlers.write.contract, (input) => + handlers.write.handle(handlers.write.contract.input.parse(input)), + ); + register(handlers.reset.contract, (input) => + handlers.reset.handle(handlers.reset.contract.input.parse(input)), + ); + return handlers.settings; } - if (handlers.has(normalized)) { - throw new Error(`Duplicate plugin RPC method: ${normalized}`); - } - return normalized; -} -function register(contract: PluginRpcContract, handler: RpcHandler): void { - if (typeof handler !== "function") { - throw new Error(`Plugin RPC ${contract.name} must provide a handler`); - } - const method = validateMethod(contract.name); - handlers.set(method, { contract: { ...contract, name: method }, handler }); -} + type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise; -function registerProvider(provider: ProviderRegistration): void { - const id = provider.id.trim(); - if (!/^[a-z][a-z0-9._-]*$/.test(id)) { - throw new Error(`Invalid plugin provider ID: ${provider.id}`); + interface RegisteredRpc { + contract: PluginRpcContract; + handler: RpcHandler; } - if (!provider.label.trim()) throw new Error(`Plugin provider ${id} requires a label`); - if (typeof provider.connect !== "function") { - throw new Error(`Plugin provider ${id} must implement connect()`); - } - if ( - provider.getCatalogCacheKey !== undefined && - typeof provider.getCatalogCacheKey !== "function" - ) { - throw new Error(`Invalid catalogue key callback for plugin provider ${id}`); - } - if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`); - providers.set(id, { ...provider, id }); -} -function providerMetadata(provider: ProviderRegistration) { - return { - id: provider.id, - label: provider.label, - description: provider.description, - iconPath: provider.icon, - hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined, - }; -} - -async function connectProvider( - message: Extract, -): Promise { - if (stopping) throw new Error("Plugin is stopping"); - const provider = providers.get(message.providerId); - if (!provider) throw new Error(`Unknown plugin provider: ${message.providerId}`); - if ( - providerConnections.has(message.connectionId) || - pendingProviderConnections.has(message.connectionId) - ) { - throw new Error(`Duplicate provider connection: ${message.connectionId}`); + const hooks = new PluginHookHandlers(() => { + send({ type: "hooks.changed", hooks: hooks.catalog() }); + }); + const handlers = new Map(); + const providers = new Map(); + const usageSources = new Map(); + const providerConnections = new Map< + string, + { connection: ProviderConnection; unsubscribe: () => void; closing?: Promise } + >(); + const pendingProviderConnections = new Map(); + let cleanup: (() => void | Promise) | null = null; + let daemonClient: DaemonClient | null = null; + let paseo: PaseoApi | null = null; + let stopping = false; + function send(message: PluginProcessMessage): void { + channel.send(message); } - const pending = { tombstoned: false }; - pendingProviderConnections.set(message.connectionId, pending); - let connection: ProviderConnection; - try { - connection = await provider.connect(message.request); - } catch (error) { + + function sendAndWait(message: PluginProcessMessage): Promise { + return new Promise((resolve) => { + channel.send(message, () => resolve()); + }); + } + + function jsonTransportValue(value: Value): Value { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error("Provider value is not JSON-serializable"); + return JSON.parse(encoded) as Value; + } + + function validateMethod(method: string): string { + const normalized = method.trim(); + if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) { + throw new Error(`Invalid plugin RPC method: ${method}`); + } + if (handlers.has(normalized)) { + throw new Error(`Duplicate plugin RPC method: ${normalized}`); + } + return normalized; + } + + function register(contract: PluginRpcContract, handler: RpcHandler): void { + if (typeof handler !== "function") { + throw new Error(`Plugin RPC ${contract.name} must provide a handler`); + } + const method = validateMethod(contract.name); + handlers.set(method, { contract: { ...contract, name: method }, handler }); + } + + function registerProvider(provider: ProviderRegistration): void { + const id = provider.id.trim(); + if (!/^[a-z][a-z0-9._-]*$/.test(id)) { + throw new Error(`Invalid plugin provider ID: ${provider.id}`); + } + if (!provider.label.trim()) throw new Error(`Plugin provider ${id} requires a label`); + if (typeof provider.connect !== "function") { + throw new Error(`Plugin provider ${id} must implement connect()`); + } + if ( + provider.getCatalogCacheKey !== undefined && + typeof provider.getCatalogCacheKey !== "function" + ) { + throw new Error(`Invalid catalogue key callback for plugin provider ${id}`); + } + if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`); + providers.set(id, { ...provider, id }); + } + + function registerUsageSource(source: UsageSourceRegistration): void { + const id = source.id.trim(); + if ( + !/^[a-z][a-z0-9._-]*$/.test(id) || + !source.label.trim() || + typeof source.fetch !== "function" || + !source.input || + typeof source.input.parseAsync !== "function" + ) { + throw new Error(`Invalid usage source: ${source.id}`); + } + if (usageSources.has(id)) throw new Error(`Duplicate usage source: ${id}`); + usageSources.set(id, { ...source, id }); + } + + function providerMetadata(provider: ProviderRegistration) { + return { + id: provider.id, + label: provider.label, + description: provider.description, + iconPath: provider.icon, + hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined, + }; + } + + async function connectProvider( + message: Extract, + ): Promise { + if (stopping) throw new Error("Plugin is stopping"); + const provider = providers.get(message.providerId); + if (!provider) throw new Error(`Unknown plugin provider: ${message.providerId}`); + if ( + providerConnections.has(message.connectionId) || + pendingProviderConnections.has(message.connectionId) + ) { + throw new Error(`Duplicate provider connection: ${message.connectionId}`); + } + const pending = { tombstoned: false }; + pendingProviderConnections.set(message.connectionId, pending); + let connection: ProviderConnection; + try { + connection = await provider.connect(message.request); + } catch (error) { + pendingProviderConnections.delete(message.connectionId); + if (pending.tombstoned || stopping) return; + throw error; + } pendingProviderConnections.delete(message.connectionId); - if (pending.tombstoned || stopping) return; - throw error; - } - pendingProviderConnections.delete(message.connectionId); - if (pending.tombstoned || stopping) { - await connection.close().catch(() => undefined); - return; - } - let unsubscribe = () => {}; - unsubscribe = connection.onEvent((event) => { - try { - send({ - type: "provider.event", - connectionId: message.connectionId, - event: ProviderEventSchema.parse(jsonTransportValue(event)), - }); - } catch (error) { - providerConnections.delete(message.connectionId); - unsubscribe(); - void connection.close().catch(() => undefined); - send({ - type: "provider.closed", - connectionId: message.connectionId, - error: describeError(error), - }); - } - }); - providerConnections.set(message.connectionId, { connection, unsubscribe }); - send({ - type: "provider.connected", - connectionId: message.connectionId, - version: connection.version, - capabilities: connection.capabilities, - }); -} - -async function sendProviderInput( - message: Extract, -): Promise { - if (stopping) throw new Error("Plugin is stopping"); - const current = providerConnections.get(message.connectionId); - if (!current) throw new Error(`Unknown provider connection: ${message.connectionId}`); - if (current.closing) throw new Error("Provider connection is closing"); - await current.connection.send(message.input); - send({ - type: "provider.accepted", - connectionId: message.connectionId, - acceptanceId: message.acceptanceId, - }); -} - -// The connection stays registered until its close has reported, so shutdown -// waits for a close already in flight instead of disconnecting underneath it. -async function closeProviderConnection(connectionId: string): Promise { - const current = providerConnections.get(connectionId); - if (!current) return; - if (current.closing) return current.closing; - const closing = (async () => { - current.unsubscribe(); - try { - await current.connection.close(); - send({ type: "provider.closed", connectionId }); - } catch (error) { - send({ type: "provider.closed", connectionId, error: describeError(error) }); - } finally { - providerConnections.delete(connectionId); - } - })(); - current.closing = closing; - return closing; -} - -function runtimeRequire(name: string): unknown { - if (isPluginClientOnlySdkSpecifier(name)) { - throw new Error(`${name} is available only in plugin client code`); - } - if (name === "@getpaseo/plugin") return pluginSharedRuntime; - if (name === "@getpaseo/plugin/server") return {}; - if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime; - if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime; - if (name === "@getpaseo/plugin/client/host") - throw new Error(`${name} is private to the app host`); - return nodeRequire(name); -} - -function evaluateBundle(bundle: string): void { - const evaluate: (source: string) => unknown = globalThis.eval; - const factory = evaluate(bundle); - if (typeof factory !== "function") throw new Error("Plugin server bundle is not executable"); - const exports = factory(runtimeRequire); - const setup = - exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined; - if (typeof setup !== "function") { - throw new Error("Plugin server bundle must default export a function"); - } - const contributedCleanup = setup({ - handle: register, - registerProvider, - registerSettings, - on: hooks.on, - before: hooks.before, - }); - if (typeof contributedCleanup !== "function") { - throw new Error("Plugin contribution must return a cleanup function"); - } - cleanup = contributedCleanup; -} - -const transportFactory = createPluginDaemonTransportFactory({ - send, - onMessage(handler) { - process.on("message", handler); - return () => process.off("message", handler); - }, -}); - -async function initialize(message: Extract) { - daemonClient = new DaemonClient({ - url: `ipc://plugin/${encodeURIComponent(message.pluginId)}`, - clientId: createPluginClientId(message.pluginId), - clientType: "cli", - appVersion: message.appVersion, - // The runtime re-attaches a session when the daemon drops this socket. - reconnect: { enabled: true }, - transportFactory, - }); - paseo = createPaseoApi(daemonClient); - await daemonClient.connect(); - settingsStore = message.settingsDirectory - ? new PluginSettingsStore(message.settingsDirectory, (settingsId) => - send({ type: "settings.changed", settingsId }), - ) - : null; - evaluateBundle(message.bundle); - send({ - type: "ready", - methods: [...handlers.keys()].sort(), - hooks: hooks.catalog(), - providers: [...providers.values()] - .sort((left, right) => left.id.localeCompare(right.id)) - .map(providerMetadata), - }); -} - -async function shutdown(): Promise { - if (stopping) return; - stopping = true; - const releaseApi = paseo - ?.dispose() - .catch((error) => console.error("Plugin API cleanup failed", error)); - hooks.close(); - for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; - const currentCleanup = cleanup; - cleanup = null; - try { - await currentCleanup?.(); - } catch (error) { - console.error("Plugin cleanup failed", error); - } - await Promise.all([...providerConnections.keys()].map(closeProviderConnection)); - await releaseApi; - await daemonClient?.close().catch(() => undefined); - await sendAndWait({ type: "paseo_close" }); - daemonClient = null; - paseo = null; - process.disconnect(); -} - -process.on("message", (rawMessage: unknown) => { - const parsed = PluginProcessRequestSchema.safeParse(rawMessage); - if (!parsed.success) { - const value = rawMessage as { connectionId?: unknown; acceptanceId?: unknown } | null; - if (value && typeof value.connectionId === "string" && typeof value.acceptanceId === "string") { - send({ - type: "provider.rejected", - connectionId: value.connectionId, - acceptanceId: value.acceptanceId, - error: `Invalid provider input: ${parsed.error.message}`, - }); - void closeProviderConnection(value.connectionId); + if (pending.tombstoned || stopping) { + await connection.close().catch(() => undefined); return; } - send({ type: "fatal", error: `Invalid plugin process request: ${parsed.error.message}` }); - void shutdown(); - return; - } - const message = parsed.data; - if (message.type === "initialize") { - void initialize(message).catch(async (error) => { - send({ type: "fatal", error: describeError(error) }); - await paseo - ?.dispose() - .catch((failure) => console.error("Plugin API cleanup failed", failure)); - await daemonClient?.close().catch(() => undefined); + let unsubscribe = () => {}; + unsubscribe = connection.onEvent((event) => { + try { + send({ + type: "provider.event", + connectionId: message.connectionId, + event: ProviderEventSchema.parse(jsonTransportValue(event)), + }); + } catch (error) { + providerConnections.delete(message.connectionId); + unsubscribe(); + void connection.close().catch(() => undefined); + send({ + type: "provider.closed", + connectionId: message.connectionId, + error: describeError(error), + }); + } + }); + providerConnections.set(message.connectionId, { connection, unsubscribe }); + send({ + type: "provider.connected", + connectionId: message.connectionId, + version: connection.version, + capabilities: connection.capabilities, }); - return; } - if (message.type === "shutdown") { - void shutdown(); - return; + + async function sendProviderInput( + message: Extract, + ): Promise { + if (stopping) throw new Error("Plugin is stopping"); + const current = providerConnections.get(message.connectionId); + if (!current) throw new Error(`Unknown provider connection: ${message.connectionId}`); + if (current.closing) throw new Error("Provider connection is closing"); + await current.connection.send(message.input); + send({ + type: "provider.accepted", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + }); } - if (stopping) { - if (message.type === "provider.catalog_key") { + + // The connection stays registered until its close has reported, so shutdown + // waits for a close already in flight instead of disconnecting underneath it. + async function closeProviderConnection(connectionId: string): Promise { + const current = providerConnections.get(connectionId); + if (!current) return; + if (current.closing) return current.closing; + const closing = (async () => { + current.unsubscribe(); + try { + await current.connection.close(); + send({ type: "provider.closed", connectionId }); + } catch (error) { + send({ type: "provider.closed", connectionId, error: describeError(error) }); + } finally { + providerConnections.delete(connectionId); + } + })(); + current.closing = closing; + return closing; + } + + const transportFactory = createPluginDaemonTransportFactory({ + send, + onMessage(handler) { + return channel.onMessage(handler); + }, + }); + + async function initialize(message: Extract) { + daemonClient = new DaemonClient({ + url: `ipc://plugin/${encodeURIComponent(message.pluginId)}`, + clientId: createPluginClientId(message.pluginId), + clientType: "cli", + appVersion: message.appVersion, + // The runtime re-attaches a session when the daemon drops this socket. + reconnect: { enabled: true }, + transportFactory, + }); + paseo = createPaseoApi(daemonClient); + await daemonClient.connect(); + settingsStore = message.settingsDirectory + ? new PluginSettingsStore(message.settingsDirectory, (settingsId) => + send({ type: "settings.changed", settingsId }), + ) + : null; + const contributedCleanup = contribute({ + handle: (contract, handler) => + register(contract, (input, context) => handler(contract.input.parse(input), context)), + registerProvider, + registerUsageSource, + registerSettings, + on: hooks.on, + before: hooks.before, + }); + if (typeof contributedCleanup !== "function") { + throw new Error("Plugin contribution must return a cleanup function"); + } + cleanup = contributedCleanup; + const usageSourceMetadata = await Promise.all( + [...usageSources.values()] + .sort((a, b) => a.id.localeCompare(b.id)) + .map(async (source) => ({ + id: source.id, + label: source.label, + icon: source.icon + ? await readPluginProviderIcon(message.pluginDirectory, source.icon) + : undefined, + discover: !!source.discover, + })), + ); + send({ + type: "ready", + methods: [...handlers.keys()].sort(), + hooks: hooks.catalog(), + providers: [...providers.values()] + .sort((left, right) => left.id.localeCompare(right.id)) + .map(providerMetadata), + usageSources: usageSourceMetadata, + }); + } + + async function shutdown(): Promise { + if (stopping) return; + stopping = true; + const releaseApi = paseo + ?.dispose() + .catch((error) => console.error("Plugin API cleanup failed", error)); + hooks.close(); + for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; + const currentCleanup = cleanup; + cleanup = null; + try { + await currentCleanup?.(); + } catch (error) { + console.error("Plugin cleanup failed", error); + } + await Promise.all([...providerConnections.keys()].map(closeProviderConnection)); + await releaseApi; + await daemonClient?.close().catch(() => undefined); + await sendAndWait({ type: "paseo_close" }); + daemonClient = null; + paseo = null; + channel.disconnect(); + } + + function handleUsageRequest( + message: Extract< + PluginProcessRequest, + { type: "usage.identify" | "usage.fetch" | "usage.discover" } + >, + ): void { + void (async () => { + const source = usageSources.get(message.sourceId); + if (!source) throw new Error(`Unknown usage source: ${message.sourceId}`); + if (message.type === "usage.discover") + return jsonTransportValue(source.discover ? await source.discover() : []); + const input = await source.input.parseAsync(message.input); + return jsonTransportValue( + message.type === "usage.identify" + ? await source.identify(input) + : await source.fetch(input), + ); + })().then( + (output) => send({ type: "result", requestId: message.requestId, output }), + (error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + } + + function rejectWhileStopping(message: PluginProcessRequest): void { + if ( + message.type === "provider.catalog_key" || + message.type === "usage.identify" || + message.type === "usage.fetch" || + message.type === "usage.discover" + ) { send({ type: "error", requestId: message.requestId, error: "Plugin is stopping" }); } else if (message.type === "provider.connect") { send({ @@ -371,105 +374,190 @@ process.on("message", (rawMessage: unknown) => { } else if (message.type === "provider.close") { send({ type: "provider.closed", connectionId: message.connectionId }); } - return; } - if (message.type === "provider.catalog_key") { - void (async () => { - const provider = providers.get(message.providerId); - if (!provider) throw new Error(`Unknown provider: ${message.providerId}`); - const output = await provider.getCatalogCacheKey?.(message.options); - if (output !== undefined && typeof output !== "string") - throw new Error("Invalid catalogue key"); - send({ type: "result", requestId: message.requestId, output }); - })().catch((error) => - send({ type: "error", requestId: message.requestId, error: describeError(error) }), - ); - return; - } - if (message.type === "provider.connect") { - void connectProvider(message).catch((error) => { - if (stopping) return; - send({ - type: "provider.connect_failed", - connectionId: message.connectionId, - error: describeError(error), - }); - }); - return; - } - if (message.type === "provider.send") { - void sendProviderInput(message).catch((error) => { - if (stopping) return; - send({ - type: "provider.rejected", - connectionId: message.connectionId, - acceptanceId: message.acceptanceId, - error: describeError(error), - }); - }); - return; - } - if (message.type === "provider.close") { - void closeProviderConnection(message.connectionId); - return; - } - if (message.type === "paseo_frame" || message.type === "paseo_close") return; - if (isHookMessage(message)) { - handleHookMessage(message); - return; - } - const registered = handlers.get(message.method); - if (!registered) { - send({ - type: "error", - requestId: message.requestId, - error: `Unknown RPC method: ${message.method}`, - }); - return; - } - void registered.contract.input - .parseAsync(message.input) - .then((input) => { - if (!paseo) throw new Error("Plugin Paseo API is unavailable"); - return registered.handler(input, { paseo }); - }) - .then((output) => registered.contract.output.parseAsync(output)) - .then( - (output) => send({ type: "result", requestId: message.requestId, output }), - (error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }), - ); -}); -function handleHookMessage( - message: Extract, -): void { - if (message.type === "hook.cancel") { - hooks.cancel(message.requestId); - return; - } - if (message.type === "hook") { - if (!paseo) { - send({ - type: "error", - requestId: message.requestId, - error: "Plugin Paseo API is unavailable", + function handleMessage(rawMessage: unknown): void { + const parsed = PluginProcessRequestSchema.safeParse(rawMessage); + if (!parsed.success) { + const value = rawMessage as { connectionId?: unknown; acceptanceId?: unknown } | null; + if ( + value && + typeof value.connectionId === "string" && + typeof value.acceptanceId === "string" + ) { + send({ + type: "provider.rejected", + connectionId: value.connectionId, + acceptanceId: value.acceptanceId, + error: `Invalid provider input: ${parsed.error.message}`, + }); + void closeProviderConnection(value.connectionId); + return; + } + send({ type: "fatal", error: `Invalid plugin process request: ${parsed.error.message}` }); + void shutdown(); + return; + } + const message = parsed.data; + if (message.type === "initialize") { + void initialize(message).catch(async (error) => { + send({ type: "fatal", error: describeError(error) }); + await paseo + ?.dispose() + .catch((failure) => console.error("Plugin API cleanup failed", failure)); + await daemonClient?.close().catch(() => undefined); }); return; } - void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then( - (output) => { - return send({ type: "result", requestId: message.requestId, output }); - }, - (error) => { - return send({ type: "error", requestId: message.requestId, error: describeError(error) }); - }, - ); - return; + if (message.type === "shutdown") { + void shutdown(); + return; + } + if (stopping) { + rejectWhileStopping(message); + return; + } + if (message.type === "provider.catalog_key") { + void (async () => { + const provider = providers.get(message.providerId); + if (!provider) throw new Error(`Unknown provider: ${message.providerId}`); + const output = await provider.getCatalogCacheKey?.(message.options); + if (output !== undefined && typeof output !== "string") + throw new Error("Invalid catalogue key"); + send({ type: "result", requestId: message.requestId, output }); + })().catch((error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + return; + } + if ( + message.type === "usage.identify" || + message.type === "usage.fetch" || + message.type === "usage.discover" + ) { + handleUsageRequest(message); + return; + } + if (message.type === "provider.connect") { + void connectProvider(message).catch((error) => { + if (stopping) return; + send({ + type: "provider.connect_failed", + connectionId: message.connectionId, + error: describeError(error), + }); + }); + return; + } + if (message.type === "provider.send") { + void sendProviderInput(message).catch((error) => { + if (stopping) return; + send({ + type: "provider.rejected", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + error: describeError(error), + }); + }); + return; + } + if (message.type === "provider.close") { + void closeProviderConnection(message.connectionId); + return; + } + if (message.type === "paseo_frame" || message.type === "paseo_close") return; + if (isHookMessage(message)) { + handleHookMessage(message); + return; + } + const registered = handlers.get(message.method); + if (!registered) { + send({ + type: "error", + requestId: message.requestId, + error: `Unknown RPC method: ${message.method}`, + }); + return; + } + void registered.contract.input + .parseAsync(message.input) + .then((input) => { + if (!paseo) throw new Error("Plugin Paseo API is unavailable"); + return registered.handler(input, { paseo }); + }) + .then((output) => registered.contract.output.parseAsync(output)) + .then( + (output) => send({ type: "result", requestId: message.requestId, output }), + (error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); } + channel.onMessage(handleMessage); + + function handleHookMessage( + message: Extract, + ): void { + if (message.type === "hook.cancel") { + hooks.cancel(message.requestId); + return; + } + if (message.type === "hook") { + if (!paseo) { + send({ + type: "error", + requestId: message.requestId, + error: "Plugin Paseo API is unavailable", + }); + return; + } + void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then( + (output) => { + return send({ type: "result", requestId: message.requestId, output }); + }, + (error) => { + return send({ type: "error", requestId: message.requestId, error: describeError(error) }); + }, + ); + return; + } + } + + function isHookMessage( + message: PluginProcessRequest, + ): message is Extract { + return message.type === "hook" || message.type === "hook.cancel"; + } + return { shutdown }; } -function isHookMessage( - message: PluginProcessRequest, -): message is Extract { - return message.type === "hook" || message.type === "hook.cancel"; +if (process.argv[1] === fileURLToPath(import.meta.url)) { + let worker: ReturnType | null = null; + const handlers = new Set<(message: PluginProcessRequest) => void>(); + const channel: PluginWorkerChannel = { + send(message, callback) { + if (callback) process.send?.(message, () => callback()); + else process.send?.(message); + }, + onMessage(handler) { + handlers.add(handler); + return () => { + handlers.delete(handler); + }; + }, + disconnect() { + process.disconnect(); + }, + }; + process.on("message", (raw) => { + const parsed = PluginProcessRequestSchema.safeParse(raw); + if (!worker && parsed.success && parsed.data.type === "initialize") { + try { + worker = createPluginWorker({ channel, contribute: evaluateBundle(parsed.data.bundle) }); + } catch (error) { + channel.send({ type: "fatal", error: describeError(error) }); + return; + } + } + if (worker) for (const handler of handlers) handler(raw as PluginProcessRequest); + }); } diff --git a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts index 2c17db0483..079f1c501d 100644 --- a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts +++ b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts @@ -8,6 +8,7 @@ export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [ const PLUGIN_SERVER_ONLY_SDK_SPECIFIERS = [ "@getpaseo/plugin/server", "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/usage", "@getpaseo/plugin/server/acp", ] as const; diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 473cc6f359..312d4d069a 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -24,9 +24,12 @@ import type { PluginProcessMessage, PluginProcessRequest, PluginProviderMetadata, + PluginUsageSourceMetadata, } from "./plugin-process-protocol.js"; import { PluginProcessMessageSchema } from "./plugin-process-protocol.js"; import { PluginSessionSocket } from "./session-socket.js"; +import { InternalPluginChild } from "./internal-child.js"; +import { evaluateBundle } from "./bundle-evaluator.js"; const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; @@ -65,6 +68,7 @@ interface LoadedPlugin { methods: ReadonlySet; hooks: { events: string[]; before: string[] }; providers: readonly PluginProviderMetadata[]; + usageSources: readonly PluginUsageSourceMetadata[]; child: PluginChild | null; outputCapture: PluginOutputCapture | null; pending: Map; @@ -332,6 +336,29 @@ export class PluginRuntime { this.appendLog(pluginId, "stdout", "[paseo] Plugin ready"); } + async startBuiltinPlugin(input: { id: string; directory: string }): Promise { + if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`); + this.appendLog(input.id, "stdout", "[paseo] Loading plugin"); + const directory = path.resolve(input.directory); + const manifest = await readPluginManifest(directory); + if (manifest.id !== input.id) { + throw new Error(`Built-in plugin ${input.id} has manifest ID ${manifest.id}`); + } + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); + const bundles = await compilePlugin(await resolveEntryPaths(directory)); + if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`); + const loaded = await this.launchPlugin({ + pluginId: input.id, + pluginDirectory: directory, + requirements: manifest.requirements, + child: new InternalPluginChild(evaluateBundle(bundles.serverBundle)), + bundle: "", + clientBundle: bundles.clientBundle ?? "", + }); + this.plugins.set(input.id, loaded); + this.appendLog(input.id, "stdout", "[paseo] Plugin ready"); + } + async validatePlugin(configuredPath: string): Promise { const directory = path.resolve(configuredPath); const manifest = await readPluginManifest(directory); @@ -359,6 +386,33 @@ export class PluginRuntime { return this.plugins.get(pluginId)?.providers ?? []; } + getUsageSourceRegistrations(pluginId: string): readonly PluginUsageSourceMetadata[] { + return this.plugins.get(pluginId)?.usageSources ?? []; + } + + identifyUsage(pluginId: string, sourceId: string, input: unknown): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + return this.request(loaded, { + type: "usage.identify", + requestId: randomUUID(), + sourceId, + input, + }); + } + + fetchUsage(pluginId: string, sourceId: string, input: unknown): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + return this.request(loaded, { type: "usage.fetch", requestId: randomUUID(), sourceId, input }); + } + + discoverUsage(pluginId: string, sourceId: string): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + return this.request(loaded, { type: "usage.discover", requestId: randomUUID(), sourceId }); + } + async connectProvider( pluginId: string, providerId: string, @@ -566,6 +620,7 @@ export class PluginRuntime { methods: new Set(), hooks: { events: [], before: [] }, providers: [], + usageSources: [], child: null, outputCapture: null, pending: new Map(), @@ -575,9 +630,27 @@ export class PluginRuntime { sessionClosed: null, }; } + return this.launchPlugin({ + pluginId, + pluginDirectory: directory, + requirements: manifest.requirements, + child: this.spawnChild(), + bundle: serverBundle, + clientBundle: bundles.clientBundle ?? "", + }); + } + + private async launchPlugin(input: { + pluginId: string; + pluginDirectory: string; + requirements: PluginRequirements | undefined; + child: PluginChild; + bundle: string; + clientBundle: string; + }): Promise { + const { pluginId, requirements, child, bundle, clientBundle } = input; const sessionHost = this.sessionHost; if (!sessionHost) throw new Error("Plugin Paseo session host is not attached"); - const child = this.spawnChild(); const outputCapture = new PluginOutputCapture(child, (stream, message) => { this.appendLog(pluginId, stream, message); }); @@ -653,8 +726,9 @@ export class PluginRuntime { void send(child, { type: "initialize", pluginId, + pluginDirectory: input.pluginDirectory, appVersion: this.daemonVersion, - bundle: serverBundle, + bundle, settingsDirectory: this.dependencies.settingsDirectory ? path.join(this.dependencies.settingsDirectory, pluginId) : undefined, @@ -669,11 +743,12 @@ export class PluginRuntime { } loaded = { id: pluginId, - clientBundle: bundles.clientBundle ?? "", - requirements: manifest.requirements, + clientBundle, + requirements, methods: new Set(ready.methods), hooks: ready.hooks ?? { events: [], before: [] }, providers: ready.providers ?? [], + usageSources: ready.usageSources ?? [], child, outputCapture, pending, diff --git a/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts b/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts new file mode 100644 index 0000000000..c4f2287bbf --- /dev/null +++ b/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts @@ -0,0 +1,150 @@ +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { ACPAgentSession } from "../agent/providers/acp-agent.js"; +import type { UsageReference } from "../agent/agent-sdk-types.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; +import { CodexAppServerAgentSession } from "../agent/providers/codex-app-server-agent.js"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; + +const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url)); +const fixtureBuiltins = () => new BuiltinPluginLoader(fixtureRoot, ["session-usage-reference"]); + +test("resolves a provider plugin session reference through its usage source", async () => { + const directory = fileURLToPath( + new URL("./test-fixtures/session-usage-reference/", import.meta.url), + ); + const daemon = await createTestPaseoDaemon({ + pluginsEnabled: false, + builtinPlugins: fixtureBuiltins(), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + const agent = await client.createAgent({ + provider: "fixture-session-provider", + cwd: directory, + }); + const result = await client.resolveAgentUsageReport({ agentId: agent.id }); + expect(result.reportId).toBe("fixture-session-usage:from-session"); + expect( + (await client.listUsageReports({ reportIds: [result.reportId!] })).reports[0], + ).toMatchObject({ + id: result.reportId, + sourceId: "fixture-session-usage", + report: { windows: [{ usedPct: 31 }] }, + }); + const reports = await client.listUsageReports(); + expect(reports.reports.map((entry) => entry.id)).toContain( + "fixture-session-usage:from-session", + ); + const missing = await client.createAgent({ + provider: "fixture-session-provider", + cwd: directory, + model: "missing", + }); + expect((await client.resolveAgentUsageReport({ agentId: missing.id })).reportId).toBeNull(); + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); + +test("agent.resolve_usage_report resolves source IDs from default built-in and ACP sessions", async () => { + const directory = fileURLToPath( + new URL("./test-fixtures/session-usage-reference/", import.meta.url), + ); + const providers = ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"] as const; + const logger = createTestLogger(); + const references = new Map(); + const claude = await new ClaudeAgentClient({ + logger, + resolveBinary: async () => "/test/claude/bin", + }).createSession( + { provider: "claude", cwd: directory }, + { + env: { + HOME: directory, + CLAUDE_CONFIG_DIR: "", + ANTHROPIC_BASE_URL: "", + ANTHROPIC_API_KEY: "", + ANTHROPIC_AUTH_TOKEN: "", + }, + }, + ); + references.set("claude", (await claude.getUsageReference?.()) ?? null); + await claude.close(); + const codex = new CodexAppServerAgentSession( + { provider: "codex", cwd: directory }, + null, + logger, + () => { + throw new Error("Codex runtime should not start"); + }, + {}, + false, + false, + false, + undefined, + "interactive", + { HOME: directory, OPENAI_BASE_URL: "" }, + ); + references.set("codex", await codex.getUsageReference()); + for (const provider of providers.slice(2)) { + const session = new ACPAgentSession( + { provider, cwd: directory }, + { + provider, + logger, + defaultCommand: ["unused"], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + }, + ); + references.set(provider, await session.getUsageReference()); + } + const agentClients = Object.fromEntries( + providers.map((provider) => { + const client = createTestAgentClient(provider); + const createSession = client.createSession.bind(client); + client.createSession = async (...args) => { + const session = await createSession(...args); + session.getUsageReference = async () => references.get(provider) ?? null; + return session; + }; + return [provider, client]; + }), + ); + const daemon = await createTestPaseoDaemon({ + pluginsEnabled: false, + builtinPlugins: fixtureBuiltins(), + agentClients, + providerOverrides: { + cursor: { extends: "acp", label: "Cursor", command: ["cursor-agent", "acp"] }, + kimi: { extends: "acp", label: "Kimi", command: ["kimi", "acp"] }, + "generic-acp": { extends: "acp", label: "Generic ACP", command: ["generic-acp", "acp"] }, + }, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + for (const provider of providers) { + const agent = await client.createAgent({ provider, cwd: directory }); + const result = await client.resolveAgentUsageReport({ agentId: agent.id }); + expect(result.reportId, provider).toBe(`${provider}:default`); + } + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/test-fixtures/internal-seam/index.server.ts b/packages/server/src/server/plugins/test-fixtures/internal-seam/index.server.ts new file mode 100644 index 0000000000..8f0914a4c8 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/internal-seam/index.server.ts @@ -0,0 +1,18 @@ +import { defineRpc } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { z } from "zod"; + +const state = defineRpc({ + name: "state", + input: z.object({}), + output: z.object({ workspaces: z.number() }), +}); + +export default function contribute(server: PluginServerContext) { + let workspaces = 0; + server.handle(state, () => ({ workspaces })); + server.on("workspace.created", () => { + workspaces += 1; + }); + return () => {}; +} diff --git a/packages/server/src/server/plugins/test-fixtures/internal-seam/paseo-plugin.json b/packages/server/src/server/plugins/test-fixtures/internal-seam/paseo-plugin.json new file mode 100644 index 0000000000..e84dff55d8 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/internal-seam/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "internal-seam", "requirements": { "paseo": ">=0.8.0" } } diff --git a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts b/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts new file mode 100644 index 0000000000..c5aeaede16 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts @@ -0,0 +1,101 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { + negotiateProviderCapabilities, + type ProviderEvent, +} from "@getpaseo/plugin/server/provider"; +import { z } from "zod"; + +export default function contribute(server: PluginServerContext) { + for (const id of ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"]) { + let input = z.object({}).strict(); + if (id === "claude") input = z.object({ configDir: z.string() }).strict(); + if (id === "codex") input = z.object({ codexHome: z.string() }).strict(); + server.registerUsageSource({ + id, + label: id, + input, + identify: async () => ({ key: "default" }), + fetch: async () => ({ + status: "available", + windows: [{ id: "hour", label: "Hour", usedPct: 31, headline: true }], + }), + }); + } + server.registerUsageSource({ + id: "fixture-session-usage", + label: "Fixture session usage", + input: z.object({ account: z.string() }).strict(), + identify: async (input) => ({ key: (input as { account: string }).account }), + fetch: async () => ({ + status: "available", + windows: [{ id: "hour", label: "Hour", usedPct: 31, headline: true }], + }), + }); + server.registerProvider({ + id: "fixture-session-provider", + label: "Fixture session provider", + async connect(request) { + const capabilities = negotiateProviderCapabilities(request.capabilities, [ + "prompt.message", + "session.usage_reference", + ]); + const listeners = new Set<(event: ProviderEvent) => void>(); + const models = new Map(); + const emit = (event: ProviderEvent) => { + for (const listener of listeners) listener(event); + }; + return { + version: 1, + capabilities, + async send(input) { + if (input.type === "catalog") + emit({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [ + { id: "fixture", label: "Fixture" }, + { id: "missing", label: "Missing" }, + ], + modes: [], + }, + }); + if (input.type === "session.open") { + models.set(input.sessionId, input.config.model); + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities, + restoration: "core", + cwd: input.config.cwd, + }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + } + if (input.type === "session.usage_reference") + emit({ + type: "usage_reference", + requestId: input.requestId, + reference: { + source: + models.get(input.sessionId) === "missing" + ? "missing-source" + : "fixture-session-usage", + input: { account: "from-session" }, + }, + }); + if (input.type === "session.close") + emit({ type: "request.completed", requestId: input.requestId }); + }, + onEvent(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async close() { + listeners.clear(); + }, + }; + }, + }); + return () => {}; +} diff --git a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/paseo-plugin.json b/packages/server/src/server/plugins/test-fixtures/session-usage-reference/paseo-plugin.json new file mode 100644 index 0000000000..f7b37baee9 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/session-usage-reference/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "session-usage-reference", "requirements": { "paseo": ">=0.9.2" } } diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/icon.svg b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/icon.svg new file mode 100644 index 0000000000..b7d6b25aef --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts new file mode 100644 index 0000000000..48a4ee5eec --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts @@ -0,0 +1,31 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { z } from "zod"; + +let fetches = 0; +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "fixture-directory", + label: "Fixture", + icon: "icon.svg", + input: z.object({ account: z.string() }).strict(), + discover: async () => { + const inputs: Array> = [ + { account: "one" }, + { account: "bad", extra: true }, + { account: "throws" }, + ]; + return inputs; + }, + identify: async (input) => ({ key: (input as { account: string }).account }), + fetch: async (input) => { + const account = (input as { account: string }).account; + if (account === "throws") throw new Error("fixture failure"); + fetches++; + return { + status: "available", + windows: [{ id: "count", label: "Count", usedPct: fetches, headline: true }], + }; + }, + }); + return () => {}; +} diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/paseo-plugin.json b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/paseo-plugin.json new file mode 100644 index 0000000000..273f46f948 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "usage-source-fixture", "requirements": { "paseo": ">=0.8.0" } } diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source/icon.svg b/packages/server/src/server/plugins/test-fixtures/usage-source/icon.svg new file mode 100644 index 0000000000..b7d6b25aef --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts new file mode 100644 index 0000000000..a8f1f28211 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts @@ -0,0 +1,31 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { z } from "zod"; + +let fetches = 0; +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "fixture", + label: "Fixture", + icon: "icon.svg", + input: z.object({ account: z.string() }).strict(), + discover: async () => { + const inputs: Array> = [ + { account: "one" }, + { account: "bad", extra: true }, + { account: "throws" }, + ]; + return inputs; + }, + identify: async (input) => ({ key: (input as { account: string }).account }), + fetch: async (input) => { + const account = (input as { account: string }).account; + if (account === "throws") throw new Error("fixture failure"); + fetches++; + return { + status: "available", + windows: [{ id: "count", label: "Count", usedPct: fetches, headline: true }], + }; + }, + }); + return () => {}; +} diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source/paseo-plugin.json b/packages/server/src/server/plugins/test-fixtures/usage-source/paseo-plugin.json new file mode 100644 index 0000000000..a509a64163 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "usage-source", "requirements": { "paseo": ">=0.8.0" } } diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts new file mode 100644 index 0000000000..c414b20557 --- /dev/null +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -0,0 +1,51 @@ +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; + +const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url)); +const subprocessDirectory = fileURLToPath( + new URL("./test-fixtures/usage-source-directory/", import.meta.url), +); + +test("lists built-in and subprocess usage; validates input and isolates fetch errors", async () => { + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(fixtureRoot, ["usage-source"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await client.connect(); + const first = await client.listUsageReports(); + expect(first.reports).toHaveLength(3); + expect(first.reports[0]?.icon).toContain(" entry.id === "fixture:one")?.report.windows[0]?.usedPct, + ).toBe(1); + expect(first.reports.filter((entry) => entry.report.status === "error")).toHaveLength(2); + expect( + (await client.listUsageReports()).reports.find((entry) => entry.id === "fixture:one")?.report + .windows[0]?.usedPct, + ).toBe(1); + expect( + ( + await client.listUsageReports({ reportIds: ["fixture:one"], forceRefresh: true }) + ).reports.find((entry) => entry.id === "fixture:one")?.report.windows[0]?.usedPct, + ).toBe(2); + expect( + (await client.listUsageReports({ reportIds: ["fixture:throws"] })).reports[0]?.report.status, + ).toBe("error"); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(subprocessDirectory, "fixture-directory"); + const both = await client.listUsageReports({ forceRefresh: true }); + expect(both.reports).toHaveLength(6); + await client.patchDaemonConfig({ pluginsEnabled: false }); + await expect.poll(async () => (await client.listUsageReports()).reports.length).toBe(3); + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts new file mode 100644 index 0000000000..2b8d0b672b --- /dev/null +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -0,0 +1,241 @@ +import { expect, test } from "vitest"; +import { UsageSourceRegistry } from "./index.js"; + +function source(input: { + id: string; + discover?: () => Promise; + identify?: (value: unknown) => Promise<{ key: string } | null>; + fetch?: (value: unknown) => Promise; +}) { + return { + id: input.id, + label: input.id, + discover: input.discover ?? (async () => []), + identify: + input.identify ?? + (async (value: unknown) => ({ key: (value as { account: string }).account })), + fetch: input.fetch ?? (async () => ({ status: "available", windows: [] })), + }; +} + +test("identifies accounts without fetching and preserves IDs across token rotation", async () => { + const registry = new UsageSourceRegistry(); + let fetches = 0; + let token = "old"; + registry.register( + source({ + id: "codex", + discover: async () => [{ account: "work", token }], + fetch: async (input) => { + fetches++; + return { + status: "available", + windows: [{ id: "token", label: (input as { token: string }).token }], + }; + }, + }), + ); + expect( + await registry.resolveReference({ source: "codex", input: { account: "work", token } }), + ).toBe("codex:work"); + expect(fetches).toBe(0); + token = "new"; + expect( + await registry.resolveReference({ source: "codex", input: { account: "work", token } }), + ).toBe("codex:work"); + expect( + (await registry.listReports({ reportIds: ["codex:work"] }))[0]?.report.windows[0]?.label, + ).toBe("new"); + expect(fetches).toBe(1); +}); + +test("coalesces per account, caches errors, and refreshes only requested IDs", async () => { + let now = 0; + const counts = new Map(); + const registry = new UsageSourceRegistry(() => now); + registry.register( + source({ + id: "source", + discover: async () => [{ account: "a" }, { account: "b" }], + fetch: async (input) => { + const account = (input as { account: string }).account; + counts.set(account, (counts.get(account) ?? 0) + 1); + if (account === "b") throw new Error("failed"); + return { status: "available", windows: [] }; + }, + }), + ); + const first = await registry.listReports(); + expect(first.map((entry) => entry.id)).toEqual(["source:a", "source:b"]); + expect(first[1]?.report.status).toBe("error"); + expect(await registry.listReports()).toEqual(first); + expect(counts.get("a")).toBe(1); + expect(counts.get("b")).toBe(1); + now = 1000; + const refreshed = await registry.listReports({ + reportIds: ["source:a", "missing:id"], + forceRefresh: true, + }); + expect(refreshed.map((entry) => entry.id)).toEqual(["source:a"]); + expect(refreshed[0]?.fetchedAt).not.toBe(first[0]?.fetchedAt); + expect(counts.get("a")).toBe(2); + expect(counts.get("b")).toBe(1); + now = 301_001; + await registry.listReports({ reportIds: ["source:a"] }); + expect(counts.get("a")).toBe(3); +}); + +test("invalid keys become source errors and missing identities produce no report", async () => { + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => [{ account: "bad:key" }, { account: "none" }], + identify: async (input) => { + const account = (input as { account: string }).account; + return account === "none" ? null : { key: account }; + }, + }), + ); + const reports = await registry.listReports(); + expect(reports.map((entry) => entry.id)).toEqual(["source:!error"]); + expect(reports[0]?.report.status).toBe("error"); + expect( + await registry.resolveReference({ source: "source", input: { account: "none" } }), + ).toBeNull(); +}); + +test("legacy listing uses oldest fetchedAt", async () => { + let now = 1000; + const registry = new UsageSourceRegistry(() => now); + registry.register( + source({ id: "source", discover: async () => [{ account: "a" }, { account: "b" }] }), + ); + await registry.listReports({ references: [{ source: "source", input: { account: "a" } }] }); + now = 2000; + await registry.listReports({ reportIds: ["source:b"], forceRefresh: true }); + expect((await registry.listLegacyUsage()).fetchedAt).toBe(new Date(1000).toISOString()); +}); + +test("two Codex homes and a token route resolve to their vendor account IDs", async () => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { identify } = await import("../../../../../../plugins/codex-usage-source/server/usage.js"); + const personal = await mkdtemp(join(tmpdir(), "usage-personal-")); + const work = await mkdtemp(join(tmpdir(), "usage-work-")); + try { + const token = (accountId: string, email: string) => + `header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId }, email })).toString("base64url")}.signature`; + await writeFile( + join(personal, "auth.json"), + JSON.stringify({ + tokens: { + account_id: "personal-id", + access_token: token("personal-id", "personal@example.test"), + }, + }), + ); + await writeFile( + join(work, "auth.json"), + JSON.stringify({ + tokens: { account_id: "work-id", access_token: token("work-id", "work@example.test") }, + }), + ); + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "codex", + discover: async () => [{ codexHome: personal }, { codexHome: work }], + identify: async (input) => identify(input as Parameters[0]), + }), + ); + + expect( + await registry.resolveReference({ + source: "codex", + input: { accessToken: token("work-id", "work@example.test") }, + }), + ).toBe("codex:work-id"); + expect((await registry.listReports()).map((entry) => [entry.id, entry.account.label])).toEqual([ + ["codex:personal-id", "personal@example.test"], + ["codex:work-id", "work@example.test"], + ]); + } finally { + await rm(personal, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true }); + } +}); + +test("concurrent requests for the same ID share one vendor fetch", async () => { + let finish!: (report: unknown) => void; + const response = new Promise((resolve) => { + finish = resolve; + }); + let fetches = 0; + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "coalesced", + fetch: async () => { + fetches++; + return response; + }, + }), + ); + await registry.resolveReference({ source: "coalesced", input: { account: "one" } }); + const first = registry.listReports({ reportIds: ["coalesced:one"] }); + const second = registry.listReports({ reportIds: ["coalesced:one"], forceRefresh: true }); + finish({ status: "available", windows: [] }); + const [one, two] = await Promise.all([first, second]); + expect(one[0]).toBe(two[0]); + expect(fetches).toBe(1); +}); + +test("source failure IDs cannot collide with an account named error", async () => { + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => [{ account: "error" }, { account: "bad:key" }], + }), + ); + const reports = await registry.listReports(); + expect(reports).toHaveLength(2); + expect(reports.find((entry) => entry.id === "source:error")?.report.status).toBe("available"); + expect(reports.find((entry) => entry.report.status === "error")?.id).toMatch( + /^source:[^A-Za-z0-9._-]/, + ); +}); + +test("expired cached entries are pruned when a new report is written", async () => { + let now = 0; + const registry = new UsageSourceRegistry(() => now, 100); + registry.register(source({ id: "source" })); + await registry.resolveReference({ source: "source", input: { account: "old" } }); + await registry.listReports({ reportIds: ["source:old"] }); + now = 101; + await registry.resolveReference({ source: "source", input: { account: "new" } }); + await registry.listReports({ reportIds: ["source:new"] }); + const cache = Reflect.get(registry, "cache") as Map; + expect([...cache.keys()]).toEqual(["source:new"]); +}); + +test("discovery failures have an ID outside the account namespace", async () => { + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => { + throw new Error("discovery failed"); + }, + }), + ); + expect(await registry.resolveReference({ source: "source", input: { account: "error" } })).toBe( + "source:error", + ); + const discovered = await registry.listReports(); + expect(discovered.map((entry) => entry.id)).toEqual(["source:!error"]); + const both = await registry.listReports({ reportIds: ["source:error", "source:!error"] }); + expect(both.map((entry) => entry.report.status)).toEqual(["available", "error"]); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts new file mode 100644 index 0000000000..a6705114de --- /dev/null +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -0,0 +1,190 @@ +import { + UsageReportSchema, + type ProviderUsage, + type UsageReportEntry, +} from "@getpaseo/protocol/messages"; +import type { UsageReference } from "../../agent/agent-sdk-types.js"; + +export interface UsageSource { + id: string; + label: string; + icon?: string; + discover(): Promise; + identify(input: unknown): Promise<{ key: string; label?: string } | null>; + fetch(input: unknown): Promise; +} + +interface KnownReport { + source: UsageSource; + input: unknown; + label?: string; +} + +/** Owns account identity, the latest input for each report, and the five-minute fetch cache. */ +export class UsageSourceRegistry { + private readonly sources = new Map(); + private readonly known = new Map(); + private readonly cache = new Map(); + private readonly pending = new Map>(); + + constructor( + private readonly now: () => number = Date.now, + private readonly ttlMs = 300_000, + ) {} + + register(source: UsageSource): void { + if (this.sources.has(source.id)) throw new Error(`Duplicate usage source: ${source.id}`); + this.sources.set(source.id, source); + } + + unregister(id: string): void { + this.sources.delete(id); + for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key); + for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); + } + + async resolveReference(reference: UsageReference): Promise { + const source = this.sources.get(reference.source); + if (!source) return null; + return this.identify(source, reference.input); + } + + private async identify(source: UsageSource, input: unknown): Promise { + try { + const account = await source.identify(input); + if (account === null) return null; + if ( + typeof account !== "object" || + (account.label !== undefined && typeof account.label !== "string") + ) + throw new Error(`Invalid account identity from ${source.id}`); + if (typeof account.key !== "string" || !/^[A-Za-z0-9._-]{1,128}$/.test(account.key)) + throw new Error(`Invalid account key from ${source.id}`); + const id = `${source.id}:${account.key}`; + this.known.set(id, { source, input, label: account.label }); + return id; + } catch (error) { + const id = `${source.id}:!error`; + this.writeCache(id, this.errorEntry(source, id, error)); + return id; + } + } + + async listReports( + options: { forceRefresh?: boolean; reportIds?: string[]; references?: UsageReference[] } = {}, + ): Promise { + const ids = options.reportIds ?? (await this.discoverReportIds(options.references ?? [])); + return Promise.all( + [...new Set(ids)] + .filter((id) => this.known.has(id) || this.cache.has(id)) + .map((id) => this.fetchId(id, options.forceRefresh)), + ); + } + + private async discoverReportIds(references: UsageReference[]): Promise { + const discovered = await Promise.all( + [...this.sources.values()].map(async (source) => { + try { + const inputs = await source.discover(); + if (!Array.isArray(inputs)) throw new Error("Usage discovery must return an array"); + return (await Promise.all(inputs.map((input) => this.identify(source, input)))).filter( + (id): id is string => id !== null, + ); + } catch (error) { + const id = `${source.id}:!error`; + this.writeCache(id, this.errorEntry(source, id, error)); + return [id]; + } + }), + ); + const live = await Promise.all(references.map((reference) => this.resolveReference(reference))); + return [...discovered.flat(), ...live.filter((id): id is string => id !== null)]; + } + + // COMPAT(providerUsageList): added in v0.9.3, remove after 2027-03-26. + async listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }> { + const reports = await this.listReports(); + return { + fetchedAt: reports.length + ? reports.reduce( + (oldest, entry) => (entry.fetchedAt < oldest ? entry.fetchedAt : oldest), + reports[0]!.fetchedAt, + ) + : new Date(this.now()).toISOString(), + providers: reports.map((entry) => ({ + providerId: entry.sourceId, + displayName: entry.sourceLabel, + status: entry.report.status, + planLabel: entry.report.planLabel ?? null, + windows: entry.report.windows, + balances: entry.report.balances ?? [], + details: entry.report.details ?? [], + error: entry.report.error ?? null, + })), + }; + } + + private fetchId(id: string, forceRefresh = false): Promise { + const known = this.known.get(id); + const cached = this.cache.get(id); + if (!known) return Promise.resolve(cached!.entry); + if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) + return Promise.resolve(cached.entry); + const pending = this.pending.get(id); + if (pending) return pending; + const request = (async () => { + let entry: UsageReportEntry; + try { + const report = UsageReportSchema.parse(await known.source.fetch(known.input)); + entry = { + id, + sourceId: known.source.id, + sourceLabel: known.source.label, + icon: known.source.icon, + account: { label: known.label }, + fetchedAt: new Date(this.now()).toISOString(), + report, + }; + } catch (error) { + entry = this.errorEntry(known.source, id, error, known.label); + } + this.writeCache(id, entry); + return entry; + })(); + this.pending.set(id, request); + void request.finally(() => { + if (this.pending.get(id) === request) this.pending.delete(id); + }); + return request; + } + + private writeCache(id: string, entry: UsageReportEntry): void { + const at = this.now(); + for (const [cachedId, cached] of this.cache) { + if (at - cached.at >= this.ttlMs) this.cache.delete(cachedId); + } + this.cache.set(id, { at, entry }); + } + + private errorEntry( + source: UsageSource, + id: string, + error: unknown, + label?: string, + fetchedAt = new Date(this.now()).toISOString(), + ): UsageReportEntry { + return { + id, + sourceId: source.id, + sourceLabel: source.label, + icon: source.icon, + account: { label }, + fetchedAt, + report: { + status: "error", + windows: [], + error: error instanceof Error ? error.message : String(error), + }, + }; + } +} diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index ab43c023cc..e60dd95209 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -521,6 +521,8 @@ test("routes plugin requests and releases its owned catalog subscription on clea }, catalog: () => [{ id: "example", clientBundle: "bundle" }], invokePluginRpc: async () => ({ ok: true }), + listUsageReports: async () => [], + listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }), }; const session = createSessionForTest({ messages, pluginRuntime }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c69033ab3f..f83040c8c9 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,11 @@ import { searchTimeline } from "./agent/chat-search/index.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; -import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import type { + SessionEventSubscription, + UsageReportEntry, + ProviderUsage, +} from "@getpaseo/protocol/messages"; import { relative } from "node:path"; import { isAbsolute } from "node:path"; import { CreationService } from "./creation/index.js"; @@ -130,6 +134,7 @@ import { type AgentPermissionResponse, type AgentRunOptions, type AgentSessionConfig, + type UsageReference, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; @@ -175,6 +180,7 @@ import { } from "./session/checkout/git-metadata-generator.js"; import { ScheduleSession } from "./session/schedule/schedule-session.js"; import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js"; +import { UsageSession } from "./session/usage/usage-session.js"; import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js"; import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; @@ -226,7 +232,6 @@ import { type GitHubService, } from "../services/github-service.js"; import type { ForgeService } from "../services/forge-service.js"; -import type { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { resolveWorkspaceRootAgent, summarizeFetchWorkspacesEntries, @@ -506,6 +511,13 @@ export interface SessionOptions { subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void; catalog(): Array<{ id: string; clientBundle: string }>; invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; + listUsageReports(options?: { + forceRefresh?: boolean; + reportIds?: string[]; + references?: UsageReference[]; + }): Promise; + resolveUsageReference(reference: UsageReference): Promise; + listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills; mcpBaseUrl?: string | null; @@ -514,7 +526,6 @@ export interface SessionOptions { tts: Resolvable; terminalManager: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; - providerUsageService: ProviderUsageService; hubExecutionAgents?: HubExecutionAgents; hubRelationships?: HubRelationshipManagement; serviceProxy?: ServiceProxySubsystem; @@ -778,6 +789,7 @@ export class Session { private readonly checkoutSession: CheckoutSession; private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; + private readonly usageSession: UsageSession; private readonly workspaceFilesSession: WorkspaceFilesSession; private readonly agentConfigSession: AgentConfigSession; private readonly projectConfigSession: ProjectConfigSession; @@ -827,7 +839,6 @@ export class Session { tts, terminalManager, providerSnapshotManager, - providerUsageService, serviceProxy, scriptRuntimeStore, workspaceSetupSnapshots, @@ -984,7 +995,13 @@ export class Session { listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config), }, providerSnapshotManager, - providerUsageService, + logger: this.sessionLogger, + }); + this.usageSession = new UsageSession({ + emit: (msg) => this.emit(msg), + listAgents: () => this.agentManager.listAgents(), + getAgent: (agentId) => this.agentManager.getAgent(agentId), + runtime: pluginRuntime, logger: this.sessionLogger, }); this.agentConfigSession = new AgentConfigSession({ @@ -2996,7 +3013,11 @@ export class Session { case "provider_diagnostic_request": return this.providerCatalogSession.handleProviderDiagnosticRequest(msg); case "provider.usage.list.request": - return this.providerCatalogSession.handleProviderUsageListRequest(msg); + return this.usageSession.handleLegacyList(msg); + case "usage.list_reports.request": + return this.usageSession.handleListReports(msg); + case "agent.resolve_usage_report.request": + return this.usageSession.handleResolveAgentReport(msg); default: return undefined; } diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index a174f1db5a..c1cec0b42a 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -14,7 +14,6 @@ import { type ProviderSnapshotTransition, } from "../../agent/provider-snapshot-manager.js"; import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js"; -import { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void; @@ -24,7 +23,6 @@ interface MakeOptions { supportsCustomModeIcons?: boolean; supportsCompactProviderSnapshots?: boolean; snapshot?: Partial; - usage?: { [K in keyof ProviderUsageService]?: unknown }; host?: Partial; } @@ -73,7 +71,6 @@ function makeSubsystem(options: MakeOptions = {}) { const subsystem = new ProviderCatalogSession({ host, providerSnapshotManager, - providerUsageService: createStub(options.usage ?? {}), logger: pino({ level: "silent" }), }); function pushSnapshotChange( @@ -299,25 +296,6 @@ describe("ProviderCatalogSession", () => { }); }); - it("surfaces a usage-list failure as an rpc_error envelope", async () => { - const { subsystem, emitted } = makeSubsystem({ - usage: { - listUsage: async () => { - throw new Error("quota service down"); - }, - }, - }); - - await subsystem.handleProviderUsageListRequest({ - type: "provider.usage.list.request", - requestId: "u1", - }); - - const err = findByType(emitted, "rpc_error"); - expect(err?.payload.code).toBe("provider_usage_list_failed"); - expect(err?.payload.requestId).toBe("u1"); - }); - it("surfaces a feature-list failure inline, not as an rpc_error", async () => { const { subsystem, emitted } = makeSubsystem({ host: { @@ -388,10 +366,6 @@ it("announces shared content without retransmitting models or hashing discovery new ProviderCatalogSession({ providerSnapshotManager: manager, logger: pino({ level: "silent" }), - providerUsageService: new ProviderUsageService({ - logger: pino({ level: "silent" }), - fetchers: [], - }), host: { emit(message) { emitted.push(message); diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index dd87543c19..af04199bca 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -19,7 +19,6 @@ import { type ProviderSnapshotEntry, } from "../../agent/agent-sdk-types.js"; import type { ProviderAvailability } from "../../agent/agent-manager.js"; -import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; import { expandTilde } from "../../../utils/path.js"; // COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any @@ -56,13 +55,12 @@ export interface ProviderCatalogSessionHost { export interface ProviderCatalogSessionOptions { host: ProviderCatalogSessionHost; providerSnapshotManager: ProviderSnapshotManager; - providerUsageService: ProviderUsageService; logger: pino.Logger; } /** * A client's provider catalog surface: model / mode / feature listing, the providers - * snapshot push + pull, provider diagnostics, and usage. The snapshot PUSH (start) and + * snapshot push + pull, and provider diagnostics. The snapshot PUSH (start) and * every PULL handler gate visibility and downgrade mode icons through the SAME predicates, * so an older client sees one consistent provider set across both paths — the COMPAT * invariant the shell could only enforce by code proximity before this carve. @@ -70,14 +68,12 @@ export interface ProviderCatalogSessionOptions { export class ProviderCatalogSession { private readonly host: ProviderCatalogSessionHost; private readonly providerSnapshotManager: ProviderSnapshotManager; - private readonly providerUsageService: ProviderUsageService; private readonly logger: pino.Logger; private unsubscribeSnapshotEvents: (() => void) | null = null; constructor(options: ProviderCatalogSessionOptions) { this.host = options.host; this.providerSnapshotManager = options.providerSnapshotManager; - this.providerUsageService = options.providerUsageService; this.logger = options.logger; } @@ -486,34 +482,6 @@ export class ProviderCatalogSession { }); } } - - async handleProviderUsageListRequest( - msg: Extract, - ): Promise { - try { - const usage = await this.providerUsageService.listUsage(); - this.host.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.logger.error({ err }, "Failed to list provider usage"); - this.host.emit({ - type: "rpc_error", - payload: { - requestId: msg.requestId, - requestType: msg.type, - error: `Failed to list provider usage: ${err.message}`, - code: "provider_usage_list_failed", - }, - }); - } - } } function resolveCatalogRequestCwd(cwd?: string | null): string | undefined { diff --git a/packages/server/src/server/session/usage/usage-session.test.ts b/packages/server/src/server/session/usage/usage-session.test.ts new file mode 100644 index 0000000000..7914af3872 --- /dev/null +++ b/packages/server/src/server/session/usage/usage-session.test.ts @@ -0,0 +1,102 @@ +import pino from "pino"; +import { expect, test } from "vitest"; +import type { AgentSession } from "../../agent/agent-sdk-types.js"; +import type { SessionOutboundMessage } from "../../messages.js"; +import { UsageSession } from "./usage-session.js"; + +test("collects live references for usage reports and resolves an agent report", async () => { + const emitted: SessionOutboundMessage[] = []; + const references: unknown[] = []; + const reference = { source: "fixture", input: { account: "one" } }; + const agent = { + session: { + getUsageReference: async () => reference, + } as AgentSession, + }; + const entry = { + id: "fixture:one", + account: {}, + fetchedAt: "2026-01-01T00:00:00.000Z", + sourceId: "fixture", + sourceLabel: "Fixture", + report: { status: "available" as const, windows: [] }, + }; + const usage = new UsageSession({ + emit: (message) => emitted.push(message), + listAgents: () => [agent, { session: null }], + getAgent: (id) => (id === "one" ? agent : null), + runtime: { + async listUsageReports(options) { + references.push(options.references); + return [entry]; + }, + async resolveUsageReference(value) { + references.push(value); + return entry.id; + }, + async listLegacyUsage() { + return { fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }; + }, + }, + logger: pino({ level: "silent" }), + }); + + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "list" }); + await usage.handleResolveAgentReport({ + type: "agent.resolve_usage_report.request", + requestId: "one", + agentId: "one", + }); + expect(references).toEqual([[reference], reference]); + expect(emitted.map((message) => message.type)).toEqual([ + "usage.list_reports.response", + "agent.resolve_usage_report.response", + ]); +}); + +test("surfaces a legacy usage-list failure as an rpc_error envelope", async () => { + const emitted: SessionOutboundMessage[] = []; + const usage = new UsageSession({ + emit: (message) => emitted.push(message), + listAgents: () => [], + getAgent: () => null, + runtime: { + async listUsageReports() { + return []; + }, + async resolveUsageReference() { + return null; + }, + async listLegacyUsage(): Promise { + throw new Error("quota service down"); + }, + }, + logger: pino({ level: "silent" }), + }); + await usage.handleLegacyList({ type: "provider.usage.list.request", requestId: "u1" }); + expect(emitted[0]).toMatchObject({ + type: "rpc_error", + payload: { requestId: "u1", code: "provider_usage_list_failed" }, + }); +}); + +test("unknown agent returns agent_not_found", async () => { + const emitted: SessionOutboundMessage[] = []; + const usage = new UsageSession({ + emit: (message) => emitted.push(message), + listAgents: () => [], + getAgent: () => null, + runtime: { + listUsageReports: async () => [], + resolveUsageReference: async () => null, + listLegacyUsage: async () => ({ fetchedAt: "", providers: [] }), + }, + logger: pino({ level: "silent" }), + }); + await usage.handleResolveAgentReport({ + type: "agent.resolve_usage_report.request", + requestId: "missing", + agentId: "missing", + }); + expect(emitted[0]).toMatchObject({ type: "rpc_error", payload: { code: "agent_not_found" } }); +}); diff --git a/packages/server/src/server/session/usage/usage-session.ts b/packages/server/src/server/session/usage/usage-session.ts new file mode 100644 index 0000000000..6665a7454f --- /dev/null +++ b/packages/server/src/server/session/usage/usage-session.ts @@ -0,0 +1,131 @@ +import type pino from "pino"; +import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages"; +import type { AgentSession, UsageReference } from "../../agent/agent-sdk-types.js"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; + +interface UsageAgent { + session: AgentSession | null; +} + +export interface UsageSessionOptions { + emit(message: SessionOutboundMessage): void; + listAgents(): UsageAgent[]; + getAgent(agentId: string): UsageAgent | null; + runtime?: { + listUsageReports(options: { + forceRefresh?: boolean; + reportIds?: string[]; + references: UsageReference[]; + }): Promise; + resolveUsageReference(reference: UsageReference): Promise; + listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; + }; + logger: pino.Logger; +} + +export class UsageSession { + constructor(private readonly options: UsageSessionOptions) {} + + async handleListReports( + msg: Extract, + ): Promise { + try { + if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); + const references = + msg.reportIds === undefined + ? ( + await Promise.all( + this.options + .listAgents() + .map( + async (agent) => agent.session?.getUsageReference?.().catch(() => null) ?? null, + ), + ) + ).filter((reference): reference is UsageReference => reference !== null) + : []; + const reports = await this.options.runtime.listUsageReports({ + forceRefresh: msg.forceRefresh, + reportIds: msg.reportIds, + references, + }); + this.options.emit({ + type: "usage.list_reports.response", + payload: { requestId: msg.requestId, reports }, + }); + } catch (error) { + this.emitError(msg, error, "usage_list_reports_failed"); + } + } + + async handleResolveAgentReport( + msg: Extract, + ): Promise { + try { + if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); + const agent = this.options.getAgent(msg.agentId); + if (!agent) { + this.emitError(msg, new Error(`Agent not found: ${msg.agentId}`), "agent_not_found"); + return; + } + const reference = (await agent.session?.getUsageReference?.()) ?? null; + const reportId = reference + ? await this.options.runtime.resolveUsageReference(reference) + : null; + this.options.emit({ + type: "agent.resolve_usage_report.response", + payload: { requestId: msg.requestId, reportId }, + }); + } catch (error) { + this.emitError(msg, error, "agent_resolve_usage_report_failed"); + } + } + + // COMPAT(providerUsageList): added in v0.9.3, remove after 2027-03-26. + async handleLegacyList( + msg: Extract, + ): Promise { + try { + if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); + const usage = await this.options.runtime.listLegacyUsage(); + this.options.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.options.logger.error({ err }, "Failed to list provider usage"); + this.options.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 emitError( + msg: Extract< + SessionInboundMessage, + { type: "usage.list_reports.request" | "agent.resolve_usage_report.request" } + >, + error: unknown, + code: string, + ): void { + this.options.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: error instanceof Error ? error.message : String(error), + code, + }, + }); + } +} diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 9cdce46865..1c038bf377 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -1,3 +1,4 @@ +import { BuiltinPluginLoader } from "../plugins/builtin/index.js"; import os from "node:os"; import path from "node:path"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; @@ -49,6 +50,7 @@ interface TestPaseoDaemonOptions { agentProfiles?: AgentProfile[]; autoArchiveAfterMerge?: boolean; pluginsEnabled?: PaseoDaemonConfig["pluginsEnabled"]; + builtinPlugins?: BuiltinPluginLoader; plugins?: PaseoDaemonConfig["plugins"]; } @@ -100,6 +102,7 @@ export async function createTestPaseoDaemon( const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options); const logger = options.logger ?? pino({ level: "silent" }); const daemon = await createPaseoDaemon(config, logger, { + builtinPlugins: options.builtinPlugins ?? new BuiltinPluginLoader(undefined, []), serverFeatureOverrides: { daemonStatusRpc: options.daemonStatusRpcCapability, relayConfig: options.relayConfigCapability, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7c533ce23d..30a9e59aa9 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -90,7 +90,6 @@ import { type WebSocketRuntimeCounters, type WebSocketRuntimeDiagnosticSnapshot, } from "./websocket/runtime-metrics.js"; -import { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { getProcessMemoryDiagnostics, getProcessUptimeSeconds } from "./process-diagnostics.js"; import { CLIENT_SHUTDOWN_RPC_REASON, @@ -584,7 +583,6 @@ 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; private readonly browserToolsBroker: BrowserToolsBroker | null; private readonly hubRelationships: HubRelationshipManagement | null; @@ -754,10 +752,6 @@ export class VoiceAssistantWebSocketServer { }); }); - this.providerUsageService = new ProviderUsageService({ - logger: this.logger, - }); - this.wss = this.createWebSocketServer(server, wsConfig, auth); this.startRuntimeMetricsInterval(); this.startApplicationSocketLeaseInterval(); @@ -1497,7 +1491,6 @@ export class VoiceAssistantWebSocketServer { tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, - providerUsageService: this.providerUsageService, hubExecutionAgents: options.hubExecutionAgents, hubRelationships: options.hubRelationships, serviceProxy: this.serviceProxy ?? undefined, @@ -1786,6 +1779,7 @@ export class VoiceAssistantWebSocketServer { desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { + usageSources: true, ownedSubscriptions: true, agentRequestReceipts: true, workspaceRequestReceipts: true, diff --git a/packages/server/src/services/quota-fetcher/manifest.ts b/packages/server/src/services/quota-fetcher/manifest.ts deleted file mode 100644 index 11578eccd5..0000000000 --- a/packages/server/src/services/quota-fetcher/manifest.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 { MiniMaxQuotaProvider } from "./providers/minimax.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 }), - }, - { - providerId: "minimax", - create: (options) => new MiniMaxQuotaProvider({ 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 deleted file mode 100644 index c8b6687cec..0000000000 --- a/packages/server/src/services/quota-fetcher/provider.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/codex.ts b/packages/server/src/services/quota-fetcher/providers/codex.ts deleted file mode 100644 index 78a1f7d26b..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/codex.ts +++ /dev/null @@ -1,214 +0,0 @@ -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, - toneFromUsedPct, - fetchProviderApi, - unavailableUsage, - windowFromUsedPct, -} from "../usage.js"; - -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(), -}); - -type CodexAuth = z.infer; -type CodexWindow = z.infer; -type CodexUsageResponse = z.infer; - -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 auth = await this.readCodexAuth(); - const accessToken = auth?.tokens?.access_token; - if (!auth || !accessToken) { - return unavailableUsage(this); - } - - const { account_id } = auth.tokens ?? {}; - const resp = await this.callCodexApi(accessToken, account_id); - - if (resp === "NEEDS_AUTH") { - // Read-only on credentials; the Codex CLI owns refresh. See docs/providers.md. - 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: toneFromUsedPct(session.usedPct), - }), - ); - } - if (weekly) { - windows.push( - windowFromUsedPct({ - id: "weekly", - label: "Weekly", - utilizationPct: weekly.usedPct, - resetsAt: weekly.resetsAt, - tone: toneFromUsedPct(weekly.usedPct), - }), - ); - } - if (codeReview) { - windows.push( - windowFromUsedPct({ - id: "code_review", - label: "Code review", - utilizationPct: codeReview.usedPct, - resetsAt: codeReview.resetsAt, - tone: toneFromUsedPct(codeReview.usedPct), - }), - ); - } - - 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; - } 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)); - } -} diff --git a/packages/server/src/services/quota-fetcher/providers/copilot.ts b/packages/server/src/services/quota-fetcher/providers/copilot.ts deleted file mode 100644 index f8b4f3183e..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/copilot.ts +++ /dev/null @@ -1,97 +0,0 @@ -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/zai.ts b/packages/server/src/services/quota-fetcher/providers/zai.ts deleted file mode 100644 index e8884cf2c4..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/zai.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index a7adb37d3e..0000000000 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ /dev/null @@ -1,1952 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { mkdtempSync, rmSync } from "node:fs"; -import { createRequire } from "node:module"; -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 { MiniMaxQuotaProvider } from "./providers/minimax.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 kimiCredentialPath(dir: string): string { - return join(dir, "credentials", "kimi-code.json"); -} - -function writeKimiCredentials(dir: string, accessToken: string, overrides: object = {}): void { - mkdirSync(join(dir, "credentials"), { recursive: true }); - writeFileSync( - kimiCredentialPath(dir), - JSON.stringify({ - access_token: accessToken, - refresh_token: "rt_kimi", - expires_at: 1_798_812_800, - expires_in: 900, - scope: "kimi-code", - token_type: "Bearer", - ...overrides, - }), - ); -} - -// node:sqlite has no @types/node@20 typings; require it with a narrow local type. -const testRequire = createRequire(import.meta.url); -interface TestSqliteDb { - exec(sql: string): void; - prepare(sql: string): { run(...params: unknown[]): void }; - close(): void; -} - -// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the -// real layouts: a plain modern token or the legacy JSON object. -function writeCursorStateDb(homeDir: string, rows: Record): void { - const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); - mkdirSync(dir, { recursive: true }); - const { DatabaseSync } = testRequire("node:sqlite") as { - DatabaseSync: new (path: string) => TestSqliteDb; - }; - const db = new DatabaseSync(join(dir, "state.vscdb")); - db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); - const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)"); - for (const [key, value] of Object.entries(rows)) { - insert.run(key, value); - } - db.close(); -} - -function writeCursorAuthJson(homeDir: string, accessToken: string): void { - const dir = join(homeDir, ".config", "cursor"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "auth.json"), JSON.stringify({ accessToken })); -} - -function writeGrokAuth(home: string, auth: Record): void { - mkdirSync(join(home, ".grok"), { recursive: true }); - writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); -} - -function writeMiniMaxConfig(dir: string, payload: Record): void { - mkdirSync(join(dir, ".mmx"), { recursive: true }); - writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload)); -} - -function writeMiniMaxCredentials( - dir: string, - accessToken: string, - expiresAt?: string, - resourceUrl?: string, -): void { - mkdirSync(join(dir, ".mmx"), { recursive: true }); - const payload: Record = { access_token: accessToken }; - if (expiresAt !== undefined) payload["expires_at"] = expiresAt; - if (resourceUrl !== undefined) payload["resource_url"] = resourceUrl; - writeFileSync(join(dir, ".mmx", "credentials.json"), JSON.stringify(payload)); -} - -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", - "KIMI_CODE_HOME", - "CODEX_HOME", - "MINIMAX_API_KEY", - "MINIMAX_BASE_URL", - ]) { - 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; - kimiHomeDir?: string; - cursorHomeDir?: string; - miniMaxConfigPath?: string; - miniMaxCredentialsPath?: string; - } = {}, - ) { - 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, - homeDir: options.cursorHomeDir, - }), - new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), - new GrokQuotaProvider({ - logger, - fetch: fetchThroughTestDouble, - // Match Kimi: inject temp HOME so nested auth-file tests work on Windows - // (os.homedir() uses USERPROFILE there and ignores process.env.HOME). - homeDir, - }), - new KimiQuotaProvider({ - logger, - fetch: fetchThroughTestDouble, - // Never leave this undefined: the provider would fall back to os.homedir() and - // read — and now write — the developer's real Kimi credentials. - homeDir: options.kimiHomeDir ?? homeDir, - }), - new MiniMaxQuotaProvider({ - logger, - fetch: fetchThroughTestDouble, - configPath: options.miniMaxConfigPath ?? join(homeDir, ".mmx", "config.json"), - credentialsPath: - options.miniMaxCredentialsPath ?? join(homeDir, ".mmx", "credentials.json"), - }), - ], - 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_model_opus", usedPct: 0.5 }), - ]), - }); - expect(fetchApi).toHaveBeenCalledWith( - "https://api.anthropic.com/api/oauth/usage", - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }); - - it("accepts a null Claude resets_at when a window has no scheduled reset", async () => { - writeClaudeCredentials(claudeHome, "at_valid"); - fetchApi = mockFetch( - new Map([ - [ - "https://api.anthropic.com/api/oauth/usage", - () => - jsonResponse({ - five_hour: { utilization: 0, resets_at: null }, - seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" }, - }), - ], - ]), - ); - - const result = await service().listUsage(); - const claude = findProvider(result, "claude"); - - expect(claude).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ id: "five_hour", usedPct: 0, resetsAt: null }), - expect.objectContaining({ id: "weekly", usedPct: 1 }), - ]), - }); - }); - - 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("returns unavailable on 401 without refreshing or rewriting credentials", async () => { - writeClaudeCredentials(claudeHome, "at_expired", "rt_valid"); - const credPath = join(claudeHome, ".credentials.json"); - const before = readFileSync(credPath, "utf8"); - 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; - return new Response(null, { status: 401 }); - } - // The read-only fetcher must never hit the OAuth token endpoint. - throw new Error(`Unmocked: ${endpoint}`); - }) as never; - - const result = await service().listUsage(); - - expect(findProvider(result, "claude").status).toBe("unavailable"); - expect(usageCalls).toBe(1); - // The credentials file must be left untouched for the Claude CLI to own. - expect(readFileSync(credPath, "utf8")).toBe(before); - }); - - 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 }), - ], - ]), - ); - - const result = await service().listUsage(); - - expect(findProvider(result, "codex").status).toBe("unavailable"); - }); - - it("returns unavailable on 401 without refreshing or rewriting auth.json", async () => { - // Regression: the fetcher used to refresh the token and rewrite auth.json - // through a schema that dropped id_token (and OPENAI_API_KEY/last_refresh), - // leaving the file unparseable by the Codex CLI and forcing a re-login. - const authPath = join(codexHome, "auth.json"); - writeFileSync( - authPath, - JSON.stringify({ - OPENAI_API_KEY: null, - tokens: { - id_token: "id_codex", - access_token: "at_codex_stale", - refresh_token: "rt_codex_valid", - account_id: "acct_codex", - }, - last_refresh: "2026-07-04T20:35:00Z", - }), - ); - const before = readFileSync(authPath, "utf8"); - let usageCalls = 0; - fetchApi = vi.fn(async (url: RequestInfo | URL) => { - const endpoint = url.toString(); - if (endpoint === "https://chatgpt.com/backend-api/wham/usage") { - usageCalls += 1; - return new Response(null, { status: 401 }); - } - // The read-only fetcher must never hit the OAuth token endpoint. - throw new Error(`Unmocked: ${endpoint}`); - }) as never; - - const result = await service().listUsage(); - - expect(findProvider(result, "codex").status).toBe("unavailable"); - expect(usageCalls).toBe(1); - // The auth file must be left byte-for-byte untouched for the Codex CLI to own. - expect(readFileSync(authPath, "utf8")).toBe(before); - }); - - 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("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => { - writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" }); - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { - totalSpend: "1500", - includedSpend: "1000", - bonusSpend: "500", - remaining: "2500", - limit: "4000", - }, - billingCycleStart: "2026-01-14T12:42:14.000Z", - billingCycleEnd: "2026-02-14T12:42:14.000Z", - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_state_jwt"); - expect(cursor).toMatchObject({ - status: "available", - balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], - }); - }); - - it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => { - writeCursorStateDb(homeDir, { - cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"), - }); - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, - billingCycleStart: null, - billingCycleEnd: null, - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_legacy_jwt"); - expect(cursor.status).toBe("available"); - }); - - it("reads the Cursor token from cursor-agent ~/.config/cursor/auth.json when desktop state is absent", async () => { - writeCursorAuthJson(homeDir, "cursor_cli_jwt"); - let authorization: string | null = null; - fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { - totalSpend: "1500", - includedSpend: "1000", - bonusSpend: "500", - remaining: "2500", - limit: "4000", - }, - billingCycleStart: "2026-01-14T12:42:14.000Z", - billingCycleEnd: "2026-02-14T12:42:14.000Z", - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_cli_jwt"); - expect(cursor).toMatchObject({ - status: "available", - balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], - }); - }); - - it("prefers the desktop state.vscdb token over cursor-agent auth.json", async () => { - writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_desktop_jwt" }); - writeCursorAuthJson(homeDir, "cursor_cli_jwt"); - let authorization: string | null = null; - fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, - billingCycleStart: null, - billingCycleEnd: null, - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_desktop_jwt"); - expect(cursor.status).toBe("available"); - }); - - it("logs a debug diagnostic and stays unavailable when state.vscdb is unreadable", async () => { - const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "state.vscdb"), "not a sqlite database"); - const logger = createLogger(); - const provider = new CursorQuotaProvider({ - logger, - fetch: (() => { - throw new Error("usage API should not be called without a token"); - }) as unknown as typeof fetch, - homeDir, - }); - - const usage = await provider.fetchUsage(); - - expect(usage.status).toBe("unavailable"); - expect((logger as unknown as { debug: ReturnType }).debug).toHaveBeenCalledWith( - expect.objectContaining({ path: expect.stringContaining("state.vscdb") }), - expect.stringContaining("Failed to read Cursor token"), - ); - }); - - 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?format=credits", - () => - jsonResponse({ - config: { monthlyLimit: { val: 0 }, used: { val: 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 Grok usage from live billing shape (config.used.val)", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { - monthlyLimit: { val: 150000 }, - used: { val: 37886 }, - billingPeriodStart: "2026-07-01T00:00:00+00:00", - billingPeriodEnd: "2026-08-01T00:00:00+00:00", - }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 37886, - remaining: 112114, - limit: 150000, - unit: "credits", - }), - ], - }); - }); - - it("fetches Grok usage with nested ~/.grok/auth.json key token", async () => { - writeGrokAuth(homeDir, { - "https://auth.x.ai::test-user-id": { - key: "nested_jwt_token", - refresh_token: "rt_nested", - expires_at: "2026-08-01T00:00:00Z", - user_id: "test-user-id", - email: "user@example.com", - }, - }); - - let authorization: string | null = null; - fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - config: { - monthlyLimit: { val: 100 }, - used: { val: 25 }, - }, - }); - }) as typeof fetch; - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(authorization).toBe("Bearer nested_jwt_token"); - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 25, - remaining: 75, - limit: 100, - }), - ], - }); - }); - - it("still accepts legacy Grok usage.creditUsage when config.used is absent", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { monthlyLimit: { val: 50 } }, - usage: { creditUsage: 10 }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 10, - remaining: 40, - limit: 50, - }), - ], - }); - }); - - it("fetches Grok unified-billing usage as a weekly window", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { - currentPeriod: { - type: "USAGE_PERIOD_TYPE_WEEKLY", - start: "2026-08-24T09:41:40.001370+00:00", - end: "2026-08-31T09:41:40.001370+00:00", - }, - creditUsagePercent: 76.0, - isUnifiedBillingUser: true, - billingPeriodStart: "2026-08-24T09:41:40.001370+00:00", - billingPeriodEnd: "2026-08-31T09:41:40.001370+00:00", - }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - windows: [ - { - id: "weekly", - label: "Weekly", - usedPct: 76, - remainingPct: 24, - resetsAt: "2026-08-31T09:41:40.001370+00:00", - tone: "warning", - }, - ], - balances: [], - }); - }); - - 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", - }), - ], - }); - }); - - it("fetches Kimi usage from the CLI credential home", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token"); - let requestedUrl: string | null = null; - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - requestedUrl = url.toString(); - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - usage: { - limit: "200", - remaining: "150", - resetTime: "2026-06-23T05:12:17Z", - }, - }); - }) as unknown as typeof fetch; - - const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi"); - - expect(requestedUrl).toBe("https://api.kimi.com/coding/v1/usages"); - expect(authorization).toBe("Bearer kimi_cli_token"); - expect(kimi).toMatchObject({ - status: "available", - windows: [ - expect.objectContaining({ - id: "coding_usage", - usedPct: 25, - remainingPct: 75, - resetsAt: "2026-06-23T05:12:17Z", - }), - ], - }); - }); - - it("reads Kimi credentials whose optional fields are null", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token", { - expires_at: null, - expires_in: null, - scope: null, - token_type: null, - }); - fetchApi = mockFetch( - new Map([ - [ - "https://api.kimi.com/coding/v1/usages", - () => jsonResponse({ usage: { limit: "100", remaining: "60" } }), - ], - ]), - ); - - const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi"); - - expect(kimi.status).toBe("available"); - }); - - it("returns unavailable on 401 without refreshing or rewriting the credential file", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired"); - const credPath = kimiCredentialPath(join(homeDir, ".kimi-code")); - const before = readFileSync(credPath, "utf8"); - let usageCalls = 0; - fetchApi = vi.fn(async (url: RequestInfo | URL) => { - const endpoint = url.toString(); - if (endpoint === "https://api.kimi.com/coding/v1/usages") { - usageCalls += 1; - return new Response(null, { status: 401 }); - } - // The read-only fetcher must never hit the OAuth token endpoint. - throw new Error(`Unmocked: ${endpoint}`); - }) as never; - - const result = await service({ kimiHomeDir: homeDir }).listUsage(); - - expect(findProvider(result, "kimi").status).toBe("unavailable"); - expect(usageCalls).toBe(1); - // The credentials file must be left untouched for the Kimi CLI to own. - expect(readFileSync(credPath, "utf8")).toBe(before); - }); - - it("does not refresh Kimi tokens read from the environment", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - const usageFetch = vi.fn(async () => new Response(null, { status: 401 })); - fetchApi = usageFetch as never; - - const result = await service().listUsage(); - - expect(findProvider(result, "kimi").status).toBe("unavailable"); - expect(usageFetch).toHaveBeenCalledTimes(1); - }); - - it("does not refresh Kimi tokens on a 403", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_forbidden"); - const usageFetch = vi.fn(async () => new Response(null, { status: 403 })); - fetchApi = usageFetch as never; - - const result = await service({ kimiHomeDir: homeDir }).listUsage(); - - expect(findProvider(result, "kimi").status).toBe("unavailable"); - expect(usageFetch).toHaveBeenCalledTimes(1); - }); - - it("fetches MiniMax usage from MINIMAX_API_KEY against the global endpoint", async () => { - process.env["MINIMAX_API_KEY"] = "minimax_test_token"; - let requestedUrl: string | null = null; - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - requestedUrl = url.toString(); - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - model_remains: [ - { - model_name: "MiniMax-M2.7", - end_time: Date.parse("2026-06-19T05:00:00.000Z"), - weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), - current_interval_total_count: 1000, - current_interval_usage_count: 250, - current_interval_remaining_percent: 75, - current_weekly_total_count: 5000, - current_weekly_usage_count: 1200, - current_weekly_remaining_percent: 76, - }, - ], - }); - }) as unknown as typeof fetch; - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(requestedUrl).toBe("https://api.minimax.io/v1/token_plan/remains"); - expect(authorization).toBe("Bearer minimax_test_token"); - expect(miniMax).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ - id: "interval_MiniMax-M2.7", - label: "MiniMax-M2.7 · Interval", - usedPct: 25, - remainingPct: 75, - resetsAt: "2026-06-19T05:00:00.000Z", - }), - expect.objectContaining({ - id: "weekly_MiniMax-M2.7", - label: "MiniMax-M2.7 · Weekly", - usedPct: 24, - remainingPct: 76, - resetsAt: "2026-06-26T00:00:00.000Z", - }), - ]), - }); - }); - - it("returns unavailable MiniMax usage when no credentials are configured", async () => { - fetchApi = vi.fn() as never; - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax.status).toBe("unavailable"); - expect(fetchApi).not.toHaveBeenCalled(); - }); - - it("reads MiniMax OAuth credentials from the CLI credentials file", async () => { - writeMiniMaxCredentials( - homeDir, - "minimax_oauth_token", - "2030-01-01T00:00:00.000Z", - "https://account.example.com", - ); - let requestedUrl: string | null = null; - fetchApi = (async (url: RequestInfo | URL) => { - requestedUrl = url.toString(); - return jsonResponse({ model_remains: [] }); - }) as unknown as typeof fetch; - - await service().listUsage(); - - expect(requestedUrl).toBe("https://account.example.com/v1/token_plan/remains"); - }); - - it("falls back to MiniMax api_key in the CLI config file", async () => { - writeMiniMaxConfig(homeDir, { - api_key: "minimax_config_key", - region: "cn", - }); - let requestedUrl: string | null = null; - fetchApi = (async (url: RequestInfo | URL) => { - requestedUrl = url.toString(); - return jsonResponse({ model_remains: [] }); - }) as unknown as typeof fetch; - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(requestedUrl).toBe("https://api.minimaxi.com/v1/token_plan/remains"); - expect(miniMax.status).toBe("unavailable"); - }); - - it("reports MiniMax accounts with no active token plan as unavailable", async () => { - writeMiniMaxConfig(homeDir, { api_key: "minimax_config_key", region: "cn" }); - fetchApi = mockFetch( - new Map([ - [ - "https://api.minimaxi.com/v1/token_plan/remains", - () => - jsonResponse({ - model_remains: null, - base_resp: { status_code: 2062, status_msg: "no active token plan subscription" }, - }), - ], - ]), - ); - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null }); - }); - - it("reports a null MiniMax token plan as unavailable even without base_resp", async () => { - process.env["MINIMAX_API_KEY"] = "minimax_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.minimax.io/v1/token_plan/remains", - () => jsonResponse({ model_remains: null }), - ], - ]), - ); - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null }); - }); - - it("still reads MiniMax windows when base_resp reports success", async () => { - process.env["MINIMAX_API_KEY"] = "minimax_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.minimax.io/v1/token_plan/remains", - () => - jsonResponse({ - base_resp: { status_code: 0, status_msg: "success" }, - model_remains: [ - { - model_name: "MiniMax-M2.7", - end_time: Date.parse("2026-06-19T05:00:00.000Z"), - current_interval_total_count: 100, - current_interval_usage_count: 25, - current_interval_remaining_percent: 75, - }, - ], - }), - ], - ]), - ); - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ id: "interval_MiniMax-M2.7", usedPct: 25 }), - ]), - }); - }); - - it("marks exhausted MiniMax interval windows with a danger tone", async () => { - process.env["MINIMAX_API_KEY"] = "minimax_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.minimax.io/v1/token_plan/remains", - () => - jsonResponse({ - model_remains: [ - { - model_name: "MiniMax-M2.7", - end_time: Date.parse("2026-06-19T05:00:00.000Z"), - weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), - current_interval_total_count: 100, - current_interval_usage_count: 100, - current_interval_remaining_percent: 0, - current_interval_status: 2, - current_weekly_total_count: 100, - current_weekly_usage_count: 10, - current_weekly_remaining_percent: 90, - current_weekly_status: 1, - }, - ], - }), - ], - ]), - ); - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ - id: "interval_MiniMax-M2.7", - usedPct: 100, - tone: "danger", - }), - expect.objectContaining({ - id: "weekly_MiniMax-M2.7", - tone: "ok", - }), - ]), - }); - }); -}); - -// Regression for #2320: providers hardcoded `tone: "ok"`, which suppressed the client's -// own thresholds (window-bar.tsx reads `window.tone ?? deriveTone(usedPct)`), so a bar -// stayed green at 99%. Codex escalated to "warning" but could never reach "danger". -describe("usage bars escalate as they fill", () => { - let claudeHome: string; - let codexHome: string; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "paseo-tone-claude-")); - codexHome = mkdtempSync(join(tmpdir(), "paseo-tone-codex-")); - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - rmSync(codexHome, { recursive: true, force: true }); - }); - - function claudeAt(utilization: number) { - writeClaudeCredentials(claudeHome, "at_valid"); - return new ClaudeQuotaProvider({ - logger: createLogger(), - claudeHome, - claudeKeychainReader: async () => null, - fetch: mockFetch( - new Map([ - [ - "https://api.anthropic.com/api/oauth/usage", - () => - jsonResponse({ - seven_day: { utilization, resets_at: "2026-06-04T00:00:00Z" }, - }), - ], - ]), - ), - }).fetchUsage(); - } - - it.each([ - [10, "ok"], - [75, "warning"], - [99, "danger"], - ])("a Claude window at %s%% is %s", async (utilization, tone) => { - const usage = await claudeAt(utilization); - expect(usage.windows).toEqual([expect.objectContaining({ id: "weekly", tone })]); - }); - - it("a Codex window can reach danger, not just warning", async () => { - writeCodexAuth(codexHome, "at_codex"); - const usage = await new CodexQuotaProvider({ - logger: createLogger(), - codexHome, - fetch: mockFetch( - new Map([ - [ - "https://chatgpt.com/backend-api/wham/usage", - () => - jsonResponse( - makeCodexResponse({ - rate_limit: { - primary_window: { used_percent: 12, reset_at: 1_748_812_800 }, - secondary_window: { used_percent: 96, reset_at: 1_749_072_000 }, - }, - }), - ), - ], - ]), - ), - }).fetchUsage(); - - expect(usage.windows).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "session", tone: "ok" }), - expect.objectContaining({ id: "weekly", tone: "danger" }), - ]), - ); - }); -}); - -// Model- and surface-scoped weekly limits arrive in a `limits[]` array rather than the -// top-level `seven_day_*` keys, which now return null on most accounts. -describe("ClaudeQuotaProvider scoped weekly limits", () => { - let claudeHome: string; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-limits-")); - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - }); - - function fableLimit(overrides: Record = {}) { - return { - kind: "weekly_scoped", - group: "weekly", - percent: 0, - severity: "normal", - resets_at: "2026-06-04T00:00:00Z", - is_active: false, - scope: { model: { id: null, display_name: "Fable" }, surface: null }, - ...overrides, - }; - } - - function claudeProvider(body: unknown) { - writeClaudeCredentials(claudeHome, "at_valid"); - const logger = createLogger() as unknown as { warn: ReturnType }; - const provider = new ClaudeQuotaProvider({ - logger: logger as never, - claudeHome, - claudeKeychainReader: async () => null, - fetch: mockFetch( - new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), - ), - }); - return { provider, logger }; - } - - it("renders a scoped weekly limit as its own window", async () => { - const { provider } = claudeProvider({ - five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [fableLimit()], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toContainEqual( - expect.objectContaining({ id: "weekly_model_fable", label: "Weekly · Fable" }), - ); - }); - - it("renders a scoped window that is at zero and inactive", async () => { - const { provider } = claudeProvider({ - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [fableLimit({ percent: 0, is_active: false })], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toContainEqual( - expect.objectContaining({ id: "weekly_model_fable", usedPct: 0, remainingPct: 100 }), - ); - }); - - it("ignores session and all-models entries so they do not duplicate the top-level windows", async () => { - const { provider } = claudeProvider({ - five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [ - { kind: "session", percent: 6, resets_at: "2026-06-01T21:00:00Z", scope: null }, - { kind: "weekly_all", percent: 23, resets_at: "2026-06-04T00:00:00Z", scope: null }, - fableLimit(), - ], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows.map((window) => window.id)).toEqual([ - "five_hour", - "weekly", - "weekly_model_fable", - ]); - }); - - it("labels a surface-scoped limit from its surface name", async () => { - const { provider } = claudeProvider({ - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [ - fableLimit({ scope: { model: null, surface: { id: "code", display_name: "Code" } } }), - ], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toContainEqual( - expect.objectContaining({ id: "weekly_surface_code", label: "Weekly · Code" }), - ); - }); - - it("skips a scoped limit with no resolvable label rather than rendering an unlabelled bar", async () => { - const { provider, logger } = claudeProvider({ - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [fableLimit({ scope: { model: { id: null, display_name: null }, surface: null } })], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows.map((window) => window.id)).toEqual(["weekly"]); - expect(logger.warn).toHaveBeenCalled(); - }); - - // Regression: an additive section must never take down data that already parsed. - it("keeps the top-level windows when a limits entry is malformed", async () => { - const { provider, logger } = claudeProvider({ - five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [{ percent: "not-a-kind" }, fableLimit()], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.status).toBe("available"); - expect(usage.windows.map((window) => window.id)).toEqual([ - "five_hour", - "weekly", - "weekly_model_fable", - ]); - expect(logger.warn).toHaveBeenCalled(); - }); - - it("warns when a successful response describes no windows at all", async () => { - const { provider, logger } = claudeProvider({ limits: [] }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toEqual([]); - expect(logger.warn).toHaveBeenCalledWith( - "Claude usage response parsed but produced no windows", - ); - }); -}); -/** - * The reconciliation matrix. - * - * Four review rounds on PR #2303 each found a different hole in how the two - * representations of one scoped limit get combined, because each fix was tested against - * the case that was reported rather than the space of cases. This walks the space: - * every combination of which representation carries the limit, whether the `limits[]` - * entry supplies values, and whether the two descriptions denote the same limit at all. - */ -describe("ClaudeQuotaProvider scoped limit reconciliation", () => { - let claudeHome: string; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-matrix-")); - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - }); - - const RESETS = "2026-06-04T00:00:00Z"; - - function scoped(scope: unknown, percent: number | null = 30, resetsAt: string | null = RESETS) { - return { kind: "weekly_scoped", percent, resets_at: resetsAt, scope }; - } - - const model = (name: string | null, id: string | null = null) => ({ - model: { id, display_name: name }, - surface: null, - }); - const surface = (name: string | null, id: string | null = null) => ({ - model: null, - surface: { id, display_name: name }, - }); - - async function windowsFor(body: Record) { - writeClaudeCredentials(claudeHome, "at_valid"); - const usage = await new ClaudeQuotaProvider({ - logger: createLogger(), - claudeHome, - claudeKeychainReader: async () => null, - fetch: mockFetch( - new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), - ), - }).fetchUsage(); - return usage.windows; - } - - it("which representation carries the limit: legacy only", async () => { - const windows = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - }); - expect(windows).toEqual([ - expect.objectContaining({ - id: "weekly_model_omelette", - label: "Weekly · Omelette", - usedPct: 12, - }), - ]); - }); - - it("which representation carries the limit: limits[] only", async () => { - const windows = await windowsFor({ limits: [scoped(model("Fable"), 2)] }); - expect(windows).toEqual([ - expect.objectContaining({ - id: "weekly_model_fable", - label: "Weekly · Fable", - usedPct: 2, - }), - ]); - }); - - it("which representation carries the limit: both, same limit — one bar, scoped identity", async () => { - const windows = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - limits: [scoped(model("Omelette"), 30)], - }); - expect(windows).toEqual([ - expect.objectContaining({ id: "weekly_model_omelette", usedPct: 30 }), - ]); - }); - - it("which representation carries the limit: both, different limits — two bars", async () => { - const windows = await windowsFor({ - seven_day_opus: { utilization: 8, resets_at: RESETS }, - limits: [scoped(model("Fable"), 2)], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_opus", "weekly_model_fable"]); - }); - - it("which representation carries the limit: neither", async () => { - const windows = await windowsFor({ seven_day: { utilization: 23, resets_at: RESETS } }); - expect(windows.map((w) => w.id)).toEqual(["weekly"]); - }); - - const legacy = { seven_day_omelette: { utilization: 12, resets_at: RESETS } }; - - it("value fallback when the scoped entry is sparse: scoped values win when present", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), 30, "2026-06-09T00:00:00Z")], - }); - expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: "2026-06-09T00:00:00Z" }); - }); - - it("value fallback when the scoped entry is sparse: percentage falls back per field", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), null, "2026-06-09T00:00:00Z")], - }); - expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: "2026-06-09T00:00:00Z" }); - }); - - it("value fallback when the scoped entry is sparse: reset time falls back per field", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), 30, null)], - }); - expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: RESETS }); - }); - - it("value fallback when the scoped entry is sparse: both fall back when the scoped entry only names the limit", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), null, null)], - }); - expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: RESETS }); - }); - - it("value fallback when the scoped entry is sparse: stays empty when neither side has a value", async () => { - const windows = await windowsFor({ limits: [scoped(model("Fable"), null, null)] }); - expect(windows[0]).toMatchObject({ id: "weekly_model_fable", usedPct: null }); - }); - - it("identity: a surface never matches a legacy model window of the same name", async () => { - const windows = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - limits: [scoped(surface("Omelette"), 30)], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_omelette", "weekly_surface_omelette"]); - expect(windows[0]).toMatchObject({ usedPct: 12 }); - expect(windows[1]).toMatchObject({ usedPct: 30 }); - }); - - it("identity: a model and a surface of the same name stay apart", async () => { - const windows = await windowsFor({ - limits: [scoped(model("Code"), 4), scoped(surface("Code"), 9)], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_code", "weekly_surface_code"]); - }); - - it("identity: ids decide when both sides have one", async () => { - const windows = await windowsFor({ - limits: [ - scoped(model("Fable-Pro", "fable-pro"), 4), - scoped(model("Fable_Pro", "fable_pro"), 9), - ], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_fable-pro", "weekly_model_fable_pro"]); - }); - - it("identity: names decide when ids are absent, so indistinguishable entries merge", async () => { - const windows = await windowsFor({ - limits: [scoped(model("Fable Pro"), 4), scoped(model("Fable-Pro"), 9)], - }); - expect(windows).toEqual([ - expect.objectContaining({ id: "weekly_model_fable_pro", usedPct: 9 }), - ]); - }); - - it("identity: a renamed scope keeps its id when the API supplies one", async () => { - const before = await windowsFor({ limits: [scoped(model("Fable", "fable"), 2)] }); - const after = await windowsFor({ limits: [scoped(model("Fable 5", "fable"), 2)] }); - expect(before[0]?.id).toBe("weekly_model_fable"); - expect(after[0]?.id).toBe("weekly_model_fable"); - expect(after[0]?.label).toBe("Weekly · Fable 5"); - }); - - it("identity: a limit keeps one id whichever representation carries it", async () => { - const viaLegacy = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - }); - const viaLimits = await windowsFor({ limits: [scoped(model("Omelette"), 12)] }); - expect(viaLegacy[0]?.id).toBe(viaLimits[0]?.id); - }); - - it("ordering and unscoped windows: puts session and weekly ahead of the scoped bars", async () => { - const windows = await windowsFor({ - five_hour: { utilization: 6, resets_at: RESETS }, - seven_day: { utilization: 23, resets_at: RESETS }, - seven_day_opus: { utilization: 8, resets_at: RESETS }, - limits: [ - { kind: "session", percent: 6, resets_at: RESETS, scope: null }, - { kind: "weekly_all", percent: 23, resets_at: RESETS, scope: null }, - scoped(model("Fable"), 2), - ], - }); - expect(windows.map((w) => w.id)).toEqual([ - "five_hour", - "weekly", - "weekly_model_opus", - "weekly_model_fable", - ]); - }); -}); - -describe("KimiQuotaProvider usage windows", () => { - afterEach(() => { - delete process.env["KIMI_TOKEN"]; - vi.restoreAllMocks(); - }); - - it("normalizes weekly and enforced rolling usage windows", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - const fetchApi = vi.fn(async () => - jsonResponse({ - limited: true, - usage: { - limit: "100", - used: "61", - remaining: "39", - resetTime: "2026-08-05T00:01:45Z", - }, - limits: [ - { - window: { - duration: 300, - timeUnit: "TIME_UNIT_MINUTE", - }, - detail: { - limit: "100", - used: "100", - resetTime: "2026-07-31T17:01:45Z", - }, - }, - ], - }), - ); - const provider = new KimiQuotaProvider({ logger: createLogger(), fetch: fetchApi }); - - const usage = await provider.fetchUsage(); - - expect(usage).toMatchObject({ - status: "available", - windows: [ - { - id: "coding_usage", - label: "Weekly limit", - usedPct: 61, - remainingPct: 39, - resetsAt: "2026-08-05T00:01:45Z", - tone: "ok", - }, - { - id: "coding_limit_300_time_unit_minute", - label: "5-hour limit", - usedPct: 100, - remainingPct: 0, - resetsAt: "2026-07-31T17:01:45Z", - tone: "danger", - }, - ], - }); - }); - - it("keeps valid windows when another limits entry is malformed", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - const logger = createLogger() as unknown as { debug: ReturnType }; - const fetchApi = vi.fn(async () => - jsonResponse({ - usage: { - limit: "100", - remaining: "75", - resetTime: "2026-08-05T00:01:45Z", - }, - limits: [ - { window: { duration: "invalid" }, detail: {} }, - { - window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, - detail: { limit: "100", remaining: "50" }, - }, - ], - }), - ); - const provider = new KimiQuotaProvider({ logger: logger as never, fetch: fetchApi }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toHaveLength(2); - expect(usage.windows[1]).toMatchObject({ - label: "5-hour limit", - usedPct: 50, - remainingPct: 50, - }); - expect(logger.debug).toHaveBeenCalledWith( - { index: 0 }, - "Ignoring malformed Kimi usage limit window", - ); - }); - - it("accepts direct limit fields, alternate reset keys, and provider labels", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - const fetchApi = vi.fn(async () => - jsonResponse({ - usage: null, - limits: [ - { - name: "Burst quota", - limit: "80", - remaining: "20", - reset_at: "2026-08-01T00:00:00Z", - }, - ], - }), - ); - const provider = new KimiQuotaProvider({ logger: createLogger(), fetch: fetchApi }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toEqual([ - expect.objectContaining({ - id: "coding_limit_burst_quota", - label: "Burst quota", - usedPct: 75, - remainingPct: 25, - resetsAt: "2026-08-01T00:00:00Z", - }), - ]); - }); - - it("keeps window ids unique when Kimi returns duplicate limit descriptors", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - const duplicate = { - window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, - detail: { limit: "100", used: "10" }, - }; - const fetchApi = vi.fn(async () => jsonResponse({ limits: [duplicate, duplicate] })); - const provider = new KimiQuotaProvider({ logger: createLogger(), fetch: fetchApi }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows.map((window) => window.id)).toEqual([ - "coding_limit_300_time_unit_minute", - "coding_limit_300_time_unit_minute_2", - ]); - }); -}); diff --git a/packages/server/src/services/quota-fetcher/service.ts b/packages/server/src/services/quota-fetcher/service.ts deleted file mode 100644 index dde20fb245..0000000000 --- a/packages/server/src/services/quota-fetcher/service.ts +++ /dev/null @@ -1,89 +0,0 @@ -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/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 2611fee842..c7d4126af1 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -777,22 +777,13 @@ describe("relative typed-entry configuration", () => { }); it("suffix mode resolves exact workspace file paths before broad traversal", async () => { - const targetPath = path.join( - workspaceDir, - "packages", - "server", - "src", - "services", - "quota-fetcher", - "providers", - "local.ts", - ); + const targetPath = path.join(workspaceDir, "plugins", "usage-sources", "providers", "local.ts"); mkdirSync(path.dirname(targetPath), { recursive: true }); writeFileSync(targetPath, ""); const results = await searchRelativeDirectoryEntries({ cwd: workspaceDir, - query: "packages/server/src/services/quota-fetcher/providers/local.ts", + query: "plugins/usage-sources/providers/local.ts", limit: 20, includeFiles: true, includeDirectories: false, @@ -802,7 +793,7 @@ describe("relative typed-entry configuration", () => { expect(results).toEqual([ { - path: "packages/server/src/services/quota-fetcher/providers/local.ts", + path: "plugins/usage-sources/providers/local.ts", kind: "file", }, ]); diff --git a/plugins/claude-usage-source/icon.svg b/plugins/claude-usage-source/icon.svg new file mode 100644 index 0000000000..b8c36572e5 --- /dev/null +++ b/plugins/claude-usage-source/icon.svg @@ -0,0 +1 @@ + diff --git a/plugins/claude-usage-source/index.server.ts b/plugins/claude-usage-source/index.server.ts new file mode 100644 index 0000000000..ce5b16c4f7 --- /dev/null +++ b/plugins/claude-usage-source/index.server.ts @@ -0,0 +1,16 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { fetchUsage, identify } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "claude", + label: "Claude", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + identify, + fetch: fetchUsage, + }); + return () => {}; +} diff --git a/plugins/claude-usage-source/paseo-plugin.json b/plugins/claude-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..f4e639a515 --- /dev/null +++ b/plugins/claude-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "claude-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts b/plugins/claude-usage-source/server/keychain.test.ts similarity index 50% rename from packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts rename to plugins/claude-usage-source/server/keychain.test.ts index 0f6be01f32..b4d5295185 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts +++ b/plugins/claude-usage-source/server/keychain.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./claude.js"; +import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./usage.js"; const SERVICE = "Claude Code-credentials"; @@ -85,3 +85,80 @@ describe("readClaudeKeychainCredentials", () => { }); }); }); + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fetchUsage } from "./usage.js"; + +function fixtureFetch(expectedToken: string): typeof fetch { + return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + expect(init?.headers).toMatchObject({ Authorization: `Bearer ${expectedToken}` }); + return new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { status: 200 }); + }) as unknown as typeof fetch; +} + +describe("Claude usage input forms", () => { + it("reads only the selected configDir file", async () => { + const dir = mkdtempSync(join(tmpdir(), "claude-usage-input-")); + try { + writeFileSync( + join(dir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "fixture-file" } }), + ); + expect((await fetchUsage({ configDir: dir }, fixtureFetch("fixture-file"))).status).toBe( + "available", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("uses an accessToken without reading a file", async () => { + expect( + (await fetchUsage({ accessToken: "fixture-direct" }, fixtureFetch("fixture-direct"))).status, + ).toBe("available"); + }); + it("default discovery reads the credential file", async () => { + const dir = mkdtempSync(join(tmpdir(), "claude-usage-default-")); + try { + writeFileSync( + join(dir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }), + ); + vi.stubEnv("CLAUDE_HOME", dir); + expect((await fetchUsage({}, fixtureFetch("fixture-default"))).status).toBe("available"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("stays unavailable when non-default configDir has no credentials", async () => { + const dir = mkdtempSync(join(tmpdir(), "claude-empty-config-")); + try { + const fetchApi = vi.fn() as unknown as typeof fetch; + expect((await fetchUsage({ configDir: dir }, fetchApi)).status).toBe("unavailable"); + expect(fetchApi).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("Claude Keychain token usage", () => { + it("returns unavailable on 401 without attempting refresh", async () => { + const run = vi.fn(async () => + JSON.stringify({ + claudeAiOauth: { accessToken: "fixture-expired", refreshToken: "fixture-refresh" }, + }), + ); + const credentials = await readClaudeKeychainCredentials(run, "fixture-account"); + const token = (credentials as { claudeAiOauth: { accessToken: string } }).claudeAiOauth + .accessToken; + const fetchApi = vi.fn( + async () => new Response(null, { status: 401 }), + ) as unknown as typeof fetch; + const report = await fetchUsage({ accessToken: token }, fetchApi); + expect(report.status).toBe("unavailable"); + expect(fetchApi).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..421d0abd8a --- /dev/null +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -0,0 +1,777 @@ +import { readFileSync, writeFileSync, 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 { fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +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 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 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" }, + }); +} + +describe("claude usage source", () => { + let claudeHome: string; + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "usage-claude-")); + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = homeDir; + process.env["CLAUDE_HOME"] = claudeHome; + 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", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "claude", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + 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_model_opus", usedPct: 0.5 }), + ]), + }); + expect(fetchApi).toHaveBeenCalledWith( + "https://api.anthropic.com/api/oauth/usage", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("accepts a null Claude resets_at when a window has no scheduled reset", async () => { + writeClaudeCredentials(claudeHome, "at_valid"); + fetchApi = mockFetch( + new Map([ + [ + "https://api.anthropic.com/api/oauth/usage", + () => + jsonResponse({ + five_hour: { utilization: 0, resets_at: null }, + seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" }, + }), + ], + ]), + ); + + const result = await service().listUsage(); + const claude = findProvider(result, "claude"); + + expect(claude).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ id: "five_hour", usedPct: 0, resetsAt: null }), + expect.objectContaining({ id: "weekly", usedPct: 1 }), + ]), + }); + }); + + 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("returns unavailable on 401 without refreshing or rewriting credentials", async () => { + writeClaudeCredentials(claudeHome, "at_expired", "rt_valid"); + const credPath = join(claudeHome, ".credentials.json"); + const before = readFileSync(credPath, "utf8"); + 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; + return new Response(null, { status: 401 }); + } + // The read-only fetcher must never hit the OAuth token endpoint. + throw new Error(`Unmocked: ${endpoint}`); + }) as never; + + const result = await service().listUsage(); + + expect(findProvider(result, "claude").status).toBe("unavailable"); + expect(usageCalls).toBe(1); + // The credentials file must be left untouched for the Claude CLI to own. + expect(readFileSync(credPath, "utf8")).toBe(before); + }); +}); + +describe("usage bars escalate as they fill", () => { + let claudeHome: string; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-tone-claude-")); + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + }); + + function claudeAt(utilization: number) { + writeClaudeCredentials(claudeHome, "at_valid"); + process.env["CLAUDE_HOME"] = claudeHome; + return fetchUsage( + {}, + mockFetch( + new Map([ + [ + "https://api.anthropic.com/api/oauth/usage", + () => jsonResponse({ seven_day: { utilization, resets_at: "2026-06-04T00:00:00Z" } }), + ], + ]), + ), + ); + } + + it.each([ + [10, "ok"], + [75, "warning"], + [99, "danger"], + ])("a Claude window at %s%% is %s", async (utilization, tone) => { + const usage = await claudeAt(utilization); + expect(usage.windows).toEqual([expect.objectContaining({ id: "weekly", tone })]); + }); +}); + +describe("Claude usage source scoped weekly limits", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + let claudeHome: string; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-limits-")); + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + }); + + function fableLimit(overrides: Record = {}) { + return { + kind: "weekly_scoped", + group: "weekly", + percent: 0, + severity: "normal", + resets_at: "2026-06-04T00:00:00Z", + is_active: false, + scope: { model: { id: null, display_name: "Fable" }, surface: null }, + ...overrides, + }; + } + + function claudeProvider(body: unknown) { + writeClaudeCredentials(claudeHome, "at_valid"); + process.env["CLAUDE_HOME"] = claudeHome; + const fetchApi = mockFetch( + new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), + ); + return { provider: { fetchUsage: () => fetchUsage({}, fetchApi) } }; + } + + it("renders a scoped weekly limit as its own window", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_model_fable", label: "Weekly · Fable" }), + ); + }); + + it("renders a scoped window that is at zero and inactive", async () => { + const { provider } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit({ percent: 0, is_active: false })], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_model_fable", usedPct: 0, remainingPct: 100 }), + ); + }); + + it("ignores session and all-models entries so they do not duplicate the top-level windows", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [ + { kind: "session", percent: 6, resets_at: "2026-06-01T21:00:00Z", scope: null }, + { kind: "weekly_all", percent: 23, resets_at: "2026-06-04T00:00:00Z", scope: null }, + fableLimit(), + ], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => window.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_fable", + ]); + }); + + it("labels a surface-scoped limit from its surface name", async () => { + const { provider } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [ + fableLimit({ scope: { model: null, surface: { id: "code", display_name: "Code" } } }), + ], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_surface_code", label: "Weekly · Code" }), + ); + }); + + it("skips a scoped limit with no resolvable label rather than rendering an unlabelled bar", async () => { + const { provider } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit({ scope: { model: { id: null, display_name: null }, surface: null } })], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => window.id)).toEqual(["weekly"]); + expect(console.warn).toHaveBeenCalled(); + }); + + // Regression: an additive section must never take down data that already parsed. + it("keeps the top-level windows when a limits entry is malformed", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [{ percent: "not-a-kind" }, fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.status).toBe("available"); + expect(usage.windows.map((window) => window.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_fable", + ]); + expect(console.warn).toHaveBeenCalled(); + }); + + it("warns when a successful response describes no windows at all", async () => { + const { provider } = claudeProvider({ limits: [] }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toEqual([]); + expect(console.warn).toHaveBeenCalledWith( + "Claude usage response parsed but produced no windows", + ); + }); +}); +/** + * The reconciliation matrix. + * + * Four review rounds on PR #2303 each found a different hole in how the two + * representations of one scoped limit get combined, because each fix was tested against + * the case that was reported rather than the space of cases. This walks the space: + * every combination of which representation carries the limit, whether the `limits[]` + * entry supplies values, and whether the two descriptions denote the same limit at all. + */ + +describe("Claude usage source scoped limit reconciliation", () => { + let claudeHome: string; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-matrix-")); + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + }); + + const RESETS = "2026-06-04T00:00:00Z"; + + function scoped(scope: unknown, percent: number | null = 30, resetsAt: string | null = RESETS) { + return { kind: "weekly_scoped", percent, resets_at: resetsAt, scope }; + } + + const model = (name: string | null, id: string | null = null) => ({ + model: { id, display_name: name }, + surface: null, + }); + const surface = (name: string | null, id: string | null = null) => ({ + model: null, + surface: { id, display_name: name }, + }); + + async function windowsFor(body: Record) { + writeClaudeCredentials(claudeHome, "at_valid"); + process.env["CLAUDE_HOME"] = claudeHome; + const usage = await fetchUsage( + {}, + mockFetch(new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]])), + ); + return usage.windows; + } + + it("which representation carries the limit: legacy only", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + }); + expect(windows).toEqual([ + expect.objectContaining({ + id: "weekly_model_omelette", + label: "Weekly · Omelette", + usedPct: 12, + }), + ]); + }); + + it("which representation carries the limit: limits[] only", async () => { + const windows = await windowsFor({ limits: [scoped(model("Fable"), 2)] }); + expect(windows).toEqual([ + expect.objectContaining({ + id: "weekly_model_fable", + label: "Weekly · Fable", + usedPct: 2, + }), + ]); + }); + + it("which representation carries the limit: both, same limit — one bar, scoped identity", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + limits: [scoped(model("Omelette"), 30)], + }); + expect(windows).toEqual([ + expect.objectContaining({ id: "weekly_model_omelette", usedPct: 30 }), + ]); + }); + + it("which representation carries the limit: both, different limits — two bars", async () => { + const windows = await windowsFor({ + seven_day_opus: { utilization: 8, resets_at: RESETS }, + limits: [scoped(model("Fable"), 2)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_opus", "weekly_model_fable"]); + }); + + it("which representation carries the limit: neither", async () => { + const windows = await windowsFor({ seven_day: { utilization: 23, resets_at: RESETS } }); + expect(windows.map((w) => w.id)).toEqual(["weekly"]); + }); + + const legacy = { seven_day_omelette: { utilization: 12, resets_at: RESETS } }; + + it("value fallback when the scoped entry is sparse: scoped values win when present", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), 30, "2026-06-09T00:00:00Z")], + }); + expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: "2026-06-09T00:00:00Z" }); + }); + + it("value fallback when the scoped entry is sparse: percentage falls back per field", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), null, "2026-06-09T00:00:00Z")], + }); + expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: "2026-06-09T00:00:00Z" }); + }); + + it("value fallback when the scoped entry is sparse: reset time falls back per field", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), 30, null)], + }); + expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: RESETS }); + }); + + it("value fallback when the scoped entry is sparse: both fall back when the scoped entry only names the limit", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), null, null)], + }); + expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: RESETS }); + }); + + it("value fallback when the scoped entry is sparse: stays empty when neither side has a value", async () => { + const windows = await windowsFor({ limits: [scoped(model("Fable"), null, null)] }); + expect(windows[0]).toMatchObject({ id: "weekly_model_fable", usedPct: null }); + }); + + it("identity: a surface never matches a legacy model window of the same name", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + limits: [scoped(surface("Omelette"), 30)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_omelette", "weekly_surface_omelette"]); + expect(windows[0]).toMatchObject({ usedPct: 12 }); + expect(windows[1]).toMatchObject({ usedPct: 30 }); + }); + + it("identity: a model and a surface of the same name stay apart", async () => { + const windows = await windowsFor({ + limits: [scoped(model("Code"), 4), scoped(surface("Code"), 9)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_code", "weekly_surface_code"]); + }); + + it("identity: ids decide when both sides have one", async () => { + const windows = await windowsFor({ + limits: [ + scoped(model("Fable-Pro", "fable-pro"), 4), + scoped(model("Fable_Pro", "fable_pro"), 9), + ], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_fable-pro", "weekly_model_fable_pro"]); + }); + + it("identity: names decide when ids are absent, so indistinguishable entries merge", async () => { + const windows = await windowsFor({ + limits: [scoped(model("Fable Pro"), 4), scoped(model("Fable-Pro"), 9)], + }); + expect(windows).toEqual([ + expect.objectContaining({ id: "weekly_model_fable_pro", usedPct: 9 }), + ]); + }); + + it("identity: a renamed scope keeps its id when the API supplies one", async () => { + const before = await windowsFor({ limits: [scoped(model("Fable", "fable"), 2)] }); + const after = await windowsFor({ limits: [scoped(model("Fable 5", "fable"), 2)] }); + expect(before[0]?.id).toBe("weekly_model_fable"); + expect(after[0]?.id).toBe("weekly_model_fable"); + expect(after[0]?.label).toBe("Weekly · Fable 5"); + }); + + it("identity: a limit keeps one id whichever representation carries it", async () => { + const viaLegacy = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + }); + const viaLimits = await windowsFor({ limits: [scoped(model("Omelette"), 12)] }); + expect(viaLegacy[0]?.id).toBe(viaLimits[0]?.id); + }); + + it("ordering and unscoped windows: puts session and weekly ahead of the scoped bars", async () => { + const windows = await windowsFor({ + five_hour: { utilization: 6, resets_at: RESETS }, + seven_day: { utilization: 23, resets_at: RESETS }, + seven_day_opus: { utilization: 8, resets_at: RESETS }, + limits: [ + { kind: "session", percent: 6, resets_at: RESETS, scope: null }, + { kind: "weekly_all", percent: 23, resets_at: RESETS, scope: null }, + scoped(model("Fable"), 2), + ], + }); + expect(windows.map((w) => w.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_opus", + "weekly_model_fable", + ]); + }); +}); + +it("identify reads account and organization from the selected Claude config", async () => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { identify } = await import("./usage.js"); + const directory = await mkdtemp(join(tmpdir(), "claude-identity-")); + try { + await writeFile( + join(directory, ".claude.json"), + JSON.stringify({ + oauthAccount: { + accountUuid: "account-uuid", + organizationUuid: "org-uuid", + emailAddress: "test@example.com", + }, + }), + ); + expect(await identify({ configDir: directory })).toEqual({ + key: "account-uuid.org-uuid", + label: "test@example.com", + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("token-only Claude inputs use a cached OAuth profile identity", async () => { + const { identify } = await import("./usage.js"); + let calls = 0; + const fetchProfile: typeof fetch = async () => { + calls++; + return new Response( + JSON.stringify({ + account: { uuid: "account-uuid", email: "test@example.com" }, + organization: { uuid: "org-uuid" }, + }), + { status: 200 }, + ); + }; + expect(await identify({ accessToken: "fixture-claude-token" }, fetchProfile)).toEqual({ + key: "account-uuid.org-uuid", + label: "test@example.com", + }); + expect(await identify({ accessToken: "fixture-claude-token" }, fetchProfile)).toEqual({ + key: "account-uuid.org-uuid", + label: "test@example.com", + }); + expect(calls).toBe(1); +}); + +it("identifies the same configDir credentials that fetch uses when oauthAccount is absent", async () => { + const { identify } = await import("./usage.js"); + const directory = mkdtempSync(join(tmpdir(), "claude-credential-identity-")); + try { + writeClaudeCredentials(directory, "fixture-credential-token"); + writeFileSync(join(directory, ".claude.json"), JSON.stringify({})); + let usageRequested = false; + await fetchUsage({ configDir: directory }, async () => { + usageRequested = true; + return new Response(null, { status: 401 }); + }); + expect(usageRequested).toBe(true); + let profileRequested = false; + const account = await identify({ configDir: directory }, async () => { + profileRequested = true; + return jsonResponse({ + account: { uuid: "account-uuid", email: "owner@example.test" }, + organization: { uuid: "org-uuid" }, + }); + }); + expect(profileRequested).toBe(true); + expect(account).toEqual({ key: "account-uuid.org-uuid", label: "owner@example.test" }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("expires cached token profiles after five minutes", async () => { + const { identify } = await import("./usage.js"); + let now = 0; + let calls = 0; + const fetchProfile: typeof fetch = async () => { + calls++; + return jsonResponse({ account: { uuid: "account-uuid" }, organization: { uuid: "org-uuid" } }); + }; + const input = { accessToken: "ttl-fixture-token" }; + await identify(input, fetchProfile, () => now); + await identify(input, fetchProfile, () => now); + expect(calls).toBe(1); + now = 300_001; + await identify(input, fetchProfile, () => now); + expect(calls).toBe(2); +}); + +it("identify and fetch share the macOS keychain credential fallback", async () => { + const { identify } = await import("./usage.js"); + const directory = mkdtempSync(join(tmpdir(), "claude-keychain-identity-")); + try { + const lookup = { + platform: "darwin" as const, + claudeHome: directory, + accountHome: directory, + readKeychainCredentials: async () => ({ + claudeAiOauth: { accessToken: "keychain-fixture-token" }, + }), + }; + let usageRequested = false; + await fetchUsage( + {}, + async () => { + usageRequested = true; + return new Response(null, { status: 401 }); + }, + lookup, + ); + expect(usageRequested).toBe(true); + const account = await identify( + {}, + async () => + jsonResponse({ + account: { uuid: "keychain-account" }, + organization: { uuid: "keychain-org" }, + }), + Date.now, + lookup, + ); + expect(account).toEqual({ key: "keychain-account.keychain-org" }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("identify and fetch share the default Claude home credentials", async () => { + const { identify } = await import("./usage.js"); + const directory = mkdtempSync(join(tmpdir(), "claude-home-identity-")); + try { + writeClaudeCredentials(directory, "home-fixture-token"); + const lookup = { claudeHome: directory, accountHome: directory }; + let usageRequested = false; + await fetchUsage( + {}, + async () => { + usageRequested = true; + return new Response(null, { status: 401 }); + }, + lookup, + ); + expect(usageRequested).toBe(true); + const identity = await identify( + {}, + async () => + jsonResponse({ + account: { uuid: "home-account" }, + organization: { uuid: "home-org" }, + }), + Date.now, + lookup, + ); + expect(identity).toEqual({ key: "home-account.home-org" }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("bounds cached profiles across many token rotations", async () => { + const { identify } = await import("./usage.js"); + let calls = 0; + const fetchProfile: typeof fetch = async () => { + calls++; + return jsonResponse({ account: { uuid: "account" }, organization: { uuid: "org" } }); + }; + for (let index = 0; index < 129; index++) { + await identify({ accessToken: `rotation-fixture-${index}` }, fetchProfile); + } + await identify({ accessToken: "rotation-fixture-0" }, fetchProfile); + expect(calls).toBe(130); +}); diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/plugins/claude-usage-source/server/usage.ts similarity index 63% rename from packages/server/src/services/quota-fetcher/providers/claude.ts rename to plugins/claude-usage-source/server/usage.ts index 75d6ed74dd..48ac928d16 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -1,23 +1,21 @@ +import type { UsageInput } from "../shared/input.js"; import { execFile } from "node:child_process"; import { existsSync, promises as fs } from "node:fs"; import { homedir, userInfo } 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, + hashAccountKey, toneFromUsedPct, unavailableUsage, windowFromUsedPct, -} from "../usage.js"; + type UsageReport, + type UsageWindow, + type UsageDetail, +} from "@getpaseo/plugin/server/usage"; + +const ApiNumberSchema = z.coerce.number().finite(); const execFileAsync = promisify(execFile); const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000; @@ -80,14 +78,6 @@ interface ClaudeCredentialRecord { oauth: { accessToken: string } & NonNullable; } -interface ClaudeQuotaProviderOptions { - logger: Logger; - claudeHome?: string; - claudeKeychainReader?: () => Promise; - platform?: typeof process.platform; - fetch?: ProviderApiFetch; -} - function buildClaudePlan( subscriptionType: string | undefined, rateLimitTier: string | undefined, @@ -254,8 +244,8 @@ function legacyScopedLimits(resp: ClaudeUsageResponse): ScopedLimit[] { return limits; } -function unscopedWindows(resp: ClaudeUsageResponse): ProviderUsageWindow[] { - const windows: ProviderUsageWindow[] = []; +function unscopedWindows(resp: ClaudeUsageResponse): UsageWindow[] { + const windows: UsageWindow[] = []; for (const spec of UNSCOPED_WINDOWS) { const window = resp[spec.field]; if (!window) continue; @@ -272,7 +262,7 @@ function unscopedWindows(resp: ClaudeUsageResponse): ProviderUsageWindow[] { return windows; } -function scopedWindows(limits: ScopedLimit[]): ProviderUsageWindow[] { +function scopedWindows(limits: ScopedLimit[]): UsageWindow[] { const taken = new Set(); return limits.map((limit) => { const id = uniqueWindowId(scopedWindowId(limit), taken); @@ -337,96 +327,72 @@ export async function readClaudeKeychainCredentials( return null; } -export class ClaudeQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "claude"; - readonly displayName = "Claude"; +interface ClaudeCredentialLookup { + platform?: NodeJS.Platform; + readKeychainCredentials?: () => Promise; + claudeHome?: string; + accountHome?: string; +} - private readonly logger: Logger; - private readonly claudeHome: string; - private readonly readKeychainCredentials: () => Promise; - private readonly platform: typeof process.platform; - private readonly fetchApi: ProviderApiFetch; +/** Shared credential lookup for usage fetches and account identification. */ +export async function resolveClaudeCredentials( + input: UsageInput, + lookup: ClaudeCredentialLookup = {}, +): Promise { + if ("accessToken" in input) return { oauth: { accessToken: input.accessToken } }; + if ("configDir" in input) return readCredentialFile(join(input.configDir, ".credentials.json")); + const claudeHome = lookup.claudeHome ?? process.env["CLAUDE_HOME"] ?? join(homedir(), ".claude"); + const fileCredentials = await readCredentialFile(join(claudeHome, ".credentials.json")); + if (fileCredentials) return fileCredentials; + if ((lookup.platform ?? process.platform) !== "darwin") return null; + const parsed = ClaudeCredentialsSchema.safeParse( + await (lookup.readKeychainCredentials ?? readClaudeKeychainCredentials)(), + ); + return parsed.success ? toCredentialRecord(parsed.data) : null; +} - constructor(options: ClaudeQuotaProviderOptions) { - this.logger = options.logger.child({ module: "claude-quota-provider" }); - 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 } = credentials; - const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); - const resp = await this.callClaudeApi(oauth.accessToken); - - if (resp === "NEEDS_AUTH") { - // Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md. - return unavailableUsage(this); - } - - const scoped = reconcileScopedLimits( - legacyScopedLimits(resp), - this.scopedLimitsFromResponse(resp.limits), +async function readCredentialFile(path: string): Promise { + if (!existsSync(path)) return null; + try { + return toCredentialRecord( + ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))), ); - const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)]; - - if (windows.length === 0) { - // The response parsed but described nothing. That silence is how the previous - // shape change went unnoticed, so make it greppable. `warn` and not `debug` - // because file logging defaults to `info`. - this.logger.warn("Claude usage response parsed but produced no windows"); - } - - 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, - }; + } catch { + return null; } +} +function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRecord | null { + const oauth = credentials.claudeAiOauth; + return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null; +} + +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, + credentialLookup: ClaudeCredentialLookup = {}, +): Promise { /** * Scoped limits carried by `limits[]`. * * Entries are validated one at a time so a single malformed or newly-shaped entry * cannot fail the whole response and take the windows that already parsed with it. */ - private scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] { + function scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] { if (!limits) return []; const parsed: ScopedLimit[] = []; for (const entry of limits) { const result = ClaudeLimitSchema.safeParse(entry); if (!result.success) { - this.logger.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry"); + console.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry"); continue; } if (result.data.kind !== SCOPED_WEEKLY_KIND) continue; const limit = scopedLimitFromEntry(result.data); if (!limit) { - this.logger.warn("Skipping scoped Claude usage limit with no resolvable scope name"); + console.warn("Skipping scoped Claude usage limit with no resolvable scope name"); continue; } parsed.push(limit); @@ -434,39 +400,9 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { return parsed; } - private async readCredentials(): Promise { - const credPath = join(this.claudeHome, ".credentials.json"); - const fileCredentials = await this.readCredentialFile(credPath); - return ( - fileCredentials ?? (this.platform === "darwin" ? await this.readKeychainCredential() : null) - ); - } - - private async readCredentialFile(path: string): Promise { - if (!existsSync(path)) return null; - try { - return this.toCredentialRecord( - ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))), - ); - } catch { - return null; - } - } - - private async readKeychainCredential(): Promise { - const parsed = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials()); - return parsed.success ? this.toCredentialRecord(parsed.data) : null; - } - - private toCredentialRecord( - credentials: z.infer, - ): ClaudeCredentialRecord | null { - const oauth = credentials.claudeAiOauth; - return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null; - } - - private async callClaudeApi(token: string): Promise { - const res = await fetchProviderApi(this.fetchApi, "https://api.anthropic.com/api/oauth/usage", { + async function callClaudeApi(token: string): Promise { + const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", { + signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${token}`, Accept: "application/json", @@ -477,4 +413,133 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`); return ClaudeUsageResponseSchema.parse(await res.json()); } + + const credentials = await resolveClaudeCredentials(input, credentialLookup); + if (!credentials) { + return unavailableUsage(); + } + + const { oauth } = credentials; + const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); + const resp = await callClaudeApi(oauth.accessToken); + + if (resp === "NEEDS_AUTH") { + // Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md. + return unavailableUsage(); + } + + const scoped = reconcileScopedLimits( + legacyScopedLimits(resp), + scopedLimitsFromResponse(resp.limits), + ); + const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)]; + if (windows[0]) windows[0].headline = true; + + if (windows.length === 0) { + // The response parsed but described nothing. That silence is how the previous + // shape change went unnoticed, so make it greppable. `warn` and not `debug` + // because file logging defaults to `info`. + console.warn("Claude usage response parsed but produced no windows"); + } + + const details: UsageDetail[] = []; + const extraUsageEnabled = resp.extra_usage?.is_enabled; + if (extraUsageEnabled !== undefined) { + details.push({ + id: "extra_usage", + label: "Extra usage", + value: extraUsageEnabled ? "Enabled" : "Disabled", + }); + } + + return { + status: "available", + planLabel: plan ?? undefined, + windows, + balances: [], + details, + }; +} + +// The OAuth usage endpoint meters the active organization selected by the token. +const ClaudeAccountSchema = z.object({ + accountUuid: z.string().min(1), + organizationUuid: z.string().min(1), + emailAddress: z.string().optional(), +}); +type ClaudeIdentity = { key: string; label?: string } | null; +const PROFILE_TTL_MS = 300_000; +const PROFILE_CACHE_LIMIT = 128; +const profileCache = new Map }>(); + +function pruneProfileCache(now: number): void { + for (const [key, entry] of profileCache) { + if (now - entry.at >= PROFILE_TTL_MS) profileCache.delete(key); + } +} + +export async function identify( + input: UsageInput, + fetchApi: typeof fetch = fetch, + now: () => number = Date.now, + credentialLookup: ClaudeCredentialLookup = {}, +) { + const directory = + "configDir" in input ? input.configDir : (credentialLookup.accountHome ?? homedir()); + if (!("accessToken" in input)) { + try { + const config = z + .object({ oauthAccount: ClaudeAccountSchema }) + .parse(JSON.parse(await fs.readFile(join(directory, ".claude.json"), "utf8"))); + const account = config.oauthAccount; + return { + key: `${account.accountUuid}.${account.organizationUuid}`, + ...(account.emailAddress ? { label: account.emailAddress } : {}), + }; + } catch { + // Credentials may still be present even when account metadata is absent. + } + } + const credentials = await resolveClaudeCredentials(input, credentialLookup); + if (!credentials) return null; + const token = credentials.oauth.accessToken; + const tokenHash = hashAccountKey(token); + pruneProfileCache(now()); + const pending = profileCache.get(tokenHash); + if (pending) return pending.result; + while (profileCache.size >= PROFILE_CACHE_LIMIT) { + const oldest = profileCache.keys().next().value; + if (oldest === undefined) break; + profileCache.delete(oldest); + } + const request = (async () => { + const response = await fetchApi("https://api.anthropic.com/api/oauth/profile", { + signal: AbortSignal.timeout(15_000), + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "anthropic-beta": CLAUDE_OAUTH_BETA, + }, + }); + if (response.status === 401 || response.status === 403) return null; + if (!response.ok) throw new Error(`Claude profile API returned ${response.status}`); + const profile = z + .object({ + account: z.object({ uuid: z.string().min(1), email: z.string().optional() }), + organization: z.object({ uuid: z.string().min(1) }), + }) + .parse(await response.json()); + const account = profile.account; + return { + key: `${account.uuid}.${profile.organization.uuid}`, + ...(account.email ? { label: account.email } : {}), + }; + })(); + profileCache.set(tokenHash, { at: now(), result: request }); + try { + return await request; + } catch (error) { + if (profileCache.get(tokenHash)?.result === request) profileCache.delete(tokenHash); + throw error; + } } diff --git a/plugins/claude-usage-source/shared/input.ts b/plugins/claude-usage-source/shared/input.ts new file mode 100644 index 0000000000..3668dd668f --- /dev/null +++ b/plugins/claude-usage-source/shared/input.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; +export const inputSchema = z.union([ + z.object({ configDir: z.string().min(1) }).strict(), + z.object({ accessToken: z.string().min(1) }).strict(), + z.object({}).strict(), +]); +export type UsageInput = z.infer; diff --git a/plugins/codex-usage-source/icon.svg b/plugins/codex-usage-source/icon.svg new file mode 100644 index 0000000000..5d9e2fec39 --- /dev/null +++ b/plugins/codex-usage-source/icon.svg @@ -0,0 +1 @@ + diff --git a/plugins/codex-usage-source/index.server.ts b/plugins/codex-usage-source/index.server.ts new file mode 100644 index 0000000000..3a7c472784 --- /dev/null +++ b/plugins/codex-usage-source/index.server.ts @@ -0,0 +1,16 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { fetchUsage, identify } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "codex", + label: "Codex", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + identify, + fetch: fetchUsage, + }); + return () => {}; +} diff --git a/plugins/codex-usage-source/paseo-plugin.json b/plugins/codex-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..64470099fb --- /dev/null +++ b/plugins/codex-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "codex-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..e45291bfbd --- /dev/null +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -0,0 +1,163 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { fetchUsage, identify } from "./usage.js"; + +const originalHome = process.env["CODEX_HOME"]; +afterEach(() => { + if (originalHome === undefined) delete process.env["CODEX_HOME"]; + else process.env["CODEX_HOME"] = originalHome; +}); + +function response(headers: HeadersInit, accountId?: string): Promise { + const request = new Headers(headers); + expect(request.get("Authorization")).toMatch(/^Bearer fixture-/); + expect(request.get("ChatGPT-Account-Id")).toBe(accountId ?? null); + return Promise.resolve( + new Response( + JSON.stringify({ + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 30, reset_at: 1700000000 } }, + }), + { status: 200 }, + ), + ); +} + +test("default input reads CODEX_HOME auth and preserves the usage request", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-codex-")); + try { + process.env["CODEX_HOME"] = home; + await writeFile( + join(home, "auth.json"), + JSON.stringify({ + tokens: { access_token: "fixture-default", account_id: "account-default" }, + }), + ); + const report = await fetchUsage({}, (_url, init) => + response(init?.headers ?? {}, "account-default"), + ); + expect(report).toMatchObject({ + status: "available", + planLabel: "plus", + windows: [{ id: "session", usedPct: 30, headline: true }], + }); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test("explicit codexHome reads only that auth file", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-codex-")); + try { + await writeFile( + join(home, "auth.json"), + JSON.stringify({ tokens: { access_token: "fixture-home", account_id: "account-home" } }), + ); + await fetchUsage({ codexHome: home }, (_url, init) => + response(init?.headers ?? {}, "account-home"), + ); + expect(await identify({ codexHome: home })).toEqual({ key: "account-home" }); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test("identify returns a key when fetch finds Codex token credentials", async () => { + await fetchUsage( + { accessToken: "fixture-supplied", accountId: "account-supplied" }, + (_url, init) => response(init?.headers ?? {}, "account-supplied"), + ); + expect( + await identify({ accessToken: "fixture-supplied", accountId: "account-supplied" }), + ).toEqual({ key: "account-supplied" }); +}); + +test("coerces credit balance and marks a 96 percent window dangerous", async () => { + const report = await fetchUsage( + { accessToken: "fixture-supplied" }, + async () => + new Response( + JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 12 }, + secondary_window: { used_percent: 96 }, + }, + credits: { balance: "0" }, + }), + { status: 200 }, + ), + ); + expect(report.windows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "session", tone: "ok" }), + expect.objectContaining({ id: "weekly", tone: "danger" }), + ]), + ); + expect(report.balances).toEqual([expect.objectContaining({ remaining: 0, tone: "danger" })]); +}); + +test("HTML usage body is unavailable", async () => { + const report = await fetchUsage( + { accessToken: "fixture-supplied" }, + async () => new Response("Login", { status: 200 }), + ); + expect(report.status).toBe("unavailable"); +}); + +test("401 leaves auth.json byte for byte unchanged and makes no refresh request", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-codex-")); + try { + const authPath = join(home, "auth.json"); + const before = JSON.stringify({ + OPENAI_API_KEY: null, + tokens: { + id_token: "fixture-id", + access_token: "fixture-stale", + refresh_token: "fixture-refresh", + account_id: "fixture-account", + }, + last_refresh: "2026-07-04T20:35:00Z", + }); + await writeFile(authPath, before); + let calls = 0; + const report = await fetchUsage({ codexHome: home }, async (url) => { + expect(String(url)).toBe("https://chatgpt.com/backend-api/wham/usage"); + calls++; + return new Response(null, { status: 401 }); + }); + expect(report.status).toBe("unavailable"); + expect(calls).toBe(1); + expect(await readFile(authPath, "utf8")).toBe(before); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test("token-only account claim survives token rotation", async () => { + const token = (suffix: string) => + `header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "work-id" }, suffix })).toString("base64url")}.signature`; + expect(await identify({ accessToken: token("first") })).toEqual({ key: "work-id" }); + expect(await identify({ accessToken: token("second") })).toEqual({ key: "work-id" }); + expect(await identify({ accessToken: "opaque-token" })).toBeNull(); +}); + +test("identify reads an email label from auth id_token when access token has no profile", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-codex-label-")); + try { + const idToken = `header.${Buffer.from(JSON.stringify({ email: "id-owner@example.test" })).toString("base64url")}.signature`; + await writeFile( + join(home, "auth.json"), + JSON.stringify({ + tokens: { account_id: "account-id", access_token: "opaque-token", id_token: idToken }, + }), + ); + expect(await identify({ codexHome: home })).toEqual({ + key: "account-id", + label: "id-owner@example.test", + }); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts new file mode 100644 index 0000000000..c7b07ce8f3 --- /dev/null +++ b/plugins/codex-usage-source/server/usage.ts @@ -0,0 +1,172 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + balanceToneFromRemaining, + toneFromUsedPct, + windowFromUsedPct, + type UsageReport, + type UsageWindow, +} from "@getpaseo/plugin/server/usage"; +import { z } from "zod"; +import type { CodexUsageInput } from "../shared/input.js"; + +const authSchema = z.object({ + tokens: z + .object({ + access_token: z.string().optional(), + account_id: z.string().optional(), + id_token: z.string().optional(), + }) + .optional(), +}); +const number = z.coerce.number().finite(); +const windowSchema = z.object({ used_percent: number.optional(), reset_at: number.optional() }); +const responseSchema = z.object({ + plan_type: z.string().optional(), + email: z.string().optional(), + rate_limit: z + .object({ primary_window: windowSchema.nullish(), secondary_window: windowSchema.nullish() }) + .nullish(), + code_review_rate_limit: z.object({ primary_window: windowSchema.nullish() }).nullish(), + credits: z.object({ balance: number.optional() }).nullish(), +}); + +export async function readAuth( + input: CodexUsageInput, +): Promise<{ token: string; accountId?: string; idToken?: string } | null> { + if ("accessToken" in input) return { token: input.accessToken, accountId: input.accountId }; + const candidates = + "codexHome" in input + ? [join(input.codexHome, "auth.json")] + : [ + ...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []), + join(homedir(), ".config", "codex", "auth.json"), + join(homedir(), ".codex", "auth.json"), + ]; + for (const path of candidates) { + try { + const auth = authSchema.parse(JSON.parse(await readFile(path, "utf8"))); + if (auth.tokens?.access_token) + return { + token: auth.tokens.access_token, + accountId: auth.tokens.account_id, + idToken: auth.tokens.id_token, + }; + } catch { + continue; + } + } + return null; +} + +function usageWindow( + id: string, + label: string, + value: z.infer | null | undefined, + headline = false, +): UsageWindow | null { + if (!value) return null; + const usedPct = value.used_percent ?? 0; + return windowFromUsedPct({ + id, + label, + utilizationPct: usedPct, + resetsAt: value.reset_at != null ? new Date(value.reset_at * 1000).toISOString() : null, + tone: toneFromUsedPct(usedPct), + headline, + }); +} + +export async function fetchUsage( + input: CodexUsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + const auth = await readAuth(input); + if (!auth) return { status: "unavailable", windows: [] }; + const headers: Record = { + Authorization: `Bearer ${auth.token}`, + Accept: "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }; + if (auth.accountId) headers["ChatGPT-Account-Id"] = auth.accountId; + const response = await fetchApi("https://chatgpt.com/backend-api/wham/usage", { + headers, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401 || response.status === 403) + return { status: "unavailable", windows: [] }; + if (!response.ok) throw new Error(`Codex usage API returned ${response.status}`); + const text = await response.text(); + if (text.trim().startsWith("<")) return { status: "unavailable", windows: [] }; + const usage = responseSchema.parse(JSON.parse(text)); + const windows = [ + usageWindow("session", "Session", usage.rate_limit?.primary_window, true), + usageWindow("weekly", "Weekly", usage.rate_limit?.secondary_window), + usageWindow("code_review", "Code review", usage.code_review_rate_limit?.primary_window), + ].filter((window): window is UsageWindow => window !== null); + const balance = usage.credits?.balance; + return { + status: "available", + planLabel: usage.plan_type, + windows, + balances: + balance === undefined + ? [] + : [ + { + id: "credits", + label: "Credits", + remaining: balance, + unit: "usd", + tone: balanceToneFromRemaining(balance), + }, + ], + details: [], + }; +} + +/** JWT claims are decoded locally; no token or email becomes an account key. */ +function jwtClaims(token: string | undefined): Record | null { + try { + const payload = token?.split(".")[1]; + return payload + ? (JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record) + : null; + } catch { + return null; + } +} + +function claimObject( + claims: Record | null, + name: string, +): Record | null { + const value = claims?.[name]; + return value && typeof value === "object" ? (value as Record) : null; +} + +function claimString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +export async function identify(input: CodexUsageInput) { + const auth = await readAuth(input); + if (!auth) return null; + const access = jwtClaims(auth.token); + const id = jwtClaims(auth.idToken); + const accessAuth = claimObject(access, "https://api.openai.com/auth"); + const idAuth = claimObject(id, "https://api.openai.com/auth"); + const key = + auth.accountId ?? + claimString(accessAuth?.["chatgpt_account_id"]) ?? + claimString(access?.["chatgpt_account_id"]) ?? + claimString(idAuth?.["chatgpt_account_id"]); + if (!key) return null; + const label = + claimString(claimObject(access, "https://api.openai.com/profile")?.["email"]) ?? + claimString(access?.["email"]) ?? + claimString(claimObject(id, "https://api.openai.com/profile")?.["email"]) ?? + claimString(id?.["email"]); + return { key, ...(label ? { label } : {}) }; +} diff --git a/plugins/codex-usage-source/shared/input.ts b/plugins/codex-usage-source/shared/input.ts new file mode 100644 index 0000000000..0c54b51e69 --- /dev/null +++ b/plugins/codex-usage-source/shared/input.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const inputSchema = z.union([ + z.object({ codexHome: z.string().min(1) }).strict(), + z.object({ accessToken: z.string().min(1), accountId: z.string().optional() }).strict(), + z.object({}).strict(), +]); +export type CodexUsageInput = z.infer; diff --git a/plugins/copilot-usage-source/icon.svg b/plugins/copilot-usage-source/icon.svg new file mode 100644 index 0000000000..7bbea5de3a --- /dev/null +++ b/plugins/copilot-usage-source/icon.svg @@ -0,0 +1,2 @@ + diff --git a/plugins/copilot-usage-source/index.server.ts b/plugins/copilot-usage-source/index.server.ts new file mode 100644 index 0000000000..79da4a578e --- /dev/null +++ b/plugins/copilot-usage-source/index.server.ts @@ -0,0 +1,16 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { fetchUsage, identify } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "copilot", + label: "GitHub Copilot", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + identify, + fetch: fetchUsage, + }); + return () => {}; +} diff --git a/plugins/copilot-usage-source/paseo-plugin.json b/plugins/copilot-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..ab6addf629 --- /dev/null +++ b/plugins/copilot-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "copilot-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/plugins/copilot-usage-source/server/usage.test.ts b/plugins/copilot-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..9e34d83ad7 --- /dev/null +++ b/plugins/copilot-usage-source/server/usage.test.ts @@ -0,0 +1,130 @@ +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 { identify, fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +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" }, + }); +} + +describe("copilot usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = 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", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "copilot", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + 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("identify returns a key when fetch finds copilot credentials", async () => { + const previous = process.env["COPILOT_TOKEN"]; + try { + process.env["COPILOT_TOKEN"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); + expect(await identify()).toEqual({ key: "default" }); + } finally { + if (previous === undefined) delete process.env["COPILOT_TOKEN"]; + else process.env["COPILOT_TOKEN"] = previous; + } +}); diff --git a/plugins/copilot-usage-source/server/usage.ts b/plugins/copilot-usage-source/server/usage.ts new file mode 100644 index 0000000000..27a5c84e35 --- /dev/null +++ b/plugins/copilot-usage-source/server/usage.ts @@ -0,0 +1,93 @@ +import type { UsageInput } from "../shared/input.js"; +import { existsSync, promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { + unavailableUsage, + type UsageReport, + type UsageDetail, +} from "@getpaseo/plugin/server/usage"; + +const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); + +const CopilotUsageResponseSchema = z.object({ + copilot_plan: ApiOptionalStringSchema, + quota_reset_date: ApiOptionalStringSchema, +}); + +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 async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; + + const token = + process.env["COPILOT_TOKEN"] || + process.env["GITHUB_TOKEN"] || + process.env["GITHUB_PAT"] || + (await readGithubCliToken()); + + if (!token) return unavailableUsage(); + + const res = await fetchApi("https://api.github.com/copilot_internal/user", { + signal: AbortSignal.timeout(15_000), + 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) { + return unavailableUsage(); + } + + const resp = CopilotUsageResponseSchema.parse(await res.json()); + const details: UsageDetail[] = resp.quota_reset_date + ? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }] + : []; + + return { + status: "available", + planLabel: resp.copilot_plan || undefined, + windows: [], + balances: [], + details, + }; +} + +export async function identify() { + const token = + process.env["COPILOT_TOKEN"] || + process.env["GITHUB_TOKEN"] || + process.env["GITHUB_PAT"] || + (await readGithubCliToken()); + return token ? { key: "default" } : null; +} diff --git a/plugins/copilot-usage-source/shared/input.ts b/plugins/copilot-usage-source/shared/input.ts new file mode 100644 index 0000000000..9ee4593341 --- /dev/null +++ b/plugins/copilot-usage-source/shared/input.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/plugins/cursor-usage-source/icon.svg b/plugins/cursor-usage-source/icon.svg new file mode 100644 index 0000000000..4ca0c2501b --- /dev/null +++ b/plugins/cursor-usage-source/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/cursor-usage-source/index.server.ts b/plugins/cursor-usage-source/index.server.ts new file mode 100644 index 0000000000..a4c1947cb4 --- /dev/null +++ b/plugins/cursor-usage-source/index.server.ts @@ -0,0 +1,16 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { fetchUsage, identify } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "cursor", + label: "Cursor", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + identify, + fetch: fetchUsage, + }); + return () => {}; +} diff --git a/plugins/cursor-usage-source/paseo-plugin.json b/plugins/cursor-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..311fd28ee4 --- /dev/null +++ b/plugins/cursor-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "cursor-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/plugins/cursor-usage-source/server/usage.test.ts b/plugins/cursor-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..acd841c14f --- /dev/null +++ b/plugins/cursor-usage-source/server/usage.test.ts @@ -0,0 +1,277 @@ +import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { identify, fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +// node:sqlite has no @types/node@20 typings; require it with a narrow local type. +const testRequire = createRequire(import.meta.url); +interface TestSqliteDb { + exec(sql: string): void; + prepare(sql: string): { run(...params: unknown[]): void }; + close(): void; +} + +// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the +// real layouts: a plain modern token or the legacy JSON object. +function writeCursorStateDb(homeDir: string, rows: Record): void { + const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); + mkdirSync(dir, { recursive: true }); + const { DatabaseSync } = testRequire("node:sqlite") as { + DatabaseSync: new (path: string) => TestSqliteDb; + }; + const db = new DatabaseSync(join(dir, "state.vscdb")); + db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); + const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)"); + for (const [key, value] of Object.entries(rows)) { + insert.run(key, value); + } + db.close(); +} + +function writeCursorAuthJson(homeDir: string, accessToken: string): void { + const dir = join(homeDir, ".config", "cursor"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "auth.json"), JSON.stringify({ accessToken })); +} + +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" }, + }); +} + +describe("cursor usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = 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", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "cursor", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + 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("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => { + writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" }); + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { + totalSpend: "1500", + includedSpend: "1000", + bonusSpend: "500", + remaining: "2500", + limit: "4000", + }, + billingCycleStart: "2026-01-14T12:42:14.000Z", + billingCycleEnd: "2026-02-14T12:42:14.000Z", + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_state_jwt"); + expect(cursor).toMatchObject({ + status: "available", + balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], + }); + }); + + it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => { + writeCursorStateDb(homeDir, { + cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"), + }); + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, + billingCycleStart: null, + billingCycleEnd: null, + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_legacy_jwt"); + expect(cursor.status).toBe("available"); + }); + + it("reads the Cursor token from cursor-agent ~/.config/cursor/auth.json when desktop state is absent", async () => { + writeCursorAuthJson(homeDir, "cursor_cli_jwt"); + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { + totalSpend: "1500", + includedSpend: "1000", + bonusSpend: "500", + remaining: "2500", + limit: "4000", + }, + billingCycleStart: "2026-01-14T12:42:14.000Z", + billingCycleEnd: "2026-02-14T12:42:14.000Z", + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_cli_jwt"); + expect(cursor).toMatchObject({ + status: "available", + balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], + }); + }); + + it("prefers the desktop state.vscdb token over cursor-agent auth.json", async () => { + writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_desktop_jwt" }); + writeCursorAuthJson(homeDir, "cursor_cli_jwt"); + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, + billingCycleStart: null, + billingCycleEnd: null, + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_desktop_jwt"); + expect(cursor.status).toBe("available"); + }); + + it("stays unavailable when state.vscdb is unreadable", async () => { + const path = join(homeDir, ".config", "Cursor", "User", "globalStorage"); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, "state.vscdb"), "not a database"); + const cursor = findProvider(await service().listUsage(), "cursor"); + expect(cursor.status).toBe("unavailable"); + }); +}); + +it("identify returns a key when fetch finds cursor credentials", async () => { + const previous = process.env["CURSOR_ACCESS_TOKEN"]; + try { + process.env["CURSOR_ACCESS_TOKEN"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); + expect(await identify()).toEqual({ key: "default" }); + } finally { + if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"]; + else process.env["CURSOR_ACCESS_TOKEN"] = previous; + } +}); diff --git a/packages/server/src/services/quota-fetcher/providers/cursor.ts b/plugins/cursor-usage-source/server/usage.ts similarity index 60% rename from packages/server/src/services/quota-fetcher/providers/cursor.ts rename to plugins/cursor-usage-source/server/usage.ts index fb1cf94939..15a9f6e7b3 100644 --- a/packages/server/src/services/quota-fetcher/providers/cursor.ts +++ b/plugins/cursor-usage-source/server/usage.ts @@ -1,19 +1,26 @@ +import type { UsageInput } from "../shared/input.js"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; 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 { - ApiNullableNumberSchema, toneFromUsedPct, usedPctOf, - fetchProviderApi, - toIsoStringOrNull, unavailableUsage, -} from "../usage.js"; + type UsageReport, + type UsageBalance, +} from "@getpaseo/plugin/server/usage"; + +const ApiNumberSchema = z.coerce.number().finite(); +const ApiNullableNumberSchema = z.preprocess( + (value) => (value == null ? null : value), + ApiNumberSchema.nullable(), +); +function toIsoStringOrNull(timestampMs: number): string | null { + const date = new Date(timestampMs); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; +} // Cursor desktop stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep // the access token as a plain JWT string under `cursorAuth/accessToken`; older builds @@ -62,12 +69,6 @@ const CursorAuthStatusSchema = z.object({ type CursorUsageResponse = z.infer; -interface CursorQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; - homeDir?: string; -} - function parseCursorBillingCycleTimestamp( value: CursorUsageResponse["billingCycleStart"], ): string | null { @@ -113,7 +114,7 @@ function cursorTokenFromDb(db: CursorStateDatabase): string | null { return null; } -async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise { +async function readCursorTokenFromSqlite(homeDir: string): Promise { const dbPaths: string[] = []; if (process.env["APPDATA"]) { dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); @@ -137,8 +138,7 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promi let sqlite: NodeSqliteModule; try { sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule; - } catch (err) { - logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb"); + } catch { return null; // runtime without node:sqlite } @@ -149,10 +149,8 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promi db = new sqlite.DatabaseSync(path, { readOnly: true }); const token = cursorTokenFromDb(db); if (token) return token; - } catch (err) { - // Locked/permission/corrupt/schema failures all land here; log so an - // unavailable Cursor card is diagnosable, then try the next candidate. - logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb"); + } catch { + // Locked, permission, corrupt, or schema failures try the next candidate. } finally { db?.close(); } @@ -160,91 +158,84 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promi return null; } -async function readCursorTokenFromAuthJson( - homeDir: string, - logger: Logger, -): Promise { +async function readCursorTokenFromAuthJson(homeDir: string): Promise { const path = join(homeDir, ".config", "cursor", "auth.json"); if (!existsSync(path)) return null; try { const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8"))); return parsed.accessToken?.trim() || null; - } catch (err) { - logger.debug({ err, path }, "Failed to read Cursor token from auth.json"); + } catch { return null; } } -export class CursorQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "cursor"; - readonly displayName = "Cursor"; +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; + const homeDir = homedir(); - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; - private readonly homeDir: string; + const token = + process.env["CURSOR_ACCESS_TOKEN"] || + process.env["CURSOR_TOKEN"] || + (await readCursorTokenFromSqlite(homeDir)) || + (await readCursorTokenFromAuthJson(homeDir)); - constructor(options: CursorQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - this.homeDir = options.homeDir ?? homedir(); - } + if (!token) return unavailableUsage(); - async fetchUsage(): Promise { - const token = - process.env["CURSOR_ACCESS_TOKEN"] || - process.env["CURSOR_TOKEN"] || - (await readCursorTokenFromSqlite(this.homeDir, this.logger)) || - (await readCursorTokenFromAuthJson(this.homeDir, this.logger)); - - 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({}), + const res = await fetchApi( + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + { + signal: AbortSignal.timeout(15_000), + 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: toneFromUsedPct(usedPctOf(totalSpend, limit)), - }); - } - - return { - providerId: this.providerId, - displayName: this.displayName, - status: "available", - planLabel: null, - windows: [], - balances, - details: [], - error: null, - }; + if (!res.ok) { + return unavailableUsage(); } + + const resp = CursorUsageResponseSchema.parse(await res.json()); + const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd); + const balances: UsageBalance[] = []; + 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: toneFromUsedPct(usedPctOf(totalSpend, limit)), + }); + } + + return { + status: "available", + planLabel: undefined, + windows: [], + balances, + details: [], + }; +} + +export async function identify() { + const home = homedir(); + const token = + process.env["CURSOR_ACCESS_TOKEN"] || + process.env["CURSOR_TOKEN"] || + (await readCursorTokenFromSqlite(home)) || + (await readCursorTokenFromAuthJson(home)); + return token ? { key: "default" } : null; } diff --git a/plugins/cursor-usage-source/shared/input.ts b/plugins/cursor-usage-source/shared/input.ts new file mode 100644 index 0000000000..9ee4593341 --- /dev/null +++ b/plugins/cursor-usage-source/shared/input.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/plugins/grok-usage-source/icon.svg b/plugins/grok-usage-source/icon.svg new file mode 100644 index 0000000000..6352f5e8e1 --- /dev/null +++ b/plugins/grok-usage-source/icon.svg @@ -0,0 +1 @@ + diff --git a/plugins/grok-usage-source/index.server.ts b/plugins/grok-usage-source/index.server.ts new file mode 100644 index 0000000000..3c87562300 --- /dev/null +++ b/plugins/grok-usage-source/index.server.ts @@ -0,0 +1,16 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { fetchUsage, identify } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "grok", + label: "Grok", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + identify, + fetch: fetchUsage, + }); + return () => {}; +} diff --git a/plugins/grok-usage-source/paseo-plugin.json b/plugins/grok-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..2fbdcc5f14 --- /dev/null +++ b/plugins/grok-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "grok-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/plugins/grok-usage-source/server/usage.test.ts b/plugins/grok-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..977c9a3fe9 --- /dev/null +++ b/plugins/grok-usage-source/server/usage.test.ts @@ -0,0 +1,285 @@ +import { mkdirSync, writeFileSync, 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 { identify, fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +function writeGrokAuth(home: string, auth: Record): void { + mkdirSync(join(home, ".grok"), { recursive: true }); + writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); +} + +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" }, + }); +} + +describe("grok usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = 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", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "grok", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + 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?format=credits", + () => + jsonResponse({ + config: { monthlyLimit: { val: 0 }, used: { val: 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 Grok usage from live billing shape (config.used.val)", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + () => + jsonResponse({ + config: { + monthlyLimit: { val: 150000 }, + used: { val: 37886 }, + billingPeriodStart: "2026-07-01T00:00:00+00:00", + billingPeriodEnd: "2026-08-01T00:00:00+00:00", + }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 37886, + remaining: 112114, + limit: 150000, + unit: "credits", + }), + ], + }); + }); + + it("fetches Grok usage with nested ~/.grok/auth.json key token", async () => { + writeGrokAuth(homeDir, { + "https://auth.x.ai::test-user-id": { + key: "nested_jwt_token", + refresh_token: "rt_nested", + expires_at: "2026-08-01T00:00:00Z", + user_id: "test-user-id", + email: "user@example.com", + }, + }); + + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + config: { + monthlyLimit: { val: 100 }, + used: { val: 25 }, + }, + }); + }) as typeof fetch; + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(authorization).toBe("Bearer nested_jwt_token"); + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 25, + remaining: 75, + limit: 100, + }), + ], + }); + }); + + it("still accepts legacy Grok usage.creditUsage when config.used is absent", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + () => + jsonResponse({ + config: { monthlyLimit: { val: 50 } }, + usage: { creditUsage: 10 }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 10, + remaining: 40, + limit: 50, + }), + ], + }); + }); + + it("fetches Grok unified-billing usage as a weekly window", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + () => + jsonResponse({ + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-08-24T09:41:40.001370+00:00", + end: "2026-08-31T09:41:40.001370+00:00", + }, + creditUsagePercent: 76.0, + isUnifiedBillingUser: true, + billingPeriodStart: "2026-08-24T09:41:40.001370+00:00", + billingPeriodEnd: "2026-08-31T09:41:40.001370+00:00", + }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + windows: [ + { + id: "weekly", + label: "Weekly", + usedPct: 76, + remainingPct: 24, + resetsAt: "2026-08-31T09:41:40.001370+00:00", + tone: "warning", + }, + ], + balances: [], + }); + }); +}); + +it("identify returns a key when fetch finds grok credentials", async () => { + const previous = process.env["GROK_TOKEN"]; + try { + process.env["GROK_TOKEN"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); + expect(await identify()).toEqual({ key: "default" }); + } finally { + if (previous === undefined) delete process.env["GROK_TOKEN"]; + else process.env["GROK_TOKEN"] = previous; + } +}); diff --git a/packages/server/src/services/quota-fetcher/providers/grok.ts b/plugins/grok-usage-source/server/usage.ts similarity index 56% rename from packages/server/src/services/quota-fetcher/providers/grok.ts rename to plugins/grok-usage-source/server/usage.ts index 83e7fde837..251674f597 100644 --- a/packages/server/src/services/quota-fetcher/providers/grok.ts +++ b/plugins/grok-usage-source/server/usage.ts @@ -1,23 +1,23 @@ +import type { UsageInput } from "../shared/input.js"; 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, - ApiOptionalStringSchema, toneFromUsedPct, usedPctOf, - fetchProviderApi, unavailableUsage, windowFromUsedPct, -} from "../usage.js"; + type UsageReport, + type UsageWindow, + type UsageBalance, +} from "@getpaseo/plugin/server/usage"; + +const ApiNumberSchema = z.coerce.number().finite(); +const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); const GrokUsageResponseSchema = z.object({ config: z @@ -48,13 +48,6 @@ const GrokUsageResponseSchema = z.object({ .nullish(), }); -interface GrokQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; - /** Override home directory (tests). Production uses os.homedir(). */ - homeDir?: string; -} - /** Resolve a Grok CLI token from ~/.grok/auth.json (legacy or current nested shape). */ export function extractGrokTokenFromAuth(auth: unknown): string | null { if (auth == null || typeof auth !== "object" || Array.isArray(auth)) return null; @@ -82,7 +75,7 @@ export function extractGrokTokenFromAuth(auth: unknown): string | null { function grokMonthlyCreditBalance( response: z.infer, -): ProviderUsageBalance | null { +): UsageBalance | null { const limit = response.config?.monthlyLimit?.val ?? null; const used = response.config?.used?.val ?? response.usage?.creditUsage ?? null; if (limit === null && used === null) return null; @@ -97,9 +90,7 @@ function grokMonthlyCreditBalance( }; } -function grokUsageWindow( - response: z.infer, -): ProviderUsageWindow | null { +function grokUsageWindow(response: z.infer): UsageWindow | null { const percent = response.config?.creditUsagePercent; if (typeof percent !== "number") return null; const period = response.config?.currentPeriod; @@ -113,64 +104,16 @@ function grokUsageWindow( }); } -export class GrokQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "grok"; - readonly displayName = "Grok"; +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; + const homeDir = homedir(); - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; - private readonly homeDir: string | undefined; - - constructor(options: GrokQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - this.homeDir = options.homeDir; - } - - async fetchUsage(): Promise { - const token = - process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await this.readGrokToken()); - - if (!token) return unavailableUsage(this); - - // The Grok CLI's /usage uses ?format=credits; without it, unified-billing accounts - // get a zeroed legacy monthly shape (monthlyLimit.val 0) instead of real usage. - const res = await fetchProviderApi( - this.fetchApi, - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - { - 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 balance = grokMonthlyCreditBalance(resp); - const window = grokUsageWindow(resp); - - return { - providerId: this.providerId, - displayName: this.displayName, - status: "available", - planLabel: null, - windows: window ? [window] : [], - balances: balance ? [balance] : [], - details: [], - error: null, - }; - } - - private async readGrokToken(): Promise { + async function readGrokToken(): Promise { // homeDir override is for tests: Windows os.homedir() ignores $HOME (uses USERPROFILE). - const path = join(this.homeDir ?? homedir(), ".grok", "auth.json"); + const path = join(homeDir ?? homedir(), ".grok", "auth.json"); if (!existsSync(path)) return null; try { return extractGrokTokenFromAuth(JSON.parse(await fs.readFile(path, "utf8"))); @@ -178,4 +121,46 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { return null; } } + + const token = process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await readGrokToken()); + + if (!token) return unavailableUsage(); + + // The Grok CLI's /usage uses ?format=credits; without it, unified-billing accounts + // get a zeroed legacy monthly shape (monthlyLimit.val 0) instead of real usage. + const res = await fetchApi("https://cli-chat-proxy.grok.com/v1/billing?format=credits", { + signal: AbortSignal.timeout(15_000), + headers: { + Authorization: `Bearer ${token}`, + "X-XAI-Token-Auth": "xai-grok-cli", + Accept: "application/json", + }, + }); + + if (!res.ok) { + return unavailableUsage(); + } + + const resp = GrokUsageResponseSchema.parse(await res.json()); + const balance = grokMonthlyCreditBalance(resp); + const window = grokUsageWindow(resp); + if (window) window.headline = true; + + return { + status: "available", + planLabel: undefined, + windows: window ? [window] : [], + balances: balance ? [balance] : [], + details: [], + }; +} + +export async function identify() { + if (process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"]) return { key: "default" }; + try { + const auth = JSON.parse(await fs.readFile(join(homedir(), ".grok", "auth.json"), "utf8")); + return extractGrokTokenFromAuth(auth) ? { key: "default" } : null; + } catch { + return null; + } } diff --git a/plugins/grok-usage-source/shared/input.ts b/plugins/grok-usage-source/shared/input.ts new file mode 100644 index 0000000000..9ee4593341 --- /dev/null +++ b/plugins/grok-usage-source/shared/input.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/plugins/kimi-usage-source/icon.svg b/plugins/kimi-usage-source/icon.svg new file mode 100644 index 0000000000..4f7547cf79 --- /dev/null +++ b/plugins/kimi-usage-source/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/kimi-usage-source/index.server.ts b/plugins/kimi-usage-source/index.server.ts new file mode 100644 index 0000000000..a1df077baa --- /dev/null +++ b/plugins/kimi-usage-source/index.server.ts @@ -0,0 +1,16 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { fetchUsage, identify } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "kimi", + label: "Kimi", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + identify, + fetch: fetchUsage, + }); + return () => {}; +} diff --git a/plugins/kimi-usage-source/paseo-plugin.json b/plugins/kimi-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..c81434ce3b --- /dev/null +++ b/plugins/kimi-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "kimi-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/plugins/kimi-usage-source/server/usage.test.ts b/plugins/kimi-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..9dc83ec587 --- /dev/null +++ b/plugins/kimi-usage-source/server/usage.test.ts @@ -0,0 +1,387 @@ +import { mkdirSync, readFileSync, writeFileSync, 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 { identify, fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +function kimiCredentialPath(dir: string): string { + return join(dir, "credentials", "kimi-code.json"); +} + +function writeKimiCredentials(dir: string, accessToken: string, overrides: object = {}): void { + mkdirSync(join(dir, "credentials"), { recursive: true }); + writeFileSync( + kimiCredentialPath(dir), + JSON.stringify({ + access_token: accessToken, + refresh_token: "rt_kimi", + expires_at: 1_798_812_800, + expires_in: 900, + scope: "kimi-code", + token_type: "Bearer", + ...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" }, + }); +} + +describe("kimi usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = 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", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "kimi", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + 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", + }), + ], + }); + }); + + it("fetches Kimi usage from the CLI credential home", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token"); + let requestedUrl: string | null = null; + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = url.toString(); + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + usage: { + limit: "200", + remaining: "150", + resetTime: "2026-06-23T05:12:17Z", + }, + }); + }) as unknown as typeof fetch; + + const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi"); + + expect(requestedUrl).toBe("https://api.kimi.com/coding/v1/usages"); + expect(authorization).toBe("Bearer kimi_cli_token"); + expect(kimi).toMatchObject({ + status: "available", + windows: [ + expect.objectContaining({ + id: "coding_usage", + usedPct: 25, + remainingPct: 75, + resetsAt: "2026-06-23T05:12:17Z", + }), + ], + }); + }); + + it("reads Kimi credentials whose optional fields are null", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token", { + expires_at: null, + expires_in: null, + scope: null, + token_type: null, + }); + fetchApi = mockFetch( + new Map([ + [ + "https://api.kimi.com/coding/v1/usages", + () => jsonResponse({ usage: { limit: "100", remaining: "60" } }), + ], + ]), + ); + + const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi"); + + expect(kimi.status).toBe("available"); + }); + + it("returns unavailable on 401 without refreshing or rewriting the credential file", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired"); + const credPath = kimiCredentialPath(join(homeDir, ".kimi-code")); + const before = readFileSync(credPath, "utf8"); + let usageCalls = 0; + fetchApi = vi.fn(async (url: RequestInfo | URL) => { + const endpoint = url.toString(); + if (endpoint === "https://api.kimi.com/coding/v1/usages") { + usageCalls += 1; + return new Response(null, { status: 401 }); + } + // The read-only fetcher must never hit the OAuth token endpoint. + throw new Error(`Unmocked: ${endpoint}`); + }) as never; + + const result = await service({ kimiHomeDir: homeDir }).listUsage(); + + expect(findProvider(result, "kimi").status).toBe("unavailable"); + expect(usageCalls).toBe(1); + // The credentials file must be left untouched for the Kimi CLI to own. + expect(readFileSync(credPath, "utf8")).toBe(before); + }); + + it("does not refresh Kimi tokens read from the environment", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + const usageFetch = vi.fn(async () => new Response(null, { status: 401 })); + fetchApi = usageFetch as never; + + const result = await service().listUsage(); + + expect(findProvider(result, "kimi").status).toBe("unavailable"); + expect(usageFetch).toHaveBeenCalledTimes(1); + }); + + it("does not refresh Kimi tokens on a 403", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_forbidden"); + const usageFetch = vi.fn(async () => new Response(null, { status: 403 })); + fetchApi = usageFetch as never; + + const result = await service({ kimiHomeDir: homeDir }).listUsage(); + + expect(findProvider(result, "kimi").status).toBe("unavailable"); + expect(usageFetch).toHaveBeenCalledTimes(1); + }); +}); + +describe("Kimi usage source usage windows", () => { + afterEach(() => { + delete process.env["KIMI_TOKEN"]; + vi.restoreAllMocks(); + }); + + it("normalizes weekly and enforced rolling usage windows", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + const fetchApi = vi.fn(async () => + jsonResponse({ + limited: true, + usage: { + limit: "100", + used: "61", + remaining: "39", + resetTime: "2026-08-05T00:01:45Z", + }, + limits: [ + { + window: { + duration: 300, + timeUnit: "TIME_UNIT_MINUTE", + }, + detail: { + limit: "100", + used: "100", + resetTime: "2026-07-31T17:01:45Z", + }, + }, + ], + }), + ); + const usage = await fetchUsage({}, fetchApi); + + expect(usage).toMatchObject({ + status: "available", + windows: [ + { + id: "coding_usage", + label: "Weekly limit", + usedPct: 61, + remainingPct: 39, + resetsAt: "2026-08-05T00:01:45Z", + tone: "ok", + }, + { + id: "coding_limit_300_time_unit_minute", + label: "5-hour limit", + usedPct: 100, + remainingPct: 0, + resetsAt: "2026-07-31T17:01:45Z", + tone: "danger", + }, + ], + }); + }); + + it("keeps valid windows when another limits entry is malformed", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + const fetchApi = vi.fn(async () => + jsonResponse({ + usage: { + limit: "100", + remaining: "75", + resetTime: "2026-08-05T00:01:45Z", + }, + limits: [ + { window: { duration: "invalid" }, detail: {} }, + { + window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, + detail: { limit: "100", remaining: "50" }, + }, + ], + }), + ); + const usage = await fetchUsage({}, fetchApi); + + expect(usage.windows).toHaveLength(2); + expect(usage.windows[1]).toMatchObject({ + label: "5-hour limit", + usedPct: 50, + remainingPct: 50, + }); + }); + + it("accepts direct limit fields, alternate reset keys, and provider labels", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + const fetchApi = vi.fn(async () => + jsonResponse({ + usage: null, + limits: [ + { + name: "Burst quota", + limit: "80", + remaining: "20", + reset_at: "2026-08-01T00:00:00Z", + }, + ], + }), + ); + const usage = await fetchUsage({}, fetchApi); + + expect(usage.windows).toEqual([ + expect.objectContaining({ + id: "coding_limit_burst_quota", + label: "Burst quota", + usedPct: 75, + remainingPct: 25, + resetsAt: "2026-08-01T00:00:00Z", + }), + ]); + }); + + it("keeps window ids unique when Kimi returns duplicate limit descriptors", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + const duplicate = { + window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, + detail: { limit: "100", used: "10" }, + }; + const fetchApi = vi.fn(async () => jsonResponse({ limits: [duplicate, duplicate] })); + const usage = await fetchUsage({}, fetchApi); + + expect(usage.windows.map((window) => window.id)).toEqual([ + "coding_limit_300_time_unit_minute", + "coding_limit_300_time_unit_minute_2", + ]); + }); +}); + +it("identify returns a key when fetch finds kimi credentials", async () => { + const previous = process.env["KIMI_TOKEN"]; + try { + process.env["KIMI_TOKEN"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); + expect(await identify()).toEqual({ key: "default" }); + } finally { + if (previous === undefined) delete process.env["KIMI_TOKEN"]; + else process.env["KIMI_TOKEN"] = previous; + } +}); diff --git a/packages/server/src/services/quota-fetcher/providers/kimi.ts b/plugins/kimi-usage-source/server/usage.ts similarity index 75% rename from packages/server/src/services/quota-fetcher/providers/kimi.ts rename to plugins/kimi-usage-source/server/usage.ts index 1e62fd6eeb..bd20560ae7 100644 --- a/packages/server/src/services/quota-fetcher/providers/kimi.ts +++ b/plugins/kimi-usage-source/server/usage.ts @@ -1,18 +1,21 @@ +import type { UsageInput } from "../shared/input.js"; 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, ProviderUsageWindow } from "../../../server/messages.js"; -import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { - ApiNumberSchema, - ApiOptionalStringSchema, - fetchProviderApi, toneFromUsedPct, unavailableUsage, windowFromUsedPct, -} from "../usage.js"; + type UsageReport, + type UsageWindow, +} from "@getpaseo/plugin/server/usage"; + +const ApiNumberSchema = z.coerce.number().finite(); +const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; @@ -165,7 +168,7 @@ function windowFromFields(input: { id: string; label: string; fields: KimiUsageFields; -}): ProviderUsageWindow { +}): UsageWindow { const usedPct = usedPctFromFields(input.fields); return windowFromUsedPct({ id: input.id, @@ -187,12 +190,9 @@ function uniqueWindowId(baseId: string, seenIds: Set): string { return id; } -function kimiUsageWindowsFromPayload( - payload: unknown, - logger: Pick, -): ProviderUsageWindow[] { +function kimiUsageWindowsFromPayload(payload: unknown): UsageWindow[] { const response = KimiUsageResponseSchema.parse(payload); - const windows: ProviderUsageWindow[] = []; + const windows: UsageWindow[] = []; const seenWindowIds = new Set(); const usage = parseUsageFields(response.usage); @@ -210,14 +210,12 @@ function kimiUsageWindowsFromPayload( for (const [index, rawLimit] of limits.entries()) { const parsedLimit = KimiUsageLimitSchema.safeParse(rawLimit); if (!parsedLimit.success) { - logger.debug({ index }, "Ignoring malformed Kimi usage limit window"); continue; } const limit = parsedLimit.data; const { fields, metadata } = limitFields(limit); if (!hasUsageData(fields)) { - logger.debug({ index }, "Ignoring malformed Kimi usage limit window"); continue; } @@ -249,54 +247,16 @@ const KimiAuthSchema = z type KimiAuth = z.infer; type KimiCredentials = KimiAuth & { access_token: string }; -interface KimiQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; - homeDir?: string; -} +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; + const homeDir = homedir(); -export class KimiQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "kimi"; - readonly displayName = "Kimi"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; - private readonly homeDir?: string; - - constructor(options: KimiQuotaProviderOptions) { - this.logger = options.logger.child({ module: "kimi-quota-provider" }); - this.fetchApi = options.fetch ?? fetch; - this.homeDir = options.homeDir; - } - - async fetchUsage(): Promise { - const credentials = await this.readCredentials(); - if (!credentials) return unavailableUsage(this); - - const res = await this.callUsageApi(credentials.access_token); - - if (!res.ok) { - // Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md. - this.logger.debug({ status: res.status }, "Kimi usage fetch failed"); - return unavailableUsage(this); - } - - const windows = kimiUsageWindowsFromPayload(await res.json(), this.logger); - - return { - providerId: this.providerId, - displayName: this.displayName, - status: "available", - planLabel: null, - windows, - balances: [], - details: [], - error: null, - }; - } - - private async callUsageApi(token: string): Promise { - return fetchProviderApi(this.fetchApi, KIMI_USAGE_URL, { + async function callUsageApi(token: string): Promise { + return fetchApi(KIMI_USAGE_URL, { + signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${token}`, Accept: "application/json", @@ -304,14 +264,14 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { }); } - private async readCredentials(): Promise { + async function readCredentials(): Promise { const environmentToken = process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"]; if (environmentToken) { return { access_token: environmentToken }; } - for (const path of this.credentialPaths()) { - const credentials = await this.readCredentialFile(path); + for (const path of credentialPaths()) { + const credentials = await readCredentialFile(path); if (credentials?.access_token) { return { ...credentials, access_token: credentials.access_token }; } @@ -319,19 +279,19 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { return null; } - private credentialPaths(): string[] { - const homeDir = this.homeDir ?? homedir(); + function credentialPaths(): string[] { + const home = homeDir; return [ join( - process.env["KIMI_CODE_HOME"] || join(homeDir, ".kimi-code"), + process.env["KIMI_CODE_HOME"] || join(home, ".kimi-code"), "credentials", "kimi-code.json", ), - join(homeDir, ".kimi", "credentials", "kimi-code.json"), + join(home, ".kimi", "credentials", "kimi-code.json"), ]; } - private async readCredentialFile(path: string): Promise { + async function readCredentialFile(path: string): Promise { if (!existsSync(path)) return null; try { return KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); @@ -339,4 +299,48 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { return null; } } + + const credentials = await readCredentials(); + if (!credentials) return unavailableUsage(); + + const res = await callUsageApi(credentials.access_token); + + if (!res.ok) { + // Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md. + + return unavailableUsage(); + } + + const windows = kimiUsageWindowsFromPayload(await res.json()); + if (windows[0]) windows[0].headline = true; + + return { + status: "available", + planLabel: undefined, + windows, + balances: [], + details: [], + }; +} + +export async function identify() { + if (process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"]) return { key: "default" }; + const home = homedir(); + const paths = [ + join( + process.env["KIMI_CODE_HOME"] || join(home, ".kimi-code"), + "credentials", + "kimi-code.json", + ), + join(home, ".kimi", "credentials", "kimi-code.json"), + ]; + for (const path of paths) { + try { + const auth = KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); + if (auth.access_token) return { key: "default" }; + } catch { + continue; + } + } + return null; } diff --git a/plugins/kimi-usage-source/shared/input.ts b/plugins/kimi-usage-source/shared/input.ts new file mode 100644 index 0000000000..9ee4593341 --- /dev/null +++ b/plugins/kimi-usage-source/shared/input.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/plugins/minimax-usage-source/icon.svg b/plugins/minimax-usage-source/icon.svg new file mode 100644 index 0000000000..01cd8219e3 --- /dev/null +++ b/plugins/minimax-usage-source/icon.svg @@ -0,0 +1 @@ + diff --git a/plugins/minimax-usage-source/index.server.ts b/plugins/minimax-usage-source/index.server.ts new file mode 100644 index 0000000000..95d470867b --- /dev/null +++ b/plugins/minimax-usage-source/index.server.ts @@ -0,0 +1,16 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { fetchUsage, identify } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "minimax", + label: "MiniMax", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + identify, + fetch: fetchUsage, + }); + return () => {}; +} diff --git a/plugins/minimax-usage-source/paseo-plugin.json b/plugins/minimax-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..61ecc81c8c --- /dev/null +++ b/plugins/minimax-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "minimax-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/plugins/minimax-usage-source/server/usage.test.ts b/plugins/minimax-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..8926b23980 --- /dev/null +++ b/plugins/minimax-usage-source/server/usage.test.ts @@ -0,0 +1,332 @@ +import { mkdirSync, writeFileSync, 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 { identify, fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +function writeMiniMaxConfig(dir: string, payload: Record): void { + mkdirSync(join(dir, ".mmx"), { recursive: true }); + writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload)); +} + +function writeMiniMaxCredentials( + dir: string, + accessToken: string, + expiresAt?: string, + resourceUrl?: string, +): void { + mkdirSync(join(dir, ".mmx"), { recursive: true }); + const payload: Record = { access_token: accessToken }; + if (expiresAt !== undefined) payload["expires_at"] = expiresAt; + if (resourceUrl !== undefined) payload["resource_url"] = resourceUrl; + writeFileSync(join(dir, ".mmx", "credentials.json"), JSON.stringify(payload)); +} + +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" }, + }); +} + +describe("minimax usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = 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", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "minimax", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + it("fetches MiniMax usage from MINIMAX_API_KEY against the global endpoint", async () => { + process.env["MINIMAX_API_KEY"] = "minimax_test_token"; + let requestedUrl: string | null = null; + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = url.toString(); + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + model_remains: [ + { + model_name: "MiniMax-M2.7", + end_time: Date.parse("2026-06-19T05:00:00.000Z"), + weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), + current_interval_total_count: 1000, + current_interval_usage_count: 250, + current_interval_remaining_percent: 75, + current_weekly_total_count: 5000, + current_weekly_usage_count: 1200, + current_weekly_remaining_percent: 76, + }, + ], + }); + }) as unknown as typeof fetch; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(requestedUrl).toBe("https://api.minimax.io/v1/token_plan/remains"); + expect(authorization).toBe("Bearer minimax_test_token"); + expect(miniMax).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ + id: "interval_MiniMax-M2.7", + label: "MiniMax-M2.7 · Interval", + usedPct: 25, + remainingPct: 75, + resetsAt: "2026-06-19T05:00:00.000Z", + }), + expect.objectContaining({ + id: "weekly_MiniMax-M2.7", + label: "MiniMax-M2.7 · Weekly", + usedPct: 24, + remainingPct: 76, + resetsAt: "2026-06-26T00:00:00.000Z", + }), + ]), + }); + }); + + it("returns unavailable MiniMax usage when no credentials are configured", async () => { + fetchApi = vi.fn() as never; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax.status).toBe("unavailable"); + expect(fetchApi).not.toHaveBeenCalled(); + }); + + it("reads MiniMax OAuth credentials from the CLI credentials file", async () => { + writeMiniMaxCredentials( + homeDir, + "minimax_oauth_token", + "2030-01-01T00:00:00.000Z", + "https://account.example.com", + ); + let requestedUrl: string | null = null; + fetchApi = (async (url: RequestInfo | URL) => { + requestedUrl = url.toString(); + return jsonResponse({ model_remains: [] }); + }) as unknown as typeof fetch; + + await service().listUsage(); + + expect(requestedUrl).toBe("https://account.example.com/v1/token_plan/remains"); + }); + + it("falls back to MiniMax api_key in the CLI config file", async () => { + writeMiniMaxConfig(homeDir, { + api_key: "minimax_config_key", + region: "cn", + }); + let requestedUrl: string | null = null; + fetchApi = (async (url: RequestInfo | URL) => { + requestedUrl = url.toString(); + return jsonResponse({ model_remains: [] }); + }) as unknown as typeof fetch; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(requestedUrl).toBe("https://api.minimaxi.com/v1/token_plan/remains"); + expect(miniMax.status).toBe("unavailable"); + }); + + it("reports MiniMax accounts with no active token plan as unavailable", async () => { + writeMiniMaxConfig(homeDir, { api_key: "minimax_config_key", region: "cn" }); + fetchApi = mockFetch( + new Map([ + [ + "https://api.minimaxi.com/v1/token_plan/remains", + () => + jsonResponse({ + model_remains: null, + base_resp: { status_code: 2062, status_msg: "no active token plan subscription" }, + }), + ], + ]), + ); + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null }); + }); + + it("reports a null MiniMax token plan as unavailable even without base_resp", async () => { + process.env["MINIMAX_API_KEY"] = "minimax_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.minimax.io/v1/token_plan/remains", + () => jsonResponse({ model_remains: null }), + ], + ]), + ); + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null }); + }); + + it("still reads MiniMax windows when base_resp reports success", async () => { + process.env["MINIMAX_API_KEY"] = "minimax_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.minimax.io/v1/token_plan/remains", + () => + jsonResponse({ + base_resp: { status_code: 0, status_msg: "success" }, + model_remains: [ + { + model_name: "MiniMax-M2.7", + end_time: Date.parse("2026-06-19T05:00:00.000Z"), + current_interval_total_count: 100, + current_interval_usage_count: 25, + current_interval_remaining_percent: 75, + }, + ], + }), + ], + ]), + ); + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ id: "interval_MiniMax-M2.7", usedPct: 25 }), + ]), + }); + }); + + it("marks exhausted MiniMax interval windows with a danger tone", async () => { + process.env["MINIMAX_API_KEY"] = "minimax_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.minimax.io/v1/token_plan/remains", + () => + jsonResponse({ + model_remains: [ + { + model_name: "MiniMax-M2.7", + end_time: Date.parse("2026-06-19T05:00:00.000Z"), + weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), + current_interval_total_count: 100, + current_interval_usage_count: 100, + current_interval_remaining_percent: 0, + current_interval_status: 2, + current_weekly_total_count: 100, + current_weekly_usage_count: 10, + current_weekly_remaining_percent: 90, + current_weekly_status: 1, + }, + ], + }), + ], + ]), + ); + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ + id: "interval_MiniMax-M2.7", + usedPct: 100, + tone: "danger", + }), + expect.objectContaining({ + id: "weekly_MiniMax-M2.7", + tone: "ok", + }), + ]), + }); + }); +}); + +it("identify returns a key when fetch finds minimax credentials", async () => { + const previous = process.env["MINIMAX_API_KEY"]; + try { + process.env["MINIMAX_API_KEY"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); + expect(await identify()).toEqual({ key: "default" }); + } finally { + if (previous === undefined) delete process.env["MINIMAX_API_KEY"]; + else process.env["MINIMAX_API_KEY"] = previous; + } +}); diff --git a/packages/server/src/services/quota-fetcher/providers/minimax.ts b/plugins/minimax-usage-source/server/usage.ts similarity index 59% rename from packages/server/src/services/quota-fetcher/providers/minimax.ts rename to plugins/minimax-usage-source/server/usage.ts index ffcff7267b..9c0b019e0f 100644 --- a/packages/server/src/services/quota-fetcher/providers/minimax.ts +++ b/plugins/minimax-usage-source/server/usage.ts @@ -1,22 +1,23 @@ +import type { UsageInput } from "../shared/input.js"; 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, ProviderUsageWindow } from "../../../server/messages.js"; -import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { - ApiNumberSchema, - ApiOptionalStringSchema, - fetchProviderApi, unavailableUsage, windowFromUsedPct, -} from "../usage.js"; + type UsageReport, + type UsageWindow, +} from "@getpaseo/plugin/server/usage"; + +const ApiNumberSchema = z.coerce.number().finite(); +const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); const MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io"; const MINIMAX_CN_BASE_URL = "https://api.minimaxi.com"; -const MINIMAX_CREDENTIALS_PATH = join(homedir(), ".mmx", "credentials.json"); -const MINIMAX_CONFIG_PATH = join(homedir(), ".mmx", "config.json"); const MiniMaxModelRemainSchema = z.object({ model_name: ApiOptionalStringSchema, @@ -73,15 +74,6 @@ interface MiniMaxResolvedAuth { baseUrl: string; } -interface MiniMaxQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; - configPath?: string; - credentialsPath?: string; - env?: NodeJS.ProcessEnv; - now?: () => number; -} - function resolveBaseUrl(input: { baseUrl?: string; region?: string }): string { const explicit = input.baseUrl; if (explicit && explicit.startsWith("http")) return explicit; @@ -105,16 +97,13 @@ function epochMsToIso(value: number | null | undefined): string | null { return new Date(value).toISOString(); } -function toneForStatus(status: number | null | undefined): ProviderUsageWindow["tone"] { +function toneForStatus(status: number | null | undefined): UsageWindow["tone"] { if (status === 2) return "danger"; if (status === 3) return "default"; return "ok"; } -function toIntervalWindow( - modelName: string, - model: MiniMaxModelRemain, -): ProviderUsageWindow | null { +function toIntervalWindow(modelName: string, model: MiniMaxModelRemain): UsageWindow | null { const total = model.current_interval_total_count ?? null; const used = model.current_interval_usage_count ?? null; const remainingPercent = model.current_interval_remaining_percent ?? null; @@ -135,7 +124,7 @@ function toIntervalWindow( }); } -function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): ProviderUsageWindow | null { +function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): UsageWindow | null { const total = model.current_weekly_total_count ?? null; const used = model.current_weekly_usage_count ?? null; const remainingPercent = model.current_weekly_remaining_percent ?? null; @@ -155,95 +144,35 @@ function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): ProviderU }); } -export class MiniMaxQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "minimax"; - readonly displayName = "MiniMax"; +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; + const configPath = join(homedir(), ".mmx", "config.json"); + const credentialsPath = join(homedir(), ".mmx", "credentials.json"); + const env = process.env; + const now = Date.now; - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; - private readonly configPath: string; - private readonly credentialsPath: string; - private readonly env: NodeJS.ProcessEnv; - private readonly now: () => number; - - constructor(options: MiniMaxQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - this.configPath = options.configPath ?? MINIMAX_CONFIG_PATH; - this.credentialsPath = options.credentialsPath ?? MINIMAX_CREDENTIALS_PATH; - this.env = options.env ?? process.env; - this.now = options.now ?? Date.now; - } - - async fetchUsage(): Promise { - const auth = await this.resolveAuth(); - if (!auth) return unavailableUsage(this); - - const res = await fetchProviderApi(this.fetchApi, `${auth.baseUrl}/v1/token_plan/remains`, { - headers: { - Authorization: `Bearer ${auth.token}`, - Accept: "application/json", - }, - }); - - if (!res.ok) { - this.logger.debug({ status: res.status }, "MiniMax usage fetch failed"); - return unavailableUsage(this); - } - - const resp = MiniMaxQuotaResponseSchema.parse(await res.json()); - - const statusCode = resp.base_resp?.status_code; - if (typeof statusCode === "number" && statusCode !== 0) { - this.logger.debug( - { statusCode, statusMsg: resp.base_resp?.status_msg }, - "MiniMax usage unavailable", - ); - return unavailableUsage(this); - } - - const models = resp.model_remains ?? []; - - const windows: ProviderUsageWindow[] = []; - for (const model of models) { - const name = model.model_name ?? "token-plan"; - const intervalWindow = toIntervalWindow(name, model); - if (intervalWindow) windows.push(intervalWindow); - const weeklyWindow = toWeeklyWindow(name, model); - if (weeklyWindow) windows.push(weeklyWindow); - } - - return { - providerId: this.providerId, - displayName: this.displayName, - status: windows.length > 0 ? "available" : "unavailable", - planLabel: null, - windows, - balances: [], - details: [], - error: null, - }; - } - - private async resolveAuth(): Promise { - const envToken = this.env["MINIMAX_API_KEY"]; + async function resolveAuth(): Promise { + const envToken = env["MINIMAX_API_KEY"]; if (envToken) { - const envBase = this.env["MINIMAX_BASE_URL"]; + const envBase = env["MINIMAX_BASE_URL"]; return { token: envToken, baseUrl: resolveBaseUrl({ baseUrl: envBase }), }; } - const credentials = await this.readCredentials(); - if (credentials?.access_token && !this.isExpired(credentials.expires_at)) { + const credentials = await readCredentials(); + if (credentials?.access_token && !isExpired(credentials.expires_at)) { return { token: credentials.access_token, baseUrl: resolveBaseUrl({ baseUrl: credentials.resource_url }), }; } - const config = await this.readConfig(); + const config = await readConfig(); if (config?.api_key) { return { token: config.api_key, @@ -254,7 +183,7 @@ export class MiniMaxQuotaProvider implements ProviderUsageFetcher { }; } - if (config?.oauth?.access_token && !this.isExpired(config.oauth.expires_at)) { + if (config?.oauth?.access_token && !isExpired(config.oauth.expires_at)) { return { token: config.oauth.access_token, baseUrl: resolveBaseUrl({ @@ -267,30 +196,94 @@ export class MiniMaxQuotaProvider implements ProviderUsageFetcher { return null; } - private isExpired(expiresAt: string | null | undefined): boolean { + function isExpired(expiresAt: string | null | undefined): boolean { if (!expiresAt) return false; const parsed = Date.parse(expiresAt); if (!Number.isFinite(parsed)) return false; - return parsed <= this.now(); + return parsed <= now(); } - private async readCredentials(): Promise | null> { - if (!existsSync(this.credentialsPath)) return null; + async function readCredentials(): Promise | null> { + if (!existsSync(credentialsPath)) return null; try { - const raw = JSON.parse(await fs.readFile(this.credentialsPath, "utf8")); + const raw = JSON.parse(await fs.readFile(credentialsPath, "utf8")); return MiniMaxCredentialsSchema.parse(raw); } catch { return null; } } - private async readConfig(): Promise | null> { - if (!existsSync(this.configPath)) return null; + async function readConfig(): Promise | null> { + if (!existsSync(configPath)) return null; try { - const raw = JSON.parse(await fs.readFile(this.configPath, "utf8")); + const raw = JSON.parse(await fs.readFile(configPath, "utf8")); return MiniMaxConfigSchema.parse(raw); } catch { return null; } } + + const auth = await resolveAuth(); + if (!auth) return unavailableUsage(); + + const res = await fetchApi(`${auth.baseUrl}/v1/token_plan/remains`, { + signal: AbortSignal.timeout(15_000), + headers: { + Authorization: `Bearer ${auth.token}`, + Accept: "application/json", + }, + }); + + if (!res.ok) { + return unavailableUsage(); + } + + const resp = MiniMaxQuotaResponseSchema.parse(await res.json()); + + const statusCode = resp.base_resp?.status_code; + if (typeof statusCode === "number" && statusCode !== 0) { + return unavailableUsage(); + } + + const models = resp.model_remains ?? []; + + const windows: UsageWindow[] = []; + for (const model of models) { + const name = model.model_name ?? "token-plan"; + const intervalWindow = toIntervalWindow(name, model); + if (intervalWindow) windows.push(intervalWindow); + const weeklyWindow = toWeeklyWindow(name, model); + if (weeklyWindow) windows.push(weeklyWindow); + } + if (windows[0]) windows[0].headline = true; + + return { + status: windows.length > 0 ? "available" : "unavailable", + planLabel: undefined, + windows, + balances: [], + details: [], + }; +} + +export async function identify() { + if (process.env["MINIMAX_API_KEY"]) return { key: "default" }; + const home = join(homedir(), ".mmx"); + try { + const credentials = MiniMaxCredentialsSchema.parse( + JSON.parse(await fs.readFile(join(home, "credentials.json"), "utf8")), + ); + if (credentials.access_token) return { key: "default" }; + } catch { + /* Check config next. */ + } + try { + const config = MiniMaxConfigSchema.parse( + JSON.parse(await fs.readFile(join(home, "config.json"), "utf8")), + ); + if (config.api_key || config.oauth?.access_token) return { key: "default" }; + } catch { + /* No configured account. */ + } + return null; } diff --git a/plugins/minimax-usage-source/shared/input.ts b/plugins/minimax-usage-source/shared/input.ts new file mode 100644 index 0000000000..9ee4593341 --- /dev/null +++ b/plugins/minimax-usage-source/shared/input.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/plugins/opencode-go-usage-source/icon.svg b/plugins/opencode-go-usage-source/icon.svg new file mode 100644 index 0000000000..79991973d6 --- /dev/null +++ b/plugins/opencode-go-usage-source/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/opencode-go-usage-source/index.server.ts b/plugins/opencode-go-usage-source/index.server.ts new file mode 100644 index 0000000000..cb47cc1ac6 --- /dev/null +++ b/plugins/opencode-go-usage-source/index.server.ts @@ -0,0 +1,16 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { discover, fetchUsage, identify } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "opencode-go", + label: "OpenCode Go", + icon: "icon.svg", + input: inputSchema, + discover, + identify, + fetch: fetchUsage, + }); + return () => {}; +} diff --git a/plugins/opencode-go-usage-source/paseo-plugin.json b/plugins/opencode-go-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..587e662d4f --- /dev/null +++ b/plugins/opencode-go-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "opencode-go-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/plugins/opencode-go-usage-source/server/usage.test.ts b/plugins/opencode-go-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..a9120cbebe --- /dev/null +++ b/plugins/opencode-go-usage-source/server/usage.test.ts @@ -0,0 +1,90 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { discover, fetchUsage, identify } from "./usage.js"; + +const upstreamResponse = { + usage: { + rolling: { status: "ok", percent: 21, resetsAt: "2026-09-26T20:00:00.000Z" }, + weekly: { status: "ok", percent: 42, resetsAt: "2026-09-28T00:00:00.000Z" }, + monthly: { status: "rate-limited", percent: 100, resetsAt: "2026-10-01T00:00:00.000Z" }, + }, +}; + +test("discovers and fetches the default key from read-only auth.json", async () => { + const directory = await mkdtemp(join(tmpdir(), "paseo-go-usage-")); + const path = join(directory, "auth.json"); + const content = JSON.stringify({ "opencode-go": { type: "api", key: "fixture-key" } }); + try { + await writeFile(path, content); + expect(await discover(path)).toEqual([{}]); + const requests: Array<{ url: string; authorization: string | null }> = []; + const fetchApi = async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ + url: String(url), + authorization: new Headers(init?.headers).get("Authorization"), + }); + return Response.json(upstreamResponse); + }; + const report = await fetchUsage({}, fetchApi as typeof fetch, path); + expect((await identify({}, path))?.key).toMatch(/^[a-f0-9]{64}$/); + expect(requests).toEqual([ + { url: "https://opencode.ai/zen/go/v1/usage", authorization: "Bearer fixture-key" }, + ]); + expect(report.status).toBe("available"); + expect( + report.windows.map((window) => [window.id, window.usedPct, window.resetsAt, window.headline]), + ).toEqual([ + ["rolling", 21, "2026-09-26T20:00:00.000Z", true], + ["weekly", 42, "2026-09-28T00:00:00.000Z", undefined], + ["monthly", 100, "2026-10-01T00:00:00.000Z", undefined], + ]); + // The current upstream endpoint returns windows only; do not invent balances. + expect(report.balances).toBeUndefined(); + expect(await readFile(path, "utf8")).toBe(content); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("omits default discovery when auth.json lacks an OpenCode Go API key", async () => { + const directory = await mkdtemp(join(tmpdir(), "paseo-go-usage-")); + const path = join(directory, "auth.json"); + try { + await writeFile(path, JSON.stringify({ openai: { type: "oauth", access: "other-token" } })); + expect(await discover(path)).toEqual([]); + const report = await fetchUsage( + {}, + async () => { + throw new Error("must not fetch"); + }, + path, + ); + expect(report).toEqual({ status: "unavailable", windows: [] }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test.each([401, 403])("maps HTTP %i to unavailable", async (status) => { + const report = await fetchUsage( + { apiKey: "fixture-key" }, + async () => new Response(null, { status }), + ); + expect(report.status).toBe("unavailable"); + expect(report.windows).toEqual([]); +}); + +test("identify returns a key when fetch finds OpenCode Go credentials", async () => { + let requested = false; + await fetchUsage({ apiKey: "fixture-secret" }, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); + const identity = await identify({ apiKey: "fixture-secret" }); + expect(identity?.key).toMatch(/^[a-f0-9]{64}$/); + expect(identity?.key).not.toBe("fixture-secret"); + expect(await identify({ apiKey: "fixture-secret" })).toEqual(identity); +}); diff --git a/plugins/opencode-go-usage-source/server/usage.ts b/plugins/opencode-go-usage-source/server/usage.ts new file mode 100644 index 0000000000..0a761fdb99 --- /dev/null +++ b/plugins/opencode-go-usage-source/server/usage.ts @@ -0,0 +1,88 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { + hashAccountKey, + toneFromUsedPct, + windowFromUsedPct, + type UsageReport, +} from "@getpaseo/plugin/server/usage"; +import type { Input } from "../shared/input.js"; + +const authSchema = z + .object({ + "opencode-go": z + .object({ type: z.literal("api"), key: z.string().min(1) }) + .passthrough() + .optional(), + }) + .passthrough(); +const windowSchema = z.object({ + status: z.enum(["ok", "rate-limited"]), + percent: z.number().finite(), + resetsAt: z.iso.datetime(), +}); +const responseSchema = z.object({ + usage: z.object({ rolling: windowSchema, weekly: windowSchema, monthly: windowSchema }), +}); + +function authPath(env: NodeJS.ProcessEnv = process.env): string { + return join( + env.XDG_DATA_HOME || join(env.HOME || homedir(), ".local", "share"), + "opencode", + "auth.json", + ); +} + +export async function readDefaultKey(path = authPath()): Promise { + try { + const auth = authSchema.parse(JSON.parse(await readFile(path, "utf8"))); + return auth["opencode-go"]?.key ?? null; + } catch { + return null; + } +} + +export async function discover(path = authPath()): Promise> { + return (await readDefaultKey(path)) ? [{}] : []; +} + +export async function fetchUsage( + input: Input, + fetchApi: typeof fetch = fetch, + path = authPath(), +): Promise { + const apiKey = "apiKey" in input ? input.apiKey : await readDefaultKey(path); + if (!apiKey) return { status: "unavailable", windows: [] }; + const response = await fetchApi("https://opencode.ai/zen/go/v1/usage", { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401 || response.status === 403) + return { status: "unavailable", windows: [] }; + if (!response.ok) throw new Error(`OpenCode Go usage API returned ${response.status}`); + const data = responseSchema.parse(await response.json()); + const windows = ( + [ + ["rolling", "Rolling", data.usage.rolling], + ["weekly", "Weekly", data.usage.weekly], + ["monthly", "Monthly", data.usage.monthly], + ] as const + ).map(([id, label, value]) => + windowFromUsedPct({ + id, + label, + utilizationPct: value.percent, + resetsAt: value.resetsAt, + tone: toneFromUsedPct(value.percent), + headline: id === "rolling", + }), + ); + return { status: "available", planLabel: "Go", windows }; +} + +export async function identify(input: Input, path = authPath()) { + const key = "apiKey" in input ? input.apiKey : await readDefaultKey(path); + return key ? { key: hashAccountKey(key) } : null; +} diff --git a/plugins/opencode-go-usage-source/shared/input.ts b/plugins/opencode-go-usage-source/shared/input.ts new file mode 100644 index 0000000000..64fb3bd25b --- /dev/null +++ b/plugins/opencode-go-usage-source/shared/input.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const inputSchema = z.union([ + z.object({ apiKey: z.string().min(1) }).strict(), + z.object({}).strict(), +]); +export type Input = z.infer; diff --git a/plugins/package.json b/plugins/package.json new file mode 100644 index 0000000000..8f1e447aca --- /dev/null +++ b/plugins/package.json @@ -0,0 +1,18 @@ +{ + "name": "@getpaseo/builtin-plugins", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@getpaseo/plugin": "*", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "vitest": "^4.1.6" + } +} diff --git a/plugins/tsconfig.json b/plugins/tsconfig.json new file mode 100644 index 0000000000..5066af976b --- /dev/null +++ b/plugins/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "lib": ["ES2023", "DOM"], + "types": ["node", "vitest/globals"], + "incremental": false, + "noEmit": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/plugins/zai-usage-source/icon.svg b/plugins/zai-usage-source/icon.svg new file mode 100644 index 0000000000..d552d2a3d0 --- /dev/null +++ b/plugins/zai-usage-source/icon.svg @@ -0,0 +1 @@ +Z.ai diff --git a/plugins/zai-usage-source/index.server.ts b/plugins/zai-usage-source/index.server.ts new file mode 100644 index 0000000000..b4fd86943a --- /dev/null +++ b/plugins/zai-usage-source/index.server.ts @@ -0,0 +1,16 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { fetchUsage, identify } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "zai", + label: "Z.ai", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + identify, + fetch: fetchUsage, + }); + return () => {}; +} diff --git a/plugins/zai-usage-source/paseo-plugin.json b/plugins/zai-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..4b65f4a4e3 --- /dev/null +++ b/plugins/zai-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "zai-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/plugins/zai-usage-source/server/usage.test.ts b/plugins/zai-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..164543c00d --- /dev/null +++ b/plugins/zai-usage-source/server/usage.test.ts @@ -0,0 +1,136 @@ +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 { identify, fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +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" }, + }); +} + +describe("zai usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = 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", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "zai", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + 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("identify returns a key when fetch finds zai credentials", async () => { + const previous = process.env["ZAI_API_KEY"]; + try { + process.env["ZAI_API_KEY"] = "fixture-token"; + let requested = false; + await fetchUsage({}, async () => { + requested = true; + return new Response(null, { status: 401 }); + }); + expect(requested).toBe(true); + expect(await identify()).toEqual({ key: "default" }); + } finally { + if (previous === undefined) delete process.env["ZAI_API_KEY"]; + else process.env["ZAI_API_KEY"] = previous; + } +}); diff --git a/plugins/zai-usage-source/server/usage.ts b/plugins/zai-usage-source/server/usage.ts new file mode 100644 index 0000000000..432990f374 --- /dev/null +++ b/plugins/zai-usage-source/server/usage.ts @@ -0,0 +1,70 @@ +import type { UsageInput } from "../shared/input.js"; +import { z } from "zod"; +import { + unavailableUsage, + type UsageReport, + type UsageDetail, +} from "@getpaseo/plugin/server/usage"; + +const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); + +const ZaiUsageResponseSchema = z.object({ + data: z + .array( + z.object({ + productName: ApiOptionalStringSchema, + status: ApiOptionalStringSchema, + purchaseTime: ApiOptionalStringSchema, + valid: ApiOptionalStringSchema, + }), + ) + .optional(), +}); + +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; + + const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"]; + if (!token) return unavailableUsage(); + + const res = await fetchApi("https://api.z.ai/api/biz/subscription/list", { + signal: AbortSignal.timeout(15_000), + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + }); + + if (!res.ok) { + return unavailableUsage(); + } + + const resp = ZaiUsageResponseSchema.parse(await res.json()); + const sub = resp.data?.[0]; + if (!sub) return unavailableUsage(); + + const details: UsageDetail[] = []; + 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 { + status: "available", + planLabel: sub.productName || undefined, + windows: [], + balances: [], + details, + }; +} + +export async function identify() { + return process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"] ? { key: "default" } : null; +} diff --git a/plugins/zai-usage-source/shared/input.ts b/plugins/zai-usage-source/shared/input.ts new file mode 100644 index 0000000000..9ee4593341 --- /dev/null +++ b/plugins/zai-usage-source/shared/input.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 658cb15346..5e7e7a030f 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -304,6 +304,37 @@ external `href` or `xlink:href` references are rejected. Fragment references suc allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline SVG or URL. +### Usage sources + +**Requires Paseo 0.9.3 or newer.** Server plugins register a usage source with `server.registerUsageSource()` and import types and helpers from `@getpaseo/plugin/server/usage`. + +```ts +import { z } from "zod"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +const input = z.object({ account: z.string() }); + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "example-usage", + label: "Example", + icon: "icon.svg", + input, + discover: async () => [{ account: "default" }], + identify: async (value) => { + const { account } = input.parse(value); + return { key: account }; + }, + fetch: async () => ({ status: "available", windows: [] }), + }); + return () => {}; +} +``` + +`discover()` supplies configured inputs; return `[]` when no account is configured. The daemon also passes each live agent's `session.usage_reference` input to `identify()`. That method returns a stable account key and optional display label without fetching usage, or `null` when there are no credentials. The daemon combines the source ID and key as `:`. The key must be 1–128 characters from `[A-Za-z0-9._-]`, remain stable across token rotation and input routes, and identify the account or organization whose quota is metered. Never use a credential or raw email as the key; use `hashAccountKey(value)` when the only stable identity is sensitive. + +`usage.list_reports` discovers reports when called without IDs, or reads only the requested known IDs. It caches each report for five minutes and `forceRefresh` refreshes only the returned IDs. Each entry carries `id`, `account.label`, and `fetchedAt`; `fetch()` returns a `UsageReport` with `status` (`available`, `unavailable`, or `error`), optional `planLabel`, and generic `windows`, `balances`, and `details`. Set `headline: true` on a window to show it first. The icon is a path to a self-contained SVG under the plugin directory and follows the provider icon restrictions above. + ## Entry point and cleanup Each present entry default-exports one contribution function and returns cleanup. Client entries diff --git a/scripts/builtin-plugins-dist.test.mjs b/scripts/builtin-plugins-dist.test.mjs new file mode 100644 index 0000000000..7ec6ba55ee --- /dev/null +++ b/scripts/builtin-plugins-dist.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import pino from "pino"; +import { + builtinPlugins, + resolveBuiltinPluginsRoot, +} from "../packages/server/dist/server/server/plugins/builtin/index.js"; +import { compilePlugin } from "../packages/server/dist/server/server/plugins/compiler.js"; +import { readPluginManifest } from "../packages/server/dist/server/server/plugins/manifest.js"; +import { PluginRuntime } from "../packages/server/dist/server/server/plugins/runtime.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packagedRoot = path.join(repoRoot, "packages/server/dist/server/builtin-plugins"); + +async function findEntry(directory, names) { + for (const name of names) { + const entry = path.join(directory, name); + if ((await stat(entry).catch(() => null))?.isFile()) return entry; + } + return null; +} + +test("built output compiles and starts every listed built-in", async () => { + assert.equal(resolveBuiltinPluginsRoot(), packagedRoot); + const { version } = JSON.parse( + await readFile(path.join(repoRoot, "packages/server/package.json"), "utf8"), + ); + const runtime = new PluginRuntime(pino({ level: "silent" }), version); + runtime.bindPaseoSessionHost({ + async attachPluginSocket(_pluginId, socket) { + const closed = new Promise((resolve) => socket.once("close", resolve)); + socket.on("message", (data) => { + if (typeof data !== "string") return; + const message = JSON.parse(data); + if (message.type !== "hello") return; + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "builtin-dist-test", + hostname: "builtin-dist-test", + version, + features: {}, + }, + }, + }), + ); + }); + return { closed }; + }, + }); + try { + for (const id of builtinPlugins) { + const directory = path.join(packagedRoot, id); + assert.equal((await readPluginManifest(directory)).id, id); + const bundles = await compilePlugin({ + server: await findEntry(directory, ["index.server.ts", "index.server.tsx"]), + client: await findEntry(directory, ["index.client.ts", "index.client.tsx"]), + }); + assert.ok(bundles.serverBundle, `${id} must compile a server bundle`); + await runtime.startBuiltinPlugin({ id, directory }); + assert.ok(runtime.catalog().some((plugin) => plugin.id === id)); + if (id.endsWith("-usage-source")) { + const sourceId = id.slice(0, -"-usage-source".length); + assert.ok( + runtime.getUsageSourceRegistrations(id).some((source) => source.id === sourceId), + `${id} must register usage source ${sourceId}`, + ); + } + } + } finally { + await runtime.stopAll(); + } +}); diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index e3527279be..8ea5411026 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -160,7 +160,7 @@ test("PR routing declares stable behavior ownership", () => { ], quality: ["**/*.{cjs,js,json,jsx,mjs,ts,tsx}", "packages/expo-two-way-audio/**"], hub: ["packages/cli/src/commands/hub/**", "packages/server/src/server/hub/**"], - server: ["packages/server/**", "packages/app/e2e/support/fixtures/recording.*"], + server: ["plugins/**", "packages/server/**", "packages/app/e2e/support/fixtures/recording.*"], desktop: [ "packages/desktop/**", "packages/app/src/desktop/**", diff --git a/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index 137ca13172..cb7408571f 100644 --- a/scripts/trace-daemon.mjs +++ b/scripts/trace-daemon.mjs @@ -53,6 +53,7 @@ const entries = [ const additionalInputs = [ // Agent orchestration skill catalog loaded through filesystem paths "packages/server/dist/server/skills/**", + "packages/server/dist/server/builtin-plugins/**", // Shell integration scripts loaded by the terminal manager "packages/server/dist/server/terminal/shell-integration/**", // Silero VAD ONNX model (sherpa speech provider)