From 792715e762dd51ab748c39e5c655866fdd5b292c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 28 Sep 2026 16:36:34 +0100 Subject: [PATCH] feat(usage): usage sources as built-in plugins, following the session's account (#5465) * feat(server): run internal plugins in-process through the plugin runtime * fix(server): harden internal plugin startup and boundary checks * feat(usage): add plugin usage sources and Codex discovery * fix(usage): share SDK helpers and bound source cache * feat(usage): migrate usage fetchers to plugin sources Serve the legacy usage RPC from discovered source reports so older apps retain the same host usage list while new clients use the source registry. Keep credential reads and normalization inside each source. * refactor(usage): shape sources as fetch functions Keep the plugin usage API limited to stable report and presentation helpers. Source-local parsers and timeout handling preserve credential and response parity while the cases move alongside each source. * feat(usage): resolve reports from active session accounts Resolve source inputs at fetch time so model and credential changes select the account each session spends. Keep the legacy discovered-account RPC for existing clients. Sync OpenCode model events outside foreground turns so usage follows model switches. * fix usage source discovery and session report parity * test adapter usage references through daemon RPC * name usage reference contract across server * refactor(usage): own usage RPCs and validate session auth * feat(omp): resolve session usage from active OAuth credential * fix(omp): match usage source input and valid credential fallback * test(omp): use upstream credential database schema * test(omp): verify live session usage references * fix(usage): declare YAML parser and consolidate credential validation * feat(app): show usage sources in the composer and a Usage screen Replace the provider-matched usage in the context ring tooltip with a composer pill driven by agent.get_usage_report, add a sidebar Usage screen over usage.list_reports grouped by host, and move the host settings Usage page to usage.list_reports. Hosts without features.usageSources show an update message. Source icons come from the report's catalog SVG. The app no longer calls provider.usage.list. * fix(usage): declare the Paseo requirement on internal usage-source plugins A manifest without requirements.paseo targets Paseo before 0.8, so the daemon refused to start eight of the nine usage sources. * fix(app): keep host connection statuses live under the React Compiler The hook read a store version only as a memo trigger (void version); the compiler dropped it, so pages that mounted before a host came online kept showing it as connecting. * fix(app): place the context ring slot before the usage pill The ring reserves its slot before it has data. With the pill first, the empty slot sat between the pill and the mic; now it falls in the flexible space. * refactor(app): extract the settings page layout into PageLayout Desktop puts the title in the page above a centered column; compact moves it into a back header. Settings renders the same pixels through it. * feat(app): lay out the Usage screen like a settings page The title moves from the header bar into the page on desktop; compact gets the settings back header, which returns home when the screen was opened by URL. * refactor(app): drop the unused actions slot from PageLayout * test(plugin): classify the usage source SDK entry * fix(usage): preserve platform test contracts * fix(settings): preserve page title test contract * feat(usage): identify and cache reports by account ID Keep report identity stable across input routes and token rotation, and make selective refresh operate on known account reports. * feat(app): read usage reports through report IDs Share report data between agent pills and host views while preserving the existing popover interaction for the next UI phase. * fix(usage): keep account identity aligned with credentials Read labels from local token claims and use one credential lookup for usage fetches and account identification. Bound cached profile identities across token rotation. * fix(usage): isolate failure IDs and prune expired reports Failure reports live outside the account-key namespace, and cache writes discard entries past the five-minute TTL. * refactor(app): share the compact time-ago prose formatter Move the tab tooltip's "3m" -> "3m ago" wording into utils/time so the usage card can reuse it. * feat(usage): show when each report was fetched The usage card reads fetchedAt as "Updated 3m ago": a tooltip on the card header where hover exists (web, desktop), printed in the card footer on native and compact. Shared by the pill popover and the Usage screen. * feat(usage): open the pill popover from cache and refresh on demand Opening the popover no longer forces a vendor fetch; it shows the cached report. A Refresh control force-refreshes only this report (usage.list_reports with its single ID and forceRefresh), shows Refreshing... while it runs, keeps the previous report visible, and says "Unable to refresh usage" if the request fails. * feat(app): let ToolbarButton show a tooltip other than its label An icon action can now say a status in its tooltip ("Updated 3m ago") while keeping its label for accessibility. * feat(usage): refresh a report from its card header Refresh is an icon button at the right edge of every usage card header, in the pill popover and on the Usage screen. It force-refreshes only that report, writes the result to the report's cache and its host list, shows a spinner and disables itself while pending, and prints "Unable to refresh usage" in the card on failure. Its tooltip carries the freshness; without hover the freshness stays in the card footer. The status indicator sits just left of the button, and the popover's separate refresh row is gone. * fix(server): include internal plugin assets in built output * feat(server): built-in plugins in /plugins (#5544) * feat(server): load compiled built-in Codex plugin * test(server): guard built-in plugin loading and installs * test(server): verify built-in plugins from dist in CI * refactor(server): migrate remaining usage sources to built-in plugins * test(server): guard all packaged usage source registrations * docs: describe usage validation across plugin hosts * test(ci): include built-in plugins in routing contract * test(server): import moved Codex usage source * fix(server): retain ACP updates received with session creation --- .github/ci-paths.yml | 1 + .github/workflows/ci.yml | 7 + docs/glossary.md | 3 + docs/plugins.md | 18 +- docs/providers.md | 21 +- knip.json | 4 + package-lock.json | 33 + package.json | 3 +- .../browser/provider-usage-settings.spec.ts | 152 -- .../browser/provider-usage-tooltip.spec.ts | 113 - .../e2e/browser/usage-composer-pill.spec.ts | 219 ++ packages/app/e2e/browser/usage-screen.spec.ts | 156 ++ .../app/e2e/browser/usage-settings.spec.ts | 136 ++ .../app/e2e/support/helpers/provider-usage.ts | 154 -- packages/app/e2e/support/helpers/settings.ts | 2 +- .../app/e2e/support/helpers/usage-reports.ts | 178 ++ packages/app/src/app/_layout.tsx | 2 + packages/app/src/app/usage.tsx | 10 + .../src/components/context-window-meter.tsx | 32 +- packages/app/src/components/left-sidebar.tsx | 28 +- .../app/src/components/page-layout/index.tsx | 71 + packages/app/src/components/provider-icons.ts | 12 +- .../components/ui/pane-content-toolbar.tsx | 5 +- packages/app/src/composer/index.tsx | 19 +- packages/app/src/provider-usage/card.tsx | 198 -- packages/app/src/provider-usage/copy.ts | 18 - .../src/provider-usage/settings-section.tsx | 99 - .../src/provider-usage/tooltip-section.tsx | 75 - packages/app/src/provider-usage/types.ts | 26 - .../src/provider-usage/use-provider-usage.ts | 103 - packages/app/src/runtime/host-runtime.ts | 23 +- packages/app/src/screens/settings-screen.tsx | 71 +- .../app/src/screens/settings/host-page.tsx | 9 +- .../workspace/workspace-desktop-tabs-row.tsx | 9 +- .../{provider-usage => usage}/balance-bar.tsx | 8 +- packages/app/src/usage/card.tsx | 308 +++ packages/app/src/usage/composer-pill.tsx | 82 + packages/app/src/usage/copy.ts | 18 + .../src/{provider-usage => usage}/format.ts | 17 +- packages/app/src/usage/host-usage-section.tsx | 17 + packages/app/src/usage/index.ts | 3 + .../src/{provider-usage => usage}/list.tsx | 18 +- packages/app/src/usage/model.test.ts | 211 ++ packages/app/src/usage/model.ts | 126 ++ packages/app/src/usage/queries.ts | 220 ++ packages/app/src/usage/source-icon.tsx | 26 + .../app/src/{provider-usage => usage}/tone.ts | 4 +- packages/app/src/usage/types.ts | 15 + packages/app/src/usage/usage-screen.tsx | 57 + packages/app/src/usage/usage-section.tsx | 103 + .../{provider-usage => usage}/window-bar.tsx | 15 +- packages/app/src/utils/host-routes.ts | 4 + packages/app/src/utils/time.test.ts | 10 + packages/app/src/utils/time.ts | 10 + packages/client/src/daemon-client.ts | 32 + packages/plugin/package.json | 5 + packages/plugin/src/boundaries.test.ts | 1 + packages/plugin/src/server/contracts.ts | 2 + packages/plugin/src/server/index.ts | 7 + packages/plugin/src/server/provider.ts | 23 + .../src/server}/usage.test.ts | 40 +- .../src/server}/usage.ts | 123 +- packages/protocol/src/messages.ts | 49 + packages/server/package.json | 3 +- .../src/server/agent/agent-sdk-types.ts | 7 + .../src/server/agent/plugin-provider.test.ts | 18 + .../src/server/agent/plugin-provider.ts | 41 +- .../src/server/agent/provider-registry.ts | 1 + .../server/agent/providers/acp-agent.test.ts | 7 + .../src/server/agent/providers/acp-agent.ts | 12 + .../agent/providers/claude/agent.test.ts | 51 + .../server/agent/providers/claude/agent.ts | 10 + .../providers/codex-app-server-agent.test.ts | 38 + .../agent/providers/codex-app-server-agent.ts | 15 + .../generic-acp-agent.commands.test.ts | 32 +- .../server/agent/providers/omp/agent.test.ts | 77 + .../src/server/agent/providers/omp/agent.ts | 11 + .../providers/omp/test-utils/omp-harness.ts | 7 +- .../providers/omp/usage-reference.test.ts | 284 +++ .../agent/providers/omp/usage-reference.ts | 169 ++ .../agent/providers/opencode-agent.test.ts | 88 + .../server/agent/providers/opencode-agent.ts | 97 +- .../server/agent/providers/pi/agent.test.ts | 67 +- .../src/server/agent/providers/pi/agent.ts | 56 + .../authorization/operation-permissions.ts | 4 + packages/server/src/server/bootstrap.ts | 7 + .../src/server/plugins/builtin/index.test.ts | 134 ++ .../src/server/plugins/builtin/index.ts | 46 + .../src/server/plugins/bundle-evaluator.ts | 38 + .../server/src/server/plugins/compiler.ts | 3 +- .../src/server/plugins/index.posix.test.ts | 12 + packages/server/src/server/plugins/index.ts | 108 +- .../src/server/plugins/internal-child.ts | 66 + .../server/plugins/internal-seam.e2e.test.ts | 82 + .../server/plugins/plugin-process-protocol.ts | 41 + .../src/server/plugins/plugin-process.ts | 912 ++++---- .../server/plugins/plugin-sdk-specifiers.ts | 1 + packages/server/src/server/plugins/runtime.ts | 83 +- .../session-usage-reference.e2e.test.ts | 150 ++ .../internal-seam/index.server.ts | 18 + .../internal-seam/paseo-plugin.json | 1 + .../session-usage-reference/index.server.ts | 101 + .../session-usage-reference/paseo-plugin.json | 1 + .../usage-source-directory/icon.svg | 1 + .../usage-source-directory/index.server.ts | 31 + .../usage-source-directory/paseo-plugin.json | 1 + .../test-fixtures/usage-source/icon.svg | 1 + .../usage-source/index.server.ts | 31 + .../usage-source/paseo-plugin.json | 1 + .../server/plugins/usage-sources.e2e.test.ts | 51 + .../plugins/usage-sources/index.test.ts | 241 ++ .../src/server/plugins/usage-sources/index.ts | 190 ++ packages/server/src/server/session.test.ts | 2 + packages/server/src/server/session.ts | 33 +- .../provider/provider-catalog-session.test.ts | 26 - .../provider/provider-catalog-session.ts | 34 +- .../session/usage/usage-session.test.ts | 102 + .../src/server/session/usage/usage-session.ts | 131 ++ .../src/server/test-utils/paseo-daemon.ts | 3 + .../server/src/server/websocket-server.ts | 8 +- .../src/services/quota-fetcher/manifest.ts | 62 - .../src/services/quota-fetcher/provider.ts | 20 - .../services/quota-fetcher/providers/codex.ts | 214 -- .../quota-fetcher/providers/copilot.ts | 97 - .../services/quota-fetcher/providers/zai.ts | 79 - .../services/quota-fetcher/service.test.ts | 1952 ----------------- .../src/services/quota-fetcher/service.ts | 89 - .../src/utils/directory-suggestions.test.ts | 15 +- plugins/claude-usage-source/icon.svg | 1 + plugins/claude-usage-source/index.server.ts | 16 + plugins/claude-usage-source/paseo-plugin.json | 1 + .../server/keychain.test.ts | 79 +- .../claude-usage-source/server/usage.test.ts | 777 +++++++ .../claude-usage-source/server/usage.ts | 307 ++- plugins/claude-usage-source/shared/input.ts | 7 + plugins/codex-usage-source/icon.svg | 1 + plugins/codex-usage-source/index.server.ts | 16 + plugins/codex-usage-source/paseo-plugin.json | 1 + .../codex-usage-source/server/usage.test.ts | 163 ++ plugins/codex-usage-source/server/usage.ts | 172 ++ plugins/codex-usage-source/shared/input.ts | 8 + plugins/copilot-usage-source/icon.svg | 2 + plugins/copilot-usage-source/index.server.ts | 16 + .../copilot-usage-source/paseo-plugin.json | 1 + .../copilot-usage-source/server/usage.test.ts | 130 ++ plugins/copilot-usage-source/server/usage.ts | 93 + plugins/copilot-usage-source/shared/input.ts | 4 + plugins/cursor-usage-source/icon.svg | 3 + plugins/cursor-usage-source/index.server.ts | 16 + plugins/cursor-usage-source/paseo-plugin.json | 1 + .../cursor-usage-source/server/usage.test.ts | 277 +++ .../cursor-usage-source/server/usage.ts | 175 +- plugins/cursor-usage-source/shared/input.ts | 4 + plugins/grok-usage-source/icon.svg | 1 + plugins/grok-usage-source/index.server.ts | 16 + plugins/grok-usage-source/paseo-plugin.json | 1 + .../grok-usage-source/server/usage.test.ts | 285 +++ .../grok-usage-source/server/usage.ts | 141 +- plugins/grok-usage-source/shared/input.ts | 4 + plugins/kimi-usage-source/icon.svg | 3 + plugins/kimi-usage-source/index.server.ts | 16 + plugins/kimi-usage-source/paseo-plugin.json | 1 + .../kimi-usage-source/server/usage.test.ts | 387 ++++ .../kimi-usage-source/server/usage.ts | 144 +- plugins/kimi-usage-source/shared/input.ts | 4 + plugins/minimax-usage-source/icon.svg | 1 + plugins/minimax-usage-source/index.server.ts | 16 + .../minimax-usage-source/paseo-plugin.json | 1 + .../minimax-usage-source/server/usage.test.ts | 332 +++ .../minimax-usage-source/server/usage.ts | 209 +- plugins/minimax-usage-source/shared/input.ts | 4 + plugins/opencode-go-usage-source/icon.svg | 6 + .../opencode-go-usage-source/index.server.ts | 16 + .../paseo-plugin.json | 1 + .../server/usage.test.ts | 90 + .../opencode-go-usage-source/server/usage.ts | 88 + .../opencode-go-usage-source/shared/input.ts | 7 + plugins/package.json | 18 + plugins/tsconfig.json | 14 + plugins/zai-usage-source/icon.svg | 1 + plugins/zai-usage-source/index.server.ts | 16 + plugins/zai-usage-source/paseo-plugin.json | 1 + plugins/zai-usage-source/server/usage.test.ts | 136 ++ plugins/zai-usage-source/server/usage.ts | 70 + plugins/zai-usage-source/shared/input.ts | 4 + public-docs/plugins/reference.md | 31 + scripts/builtin-plugins-dist.test.mjs | 80 + scripts/ci-workflow.test.mjs | 2 +- scripts/trace-daemon.mjs | 1 + 189 files changed, 9602 insertions(+), 4695 deletions(-) delete mode 100644 packages/app/e2e/browser/provider-usage-settings.spec.ts delete mode 100644 packages/app/e2e/browser/provider-usage-tooltip.spec.ts create mode 100644 packages/app/e2e/browser/usage-composer-pill.spec.ts create mode 100644 packages/app/e2e/browser/usage-screen.spec.ts create mode 100644 packages/app/e2e/browser/usage-settings.spec.ts delete mode 100644 packages/app/e2e/support/helpers/provider-usage.ts create mode 100644 packages/app/e2e/support/helpers/usage-reports.ts create mode 100644 packages/app/src/app/usage.tsx create mode 100644 packages/app/src/components/page-layout/index.tsx delete mode 100644 packages/app/src/provider-usage/card.tsx delete mode 100644 packages/app/src/provider-usage/copy.ts delete mode 100644 packages/app/src/provider-usage/settings-section.tsx delete mode 100644 packages/app/src/provider-usage/tooltip-section.tsx delete mode 100644 packages/app/src/provider-usage/types.ts delete mode 100644 packages/app/src/provider-usage/use-provider-usage.ts rename packages/app/src/{provider-usage => usage}/balance-bar.tsx (91%) create mode 100644 packages/app/src/usage/card.tsx create mode 100644 packages/app/src/usage/composer-pill.tsx create mode 100644 packages/app/src/usage/copy.ts rename packages/app/src/{provider-usage => usage}/format.ts (63%) create mode 100644 packages/app/src/usage/host-usage-section.tsx create mode 100644 packages/app/src/usage/index.ts rename packages/app/src/{provider-usage => usage}/list.tsx (58%) create mode 100644 packages/app/src/usage/model.test.ts create mode 100644 packages/app/src/usage/model.ts create mode 100644 packages/app/src/usage/queries.ts create mode 100644 packages/app/src/usage/source-icon.tsx rename packages/app/src/{provider-usage => usage}/tone.ts (74%) create mode 100644 packages/app/src/usage/types.ts create mode 100644 packages/app/src/usage/usage-screen.tsx create mode 100644 packages/app/src/usage/usage-section.tsx rename packages/app/src/{provider-usage => usage}/window-bar.tsx (85%) rename packages/{server/src/services/quota-fetcher => plugin/src/server}/usage.test.ts (60%) rename packages/{server/src/services/quota-fetcher => plugin/src/server}/usage.ts (51%) create mode 100644 packages/server/src/server/agent/providers/omp/usage-reference.test.ts create mode 100644 packages/server/src/server/agent/providers/omp/usage-reference.ts create mode 100644 packages/server/src/server/plugins/builtin/index.test.ts create mode 100644 packages/server/src/server/plugins/builtin/index.ts create mode 100644 packages/server/src/server/plugins/bundle-evaluator.ts create mode 100644 packages/server/src/server/plugins/internal-child.ts create mode 100644 packages/server/src/server/plugins/internal-seam.e2e.test.ts create mode 100644 packages/server/src/server/plugins/session-usage-reference.e2e.test.ts create mode 100644 packages/server/src/server/plugins/test-fixtures/internal-seam/index.server.ts create mode 100644 packages/server/src/server/plugins/test-fixtures/internal-seam/paseo-plugin.json create mode 100644 packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts create mode 100644 packages/server/src/server/plugins/test-fixtures/session-usage-reference/paseo-plugin.json create mode 100644 packages/server/src/server/plugins/test-fixtures/usage-source-directory/icon.svg create mode 100644 packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts create mode 100644 packages/server/src/server/plugins/test-fixtures/usage-source-directory/paseo-plugin.json create mode 100644 packages/server/src/server/plugins/test-fixtures/usage-source/icon.svg create mode 100644 packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts create mode 100644 packages/server/src/server/plugins/test-fixtures/usage-source/paseo-plugin.json create mode 100644 packages/server/src/server/plugins/usage-sources.e2e.test.ts create mode 100644 packages/server/src/server/plugins/usage-sources/index.test.ts create mode 100644 packages/server/src/server/plugins/usage-sources/index.ts create mode 100644 packages/server/src/server/session/usage/usage-session.test.ts create mode 100644 packages/server/src/server/session/usage/usage-session.ts delete mode 100644 packages/server/src/services/quota-fetcher/manifest.ts delete mode 100644 packages/server/src/services/quota-fetcher/provider.ts delete mode 100644 packages/server/src/services/quota-fetcher/providers/codex.ts delete mode 100644 packages/server/src/services/quota-fetcher/providers/copilot.ts delete mode 100644 packages/server/src/services/quota-fetcher/providers/zai.ts delete mode 100644 packages/server/src/services/quota-fetcher/service.test.ts delete mode 100644 packages/server/src/services/quota-fetcher/service.ts create mode 100644 plugins/claude-usage-source/icon.svg create mode 100644 plugins/claude-usage-source/index.server.ts create mode 100644 plugins/claude-usage-source/paseo-plugin.json rename packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts => plugins/claude-usage-source/server/keychain.test.ts (50%) create mode 100644 plugins/claude-usage-source/server/usage.test.ts rename packages/server/src/services/quota-fetcher/providers/claude.ts => plugins/claude-usage-source/server/usage.ts (63%) create mode 100644 plugins/claude-usage-source/shared/input.ts create mode 100644 plugins/codex-usage-source/icon.svg create mode 100644 plugins/codex-usage-source/index.server.ts create mode 100644 plugins/codex-usage-source/paseo-plugin.json create mode 100644 plugins/codex-usage-source/server/usage.test.ts create mode 100644 plugins/codex-usage-source/server/usage.ts create mode 100644 plugins/codex-usage-source/shared/input.ts create mode 100644 plugins/copilot-usage-source/icon.svg create mode 100644 plugins/copilot-usage-source/index.server.ts create mode 100644 plugins/copilot-usage-source/paseo-plugin.json create mode 100644 plugins/copilot-usage-source/server/usage.test.ts create mode 100644 plugins/copilot-usage-source/server/usage.ts create mode 100644 plugins/copilot-usage-source/shared/input.ts create mode 100644 plugins/cursor-usage-source/icon.svg create mode 100644 plugins/cursor-usage-source/index.server.ts create mode 100644 plugins/cursor-usage-source/paseo-plugin.json create mode 100644 plugins/cursor-usage-source/server/usage.test.ts rename packages/server/src/services/quota-fetcher/providers/cursor.ts => plugins/cursor-usage-source/server/usage.ts (60%) create mode 100644 plugins/cursor-usage-source/shared/input.ts create mode 100644 plugins/grok-usage-source/icon.svg create mode 100644 plugins/grok-usage-source/index.server.ts create mode 100644 plugins/grok-usage-source/paseo-plugin.json create mode 100644 plugins/grok-usage-source/server/usage.test.ts rename packages/server/src/services/quota-fetcher/providers/grok.ts => plugins/grok-usage-source/server/usage.ts (56%) create mode 100644 plugins/grok-usage-source/shared/input.ts create mode 100644 plugins/kimi-usage-source/icon.svg create mode 100644 plugins/kimi-usage-source/index.server.ts create mode 100644 plugins/kimi-usage-source/paseo-plugin.json create mode 100644 plugins/kimi-usage-source/server/usage.test.ts rename packages/server/src/services/quota-fetcher/providers/kimi.ts => plugins/kimi-usage-source/server/usage.ts (75%) create mode 100644 plugins/kimi-usage-source/shared/input.ts create mode 100644 plugins/minimax-usage-source/icon.svg create mode 100644 plugins/minimax-usage-source/index.server.ts create mode 100644 plugins/minimax-usage-source/paseo-plugin.json create mode 100644 plugins/minimax-usage-source/server/usage.test.ts rename packages/server/src/services/quota-fetcher/providers/minimax.ts => plugins/minimax-usage-source/server/usage.ts (59%) create mode 100644 plugins/minimax-usage-source/shared/input.ts create mode 100644 plugins/opencode-go-usage-source/icon.svg create mode 100644 plugins/opencode-go-usage-source/index.server.ts create mode 100644 plugins/opencode-go-usage-source/paseo-plugin.json create mode 100644 plugins/opencode-go-usage-source/server/usage.test.ts create mode 100644 plugins/opencode-go-usage-source/server/usage.ts create mode 100644 plugins/opencode-go-usage-source/shared/input.ts create mode 100644 plugins/package.json create mode 100644 plugins/tsconfig.json create mode 100644 plugins/zai-usage-source/icon.svg create mode 100644 plugins/zai-usage-source/index.server.ts create mode 100644 plugins/zai-usage-source/paseo-plugin.json create mode 100644 plugins/zai-usage-source/server/usage.test.ts create mode 100644 plugins/zai-usage-source/server/usage.ts create mode 100644 plugins/zai-usage-source/shared/input.ts create mode 100644 scripts/builtin-plugins-dist.test.mjs 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)