From 0f2f6a3b89c3ae60958365ec447629e4ea4a4117 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 26 Sep 2026 18:51:47 +0200 Subject: [PATCH] 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. --- docs/glossary.md | 2 + docs/plugins.md | 6 + docs/providers.md | 21 +- packages/plugin/src/server/usage.test.ts | 51 ++- packages/plugin/src/server/usage.ts | 40 +- .../src/plugins/claude-usage-source/icon.svg | 1 + .../claude-usage-source/index.server.ts | 22 + .../claude-usage-source/paseo-plugin.json | 1 + .../claude-usage-source/server/usage.test.ts | 194 ++++++++ .../claude-usage-source/server/usage.ts} | 59 +-- .../claude-usage-source/shared/input.ts | 7 + .../src/plugins/copilot-usage-source/icon.svg | 2 + .../copilot-usage-source/index.server.ts | 15 + .../copilot-usage-source/paseo-plugin.json | 1 + .../copilot-usage-source/server/usage.ts} | 39 +- .../copilot-usage-source/shared/input.ts | 2 + .../src/plugins/cursor-usage-source/icon.svg | 3 + .../cursor-usage-source/index.server.ts | 15 + .../cursor-usage-source/paseo-plugin.json | 1 + .../cursor-usage-source/server/usage.ts} | 39 +- .../cursor-usage-source/shared/input.ts | 2 + .../src/plugins/grok-usage-source/icon.svg | 1 + .../plugins/grok-usage-source/index.server.ts | 15 + .../grok-usage-source/paseo-plugin.json | 1 + .../grok-usage-source/server/usage.ts} | 45 +- .../plugins/grok-usage-source/shared/input.ts | 2 + packages/server/src/plugins/index.ts | 42 ++ .../src/plugins/kimi-usage-source/icon.svg | 3 + .../plugins/kimi-usage-source/index.server.ts | 15 + .../kimi-usage-source/paseo-plugin.json | 1 + .../kimi-usage-source/server/usage.ts} | 44 +- .../plugins/kimi-usage-source/shared/input.ts | 2 + .../src/plugins/minimax-usage-source/icon.svg | 1 + .../minimax-usage-source/index.server.ts | 15 + .../minimax-usage-source/paseo-plugin.json | 1 + .../minimax-usage-source/server/usage.ts} | 47 +- .../minimax-usage-source/shared/input.ts | 2 + .../usage-sources.test.ts} | 430 +++--------------- .../src/plugins/zai-usage-source/icon.svg | 1 + .../plugins/zai-usage-source/index.server.ts | 15 + .../zai-usage-source/paseo-plugin.json | 1 + .../zai-usage-source/server/usage.ts} | 41 +- .../plugins/zai-usage-source/shared/input.ts | 2 + packages/server/src/server/plugins/index.ts | 4 + .../plugins/usage-sources/index.test.ts | 45 ++ .../src/server/plugins/usage-sources/index.ts | 24 +- packages/server/src/server/session.test.ts | 1 + packages/server/src/server/session.ts | 15 +- .../provider/provider-catalog-session.test.ts | 17 +- .../provider/provider-catalog-session.ts | 10 +- .../server/src/server/websocket-server.ts | 7 - .../src/services/quota-fetcher/manifest.ts | 62 --- .../src/services/quota-fetcher/provider.ts | 20 - .../providers/claude-keychain.test.ts | 87 ---- .../services/quota-fetcher/providers/codex.ts | 214 --------- .../src/services/quota-fetcher/service.ts | 89 ---- .../src/services/quota-fetcher/usage.test.ts | 51 --- .../src/services/quota-fetcher/usage.ts | 111 ----- public-docs/plugins/reference.md | 28 ++ 59 files changed, 822 insertions(+), 1213 deletions(-) create mode 100644 packages/server/src/plugins/claude-usage-source/icon.svg create mode 100644 packages/server/src/plugins/claude-usage-source/index.server.ts create mode 100644 packages/server/src/plugins/claude-usage-source/paseo-plugin.json create mode 100644 packages/server/src/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} (92%) create mode 100644 packages/server/src/plugins/claude-usage-source/shared/input.ts create mode 100644 packages/server/src/plugins/copilot-usage-source/icon.svg create mode 100644 packages/server/src/plugins/copilot-usage-source/index.server.ts create mode 100644 packages/server/src/plugins/copilot-usage-source/paseo-plugin.json rename packages/server/src/{services/quota-fetcher/providers/copilot.ts => plugins/copilot-usage-source/server/usage.ts} (69%) create mode 100644 packages/server/src/plugins/copilot-usage-source/shared/input.ts create mode 100644 packages/server/src/plugins/cursor-usage-source/icon.svg create mode 100644 packages/server/src/plugins/cursor-usage-source/index.server.ts create mode 100644 packages/server/src/plugins/cursor-usage-source/paseo-plugin.json rename packages/server/src/{services/quota-fetcher/providers/cursor.ts => plugins/cursor-usage-source/server/usage.ts} (89%) create mode 100644 packages/server/src/plugins/cursor-usage-source/shared/input.ts create mode 100644 packages/server/src/plugins/grok-usage-source/icon.svg create mode 100644 packages/server/src/plugins/grok-usage-source/index.server.ts create mode 100644 packages/server/src/plugins/grok-usage-source/paseo-plugin.json rename packages/server/src/{services/quota-fetcher/providers/grok.ts => plugins/grok-usage-source/server/usage.ts} (83%) create mode 100644 packages/server/src/plugins/grok-usage-source/shared/input.ts create mode 100644 packages/server/src/plugins/kimi-usage-source/icon.svg create mode 100644 packages/server/src/plugins/kimi-usage-source/index.server.ts create mode 100644 packages/server/src/plugins/kimi-usage-source/paseo-plugin.json rename packages/server/src/{services/quota-fetcher/providers/kimi.ts => plugins/kimi-usage-source/server/usage.ts} (90%) create mode 100644 packages/server/src/plugins/kimi-usage-source/shared/input.ts create mode 100644 packages/server/src/plugins/minimax-usage-source/icon.svg create mode 100644 packages/server/src/plugins/minimax-usage-source/index.server.ts create mode 100644 packages/server/src/plugins/minimax-usage-source/paseo-plugin.json rename packages/server/src/{services/quota-fetcher/providers/minimax.ts => plugins/minimax-usage-source/server/usage.ts} (89%) create mode 100644 packages/server/src/plugins/minimax-usage-source/shared/input.ts rename packages/server/src/{services/quota-fetcher/service.test.ts => plugins/usage-sources.test.ts} (82%) create mode 100644 packages/server/src/plugins/zai-usage-source/icon.svg create mode 100644 packages/server/src/plugins/zai-usage-source/index.server.ts create mode 100644 packages/server/src/plugins/zai-usage-source/paseo-plugin.json rename packages/server/src/{services/quota-fetcher/providers/zai.ts => plugins/zai-usage-source/server/usage.ts} (61%) create mode 100644 packages/server/src/plugins/zai-usage-source/shared/input.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/claude-keychain.test.ts delete mode 100644 packages/server/src/services/quota-fetcher/providers/codex.ts delete mode 100644 packages/server/src/services/quota-fetcher/service.ts delete mode 100644 packages/server/src/services/quota-fetcher/usage.test.ts delete mode 100644 packages/server/src/services/quota-fetcher/usage.ts diff --git a/docs/glossary.md b/docs/glossary.md index 2c09d04dce..c29e741824 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -31,6 +31,8 @@ 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`). +- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. 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..93aac8f374 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -376,6 +376,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..8985d10ef1 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 process, 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 an internal source under `packages/server/src/plugins/-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Register internal sources in `packages/server/src/plugins/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/packages/plugin/src/server/usage.test.ts b/packages/plugin/src/server/usage.test.ts index 4206bbdfcf..0d1dd04ac5 100644 --- a/packages/plugin/src/server/usage.test.ts +++ b/packages/plugin/src/server/usage.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "vitest"; +import { describe, expect, it, test } from "vitest"; import { balanceToneFromRemaining, toneFromUsedPct, @@ -36,3 +36,52 @@ test("shared windows retain percentage, reset time, and optional headline", () = 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 + // the client applies when a window arrives without a tone. + it.each([ + [0, "ok"], + [69.9, "ok"], + [70, "warning"], + [90, "warning"], + [90.1, "danger"], + [100, "danger"], + [150, "danger"], + ])("%s%% used is %s", (usedPct, expected) => { + expect(toneFromUsedPct(usedPct)).toBe(expected); + }); + + it("is neutral when the percentage is unknown", () => { + expect(toneFromUsedPct(null)).toBe("default"); + expect(toneFromUsedPct(undefined)).toBe("default"); + }); +}); + +describe("usedPctOf", () => { + it("computes a percentage of the limit", () => { + expect(usedPctOf(15.79, 42.5)).toBeCloseTo(37.15, 2); + }); + + it("is unknown when either side is missing", () => { + expect(usedPctOf(null, 100)).toBeNull(); + expect(usedPctOf(50, null)).toBeNull(); + }); + + // A zero limit would divide to Infinity and render as a full red bar. + it("is unknown when the limit is zero or negative", () => { + expect(usedPctOf(50, 0)).toBeNull(); + expect(usedPctOf(50, -1)).toBeNull(); + }); +}); + +describe("balanceToneFromRemaining", () => { + // Kept for balances with no limit, where no percentage can be computed. It only + // escalates at exhaustion, which is why anything with a limit should use + // toneFromUsedPct instead. + it("stays ok until nothing is left", () => { + expect(balanceToneFromRemaining(0.01)).toBe("ok"); + expect(balanceToneFromRemaining(0)).toBe("danger"); + expect(balanceToneFromRemaining(null)).toBe("default"); + }); +}); diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index 07bf5c5562..afc87f312e 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -1,4 +1,4 @@ -import type { ZodType } from "zod"; +import { z, type ZodType } from "zod"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; export interface UsageWindow { @@ -107,3 +107,41 @@ export function usedPctOf( if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return null; return (used / limit) * 100; } + +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 type UsageApiFetch = typeof fetch; + +export function fetchProviderApi( + fetchApi: UsageApiFetch, + input: RequestInfo | URL, + init: RequestInit = {}, +): Promise { + return fetchApi(input, { + ...init, + signal: init.signal ?? AbortSignal.timeout(15_000), + }); +} + +export function unavailableUsage(accountKey = "default"): UsageReport { + return { + account: { key: accountKey }, + status: "unavailable", + windows: [], + balances: [], + details: [], + }; +} + +export function toIsoStringOrNull(timestampMs: number): string | null { + const date = new Date(timestampMs); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; +} diff --git a/packages/server/src/plugins/claude-usage-source/icon.svg b/packages/server/src/plugins/claude-usage-source/icon.svg new file mode 100644 index 0000000000..b8c36572e5 --- /dev/null +++ b/packages/server/src/plugins/claude-usage-source/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/server/src/plugins/claude-usage-source/index.server.ts b/packages/server/src/plugins/claude-usage-source/index.server.ts new file mode 100644 index 0000000000..b989c30260 --- /dev/null +++ b/packages/server/src/plugins/claude-usage-source/index.server.ts @@ -0,0 +1,22 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { ClaudeQuotaProvider } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "claude", + label: "Claude", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + fetch: async (input) => { + const parsed = inputSchema.parse(input); + return new ClaudeQuotaProvider({ + logger: console, + ...("configDir" in parsed ? { configDir: parsed.configDir } : {}), + ...("accessToken" in parsed ? { accessToken: parsed.accessToken } : {}), + }).fetchUsage(); + }, + }); + return () => {}; +} diff --git a/packages/server/src/plugins/claude-usage-source/paseo-plugin.json b/packages/server/src/plugins/claude-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..f4e639a515 --- /dev/null +++ b/packages/server/src/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/plugins/claude-usage-source/server/usage.test.ts b/packages/server/src/plugins/claude-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..6b74a7fe88 --- /dev/null +++ b/packages/server/src/plugins/claude-usage-source/server/usage.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./usage.js"; + +const SERVICE = "Claude Code-credentials"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("claudeKeychainAccount", () => { + it("uses a supported username verbatim", () => { + expect(claudeKeychainAccount("thomas.benoit")).toBe("thomas.benoit"); + }); + + it("falls back to claude-code-user when the username carries a rejected character", () => { + expect(claudeKeychainAccount("first.last@example.com")).toBe("claude-code-user"); + }); + + it("reads the username from $USER when no account is given", () => { + vi.stubEnv("USER", "ci-runner"); + expect(claudeKeychainAccount()).toBe("ci-runner"); + + vi.stubEnv("USER", "first.last@example.com"); + expect(claudeKeychainAccount()).toBe("claude-code-user"); + }); +}); + +describe("readClaudeKeychainCredentials", () => { + it("looks the item up by account and stops there when it exists", async () => { + const run = vi.fn(async () => JSON.stringify({ claudeAiOauth: { accessToken: "at_fresh" } })); + + const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); + + expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_fresh" } }); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith([ + "find-generic-password", + "-a", + "claude-code-user", + "-w", + "-s", + SERVICE, + ]); + }); + + it("falls back to the account-less lookup when no item matches the account", async () => { + const run = vi.fn(async (args: string[]) => + args.includes("-a") ? null : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), + ); + + const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); + + expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); + expect(run).toHaveBeenNthCalledWith(2, ["find-generic-password", "-w", "-s", SERVICE]); + }); + + it("falls through when the account item carries no access token", async () => { + const run = vi.fn(async (args: string[]) => + args.includes("-a") + ? JSON.stringify({ claudeAiOauth: { subscriptionType: "team" } }) + : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), + ); + + const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); + + expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("is null when the Keychain holds no item at all", async () => { + const run = vi.fn(async () => null); + + expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toBeNull(); + }); + + it("tries the next lookup when the item does not parse", async () => { + const run = vi.fn(async (args: string[]) => + args.includes("-a") + ? "not-json" + : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), + ); + + expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toEqual({ + claudeAiOauth: { accessToken: "at_legacy" }, + }); + }); +}); + +describe("Claude usage input forms", () => { + it("reads only the selected configDir file", async () => { + const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { ClaudeQuotaProvider } = await import("./usage.js"); + const dir = mkdtempSync(join(tmpdir(), "claude-usage-input-")); + try { + writeFileSync( + join(dir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "fixture-file" } }), + ); + const keychain = vi.fn(async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } })); + const fetchApi = vi.fn(async (_url: unknown, init: RequestInit) => { + expect(init.headers).toMatchObject({ Authorization: "Bearer fixture-file" }); + return new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { status: 200 }); + }); + const report = await new ClaudeQuotaProvider({ + logger: console, + configDir: dir, + platform: "darwin", + claudeKeychainReader: keychain, + fetch: fetchApi as never, + }).fetchUsage(); + expect(report.status).toBe("available"); + expect(keychain).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("uses an accessToken without reading a file or keychain", async () => { + const { ClaudeQuotaProvider } = await import("./usage.js"); + const keychain = vi.fn(async () => null); + const fetchApi = vi.fn(async (_url: unknown, init: RequestInit) => { + expect(init.headers).toMatchObject({ Authorization: "Bearer fixture-direct" }); + return new Response(JSON.stringify({ seven_day: { utilization: 25 } }), { status: 200 }); + }); + const report = await new ClaudeQuotaProvider({ + logger: console, + accessToken: "fixture-direct", + configDir: "/nonexistent", + platform: "darwin", + claudeKeychainReader: keychain, + fetch: fetchApi as never, + }).fetchUsage(); + expect(report.status).toBe("available"); + expect(keychain).not.toHaveBeenCalled(); + }); + + it("default discovery reads the credential file before keychain", async () => { + const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { ClaudeQuotaProvider } = await import("./usage.js"); + const dir = mkdtempSync(join(tmpdir(), "claude-usage-default-")); + try { + writeFileSync( + join(dir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }), + ); + const keychain = vi.fn(async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } })); + const fetchApi = vi.fn(async (_url: unknown, init: RequestInit) => { + expect(init.headers).toMatchObject({ Authorization: "Bearer fixture-default" }); + return new Response(JSON.stringify({ five_hour: { utilization: 7 } }), { status: 200 }); + }); + const report = await new ClaudeQuotaProvider({ + logger: console, + claudeHome: dir, + platform: "darwin", + claudeKeychainReader: keychain, + fetch: fetchApi as never, + }).fetchUsage(); + expect(report.status).toBe("available"); + expect(keychain).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("Claude non-default config directory", () => { + it("stays unavailable when its credential file is missing without probing keychain", async () => { + const { mkdtempSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { ClaudeQuotaProvider } = await import("./usage.js"); + const dir = mkdtempSync(join(tmpdir(), "claude-empty-config-")); + try { + const keychain = vi.fn(async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } })); + const fetchApi = vi.fn(); + const report = await new ClaudeQuotaProvider({ + logger: console, + configDir: dir, + platform: "darwin", + claudeKeychainReader: keychain, + fetch: fetchApi as never, + }).fetchUsage(); + expect(report.status).toBe("unavailable"); + expect(keychain).not.toHaveBeenCalled(); + expect(fetchApi).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/plugins/claude-usage-source/server/usage.ts similarity index 92% rename from packages/server/src/services/quota-fetcher/providers/claude.ts rename to packages/server/src/plugins/claude-usage-source/server/usage.ts index 75d6ed74dd..9ce3459ea8 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ b/packages/server/src/plugins/claude-usage-source/server/usage.ts @@ -1,23 +1,21 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; 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, toneFromUsedPct, unavailableUsage, windowFromUsedPct, -} from "../usage.js"; + type UsageReport, + type UsageWindow, + type UsageDetail, + type UsageApiFetch, +} from "@getpaseo/plugin/server/usage"; const execFileAsync = promisify(execFile); const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000; @@ -81,11 +79,13 @@ interface ClaudeCredentialRecord { } interface ClaudeQuotaProviderOptions { - logger: Logger; + logger: Console; claudeHome?: string; + configDir?: string; + accessToken?: string; claudeKeychainReader?: () => Promise; platform?: typeof process.platform; - fetch?: ProviderApiFetch; + fetch?: UsageApiFetch; } function buildClaudePlan( @@ -254,8 +254,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 +272,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,38 +337,40 @@ export async function readClaudeKeychainCredentials( return null; } -export class ClaudeQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "claude"; - readonly displayName = "Claude"; - - private readonly logger: Logger; +export class ClaudeQuotaProvider { + private readonly logger: Console; private readonly claudeHome: string; + private readonly configDir: string | undefined; + private readonly accessToken: string | undefined; private readonly readKeychainCredentials: () => Promise; private readonly platform: typeof process.platform; - private readonly fetchApi: ProviderApiFetch; + private readonly fetchApi: UsageApiFetch; constructor(options: ClaudeQuotaProviderOptions) { - this.logger = options.logger.child({ module: "claude-quota-provider" }); + this.logger = options.logger; this.claudeHome = options.claudeHome || process.env["CLAUDE_HOME"] || join(homedir(), ".claude"); + this.configDir = options.configDir; + this.accessToken = options.accessToken; this.readKeychainCredentials = options.claudeKeychainReader ?? readClaudeKeychainCredentials; this.platform = options.platform ?? process.platform; this.fetchApi = options.fetch ?? fetch; } - async fetchUsage(): Promise { + async fetchUsage(): Promise { const credentials = await this.readCredentials(); if (!credentials) { - return unavailableUsage(this); + return unavailableUsage(); } const { oauth } = credentials; + const accountKey = createHash("sha256").update(oauth.accessToken).digest("hex"); 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); + return unavailableUsage(accountKey); } const scoped = reconcileScopedLimits( @@ -376,6 +378,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { this.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 @@ -384,7 +387,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { this.logger.warn("Claude usage response parsed but produced no windows"); } - const details: ProviderUsageDetail[] = []; + const details: UsageDetail[] = []; const extraUsageEnabled = resp.extra_usage?.is_enabled; if (extraUsageEnabled !== undefined) { details.push({ @@ -395,14 +398,12 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { } return { - providerId: this.providerId, - displayName: this.displayName, + account: { key: accountKey }, status: "available", - planLabel: plan, + planLabel: plan ?? undefined, windows, balances: [], details, - error: null, }; } @@ -435,6 +436,8 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { } private async readCredentials(): Promise { + if (this.accessToken) return { oauth: { accessToken: this.accessToken } }; + if (this.configDir) return this.readCredentialFile(join(this.configDir, ".credentials.json")); const credPath = join(this.claudeHome, ".credentials.json"); const fileCredentials = await this.readCredentialFile(credPath); return ( diff --git a/packages/server/src/plugins/claude-usage-source/shared/input.ts b/packages/server/src/plugins/claude-usage-source/shared/input.ts new file mode 100644 index 0000000000..3668dd668f --- /dev/null +++ b/packages/server/src/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/packages/server/src/plugins/copilot-usage-source/icon.svg b/packages/server/src/plugins/copilot-usage-source/icon.svg new file mode 100644 index 0000000000..7bbea5de3a --- /dev/null +++ b/packages/server/src/plugins/copilot-usage-source/icon.svg @@ -0,0 +1,2 @@ + diff --git a/packages/server/src/plugins/copilot-usage-source/index.server.ts b/packages/server/src/plugins/copilot-usage-source/index.server.ts new file mode 100644 index 0000000000..7a9f05bc98 --- /dev/null +++ b/packages/server/src/plugins/copilot-usage-source/index.server.ts @@ -0,0 +1,15 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { CopilotQuotaProvider } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "copilot", + label: "GitHub Copilot", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + fetch: async (_input) => new CopilotQuotaProvider({ logger: console }).fetchUsage(), + }); + return () => {}; +} diff --git a/packages/server/src/plugins/copilot-usage-source/paseo-plugin.json b/packages/server/src/plugins/copilot-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..ab6addf629 --- /dev/null +++ b/packages/server/src/plugins/copilot-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "copilot-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/packages/server/src/services/quota-fetcher/providers/copilot.ts b/packages/server/src/plugins/copilot-usage-source/server/usage.ts similarity index 69% rename from packages/server/src/services/quota-fetcher/providers/copilot.ts rename to packages/server/src/plugins/copilot-usage-source/server/usage.ts index f8b4f3183e..3368351189 100644 --- a/packages/server/src/services/quota-fetcher/providers/copilot.ts +++ b/packages/server/src/plugins/copilot-usage-source/server/usage.ts @@ -1,11 +1,15 @@ 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"; +import { + ApiOptionalStringSchema, + fetchProviderApi, + unavailableUsage, + type UsageReport, + type UsageDetail, + type UsageApiFetch, +} from "@getpaseo/plugin/server/usage"; const CopilotUsageResponseSchema = z.object({ copilot_plan: ApiOptionalStringSchema, @@ -13,8 +17,8 @@ const CopilotUsageResponseSchema = z.object({ }); interface CopilotQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; + logger: Console; + fetch?: UsageApiFetch; } async function readGithubCliToken(): Promise { @@ -37,26 +41,23 @@ async function readGithubCliToken(): Promise { return null; } -export class CopilotQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "copilot"; - readonly displayName = "GitHub Copilot"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; +export class CopilotQuotaProvider { + private readonly logger: Console; + private readonly fetchApi: UsageApiFetch; constructor(options: CopilotQuotaProviderOptions) { this.logger = options.logger; this.fetchApi = options.fetch ?? fetch; } - async fetchUsage(): Promise { + async fetchUsage(): Promise { const token = process.env["COPILOT_TOKEN"] || process.env["GITHUB_TOKEN"] || process.env["GITHUB_PAT"] || (await readGithubCliToken()); - if (!token) return unavailableUsage(this); + if (!token) return unavailableUsage(); const res = await fetchProviderApi( this.fetchApi, @@ -75,23 +76,21 @@ export class CopilotQuotaProvider implements ProviderUsageFetcher { if (!res.ok) { this.logger.debug({ status: res.status }, "Copilot usage fetch failed"); - return unavailableUsage(this); + return unavailableUsage(); } const resp = CopilotUsageResponseSchema.parse(await res.json()); - const details: ProviderUsageDetail[] = resp.quota_reset_date + const details: UsageDetail[] = resp.quota_reset_date ? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }] : []; return { - providerId: this.providerId, - displayName: this.displayName, + account: { key: "default" }, status: "available", - planLabel: resp.copilot_plan || null, + planLabel: resp.copilot_plan || undefined, windows: [], balances: [], details, - error: null, }; } } diff --git a/packages/server/src/plugins/copilot-usage-source/shared/input.ts b/packages/server/src/plugins/copilot-usage-source/shared/input.ts new file mode 100644 index 0000000000..79498e9cd5 --- /dev/null +++ b/packages/server/src/plugins/copilot-usage-source/shared/input.ts @@ -0,0 +1,2 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); diff --git a/packages/server/src/plugins/cursor-usage-source/icon.svg b/packages/server/src/plugins/cursor-usage-source/icon.svg new file mode 100644 index 0000000000..4ca0c2501b --- /dev/null +++ b/packages/server/src/plugins/cursor-usage-source/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/server/src/plugins/cursor-usage-source/index.server.ts b/packages/server/src/plugins/cursor-usage-source/index.server.ts new file mode 100644 index 0000000000..aaf27dfefd --- /dev/null +++ b/packages/server/src/plugins/cursor-usage-source/index.server.ts @@ -0,0 +1,15 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { CursorQuotaProvider } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "cursor", + label: "Cursor", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + fetch: async (_input) => new CursorQuotaProvider({ logger: console }).fetchUsage(), + }); + return () => {}; +} diff --git a/packages/server/src/plugins/cursor-usage-source/paseo-plugin.json b/packages/server/src/plugins/cursor-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..311fd28ee4 --- /dev/null +++ b/packages/server/src/plugins/cursor-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "cursor-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/packages/server/src/services/quota-fetcher/providers/cursor.ts b/packages/server/src/plugins/cursor-usage-source/server/usage.ts similarity index 89% rename from packages/server/src/services/quota-fetcher/providers/cursor.ts rename to packages/server/src/plugins/cursor-usage-source/server/usage.ts index fb1cf94939..d1aac7e614 100644 --- a/packages/server/src/services/quota-fetcher/providers/cursor.ts +++ b/packages/server/src/plugins/cursor-usage-source/server/usage.ts @@ -2,10 +2,7 @@ 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, @@ -13,7 +10,10 @@ import { fetchProviderApi, toIsoStringOrNull, unavailableUsage, -} from "../usage.js"; + type UsageReport, + type UsageBalance, + type UsageApiFetch, +} from "@getpaseo/plugin/server/usage"; // 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 @@ -63,8 +63,8 @@ const CursorAuthStatusSchema = z.object({ type CursorUsageResponse = z.infer; interface CursorQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; + logger: Console; + fetch?: UsageApiFetch; homeDir?: string; } @@ -113,7 +113,7 @@ function cursorTokenFromDb(db: CursorStateDatabase): string | null { return null; } -async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise { +async function readCursorTokenFromSqlite(homeDir: string, logger: Console): Promise { const dbPaths: string[] = []; if (process.env["APPDATA"]) { dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); @@ -162,7 +162,7 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promi async function readCursorTokenFromAuthJson( homeDir: string, - logger: Logger, + logger: Console, ): Promise { const path = join(homeDir, ".config", "cursor", "auth.json"); if (!existsSync(path)) return null; @@ -175,12 +175,9 @@ async function readCursorTokenFromAuthJson( } } -export class CursorQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "cursor"; - readonly displayName = "Cursor"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; +export class CursorQuotaProvider { + private readonly logger: Console; + private readonly fetchApi: UsageApiFetch; private readonly homeDir: string; constructor(options: CursorQuotaProviderOptions) { @@ -189,14 +186,14 @@ export class CursorQuotaProvider implements ProviderUsageFetcher { this.homeDir = options.homeDir ?? homedir(); } - async fetchUsage(): Promise { + 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); + if (!token) return unavailableUsage(); const res = await fetchProviderApi( this.fetchApi, @@ -214,12 +211,12 @@ export class CursorQuotaProvider implements ProviderUsageFetcher { if (!res.ok) { this.logger.debug({ status: res.status }, "Cursor usage fetch failed"); - return unavailableUsage(this); + return unavailableUsage(); } const resp = CursorUsageResponseSchema.parse(await res.json()); const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd); - const balances: ProviderUsageBalance[] = []; + const balances: UsageBalance[] = []; if (resp.planUsage) { const totalSpend = centsToDollars(resp.planUsage.totalSpend); const remaining = centsToDollars(resp.planUsage.remaining); @@ -237,14 +234,12 @@ export class CursorQuotaProvider implements ProviderUsageFetcher { } return { - providerId: this.providerId, - displayName: this.displayName, + account: { key: "default" }, status: "available", - planLabel: null, + planLabel: undefined, windows: [], balances, details: [], - error: null, }; } } diff --git a/packages/server/src/plugins/cursor-usage-source/shared/input.ts b/packages/server/src/plugins/cursor-usage-source/shared/input.ts new file mode 100644 index 0000000000..79498e9cd5 --- /dev/null +++ b/packages/server/src/plugins/cursor-usage-source/shared/input.ts @@ -0,0 +1,2 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); diff --git a/packages/server/src/plugins/grok-usage-source/icon.svg b/packages/server/src/plugins/grok-usage-source/icon.svg new file mode 100644 index 0000000000..6352f5e8e1 --- /dev/null +++ b/packages/server/src/plugins/grok-usage-source/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/server/src/plugins/grok-usage-source/index.server.ts b/packages/server/src/plugins/grok-usage-source/index.server.ts new file mode 100644 index 0000000000..e3576fe5de --- /dev/null +++ b/packages/server/src/plugins/grok-usage-source/index.server.ts @@ -0,0 +1,15 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { GrokQuotaProvider } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "grok", + label: "Grok", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + fetch: async (_input) => new GrokQuotaProvider({ logger: console }).fetchUsage(), + }); + return () => {}; +} diff --git a/packages/server/src/plugins/grok-usage-source/paseo-plugin.json b/packages/server/src/plugins/grok-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..2fbdcc5f14 --- /dev/null +++ b/packages/server/src/plugins/grok-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "grok-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/packages/server/src/services/quota-fetcher/providers/grok.ts b/packages/server/src/plugins/grok-usage-source/server/usage.ts similarity index 83% rename from packages/server/src/services/quota-fetcher/providers/grok.ts rename to packages/server/src/plugins/grok-usage-source/server/usage.ts index 83e7fde837..4e43d7c627 100644 --- a/packages/server/src/services/quota-fetcher/providers/grok.ts +++ b/packages/server/src/plugins/grok-usage-source/server/usage.ts @@ -1,14 +1,7 @@ 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, @@ -17,7 +10,11 @@ import { fetchProviderApi, unavailableUsage, windowFromUsedPct, -} from "../usage.js"; + type UsageReport, + type UsageWindow, + type UsageBalance, + type UsageApiFetch, +} from "@getpaseo/plugin/server/usage"; const GrokUsageResponseSchema = z.object({ config: z @@ -49,8 +46,8 @@ const GrokUsageResponseSchema = z.object({ }); interface GrokQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; + logger: Console; + fetch?: UsageApiFetch; /** Override home directory (tests). Production uses os.homedir(). */ homeDir?: string; } @@ -82,7 +79,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 +94,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,12 +108,9 @@ function grokUsageWindow( }); } -export class GrokQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "grok"; - readonly displayName = "Grok"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; +export class GrokQuotaProvider { + private readonly logger: Console; + private readonly fetchApi: UsageApiFetch; private readonly homeDir: string | undefined; constructor(options: GrokQuotaProviderOptions) { @@ -127,11 +119,11 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { this.homeDir = options.homeDir; } - async fetchUsage(): Promise { + async fetchUsage(): Promise { const token = process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await this.readGrokToken()); - if (!token) return unavailableUsage(this); + 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. @@ -149,22 +141,21 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { if (!res.ok) { this.logger.debug({ status: res.status }, "Grok usage fetch failed"); - return unavailableUsage(this); + return unavailableUsage(); } const resp = GrokUsageResponseSchema.parse(await res.json()); const balance = grokMonthlyCreditBalance(resp); const window = grokUsageWindow(resp); + if (window) window.headline = true; return { - providerId: this.providerId, - displayName: this.displayName, + account: { key: "default" }, status: "available", - planLabel: null, + planLabel: undefined, windows: window ? [window] : [], balances: balance ? [balance] : [], details: [], - error: null, }; } diff --git a/packages/server/src/plugins/grok-usage-source/shared/input.ts b/packages/server/src/plugins/grok-usage-source/shared/input.ts new file mode 100644 index 0000000000..79498e9cd5 --- /dev/null +++ b/packages/server/src/plugins/grok-usage-source/shared/input.ts @@ -0,0 +1,2 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); diff --git a/packages/server/src/plugins/index.ts b/packages/server/src/plugins/index.ts index 3dd7c1d778..4bbf849975 100644 --- a/packages/server/src/plugins/index.ts +++ b/packages/server/src/plugins/index.ts @@ -1,6 +1,13 @@ import type { PluginServerContribution } from "@getpaseo/plugin/server"; import { fileURLToPath } from "node:url"; import codexUsageSource from "./codex-usage-source/index.server.js"; +import claudeUsageSource from "./claude-usage-source/index.server.js"; +import copilotUsageSource from "./copilot-usage-source/index.server.js"; +import cursorUsageSource from "./cursor-usage-source/index.server.js"; +import grokUsageSource from "./grok-usage-source/index.server.js"; +import kimiUsageSource from "./kimi-usage-source/index.server.js"; +import minimaxUsageSource from "./minimax-usage-source/index.server.js"; +import zaiUsageSource from "./zai-usage-source/index.server.js"; export interface InternalPlugin { id: string; @@ -9,9 +16,44 @@ export interface InternalPlugin { } export const INTERNAL_PLUGINS: InternalPlugin[] = [ + { + id: "claude-usage-source", + directory: fileURLToPath(new URL("./claude-usage-source/", import.meta.url)), + contribute: claudeUsageSource, + }, { id: "codex-usage-source", directory: fileURLToPath(new URL("./codex-usage-source/", import.meta.url)), contribute: codexUsageSource, }, + { + id: "copilot-usage-source", + directory: fileURLToPath(new URL("./copilot-usage-source/", import.meta.url)), + contribute: copilotUsageSource, + }, + { + id: "cursor-usage-source", + directory: fileURLToPath(new URL("./cursor-usage-source/", import.meta.url)), + contribute: cursorUsageSource, + }, + { + id: "grok-usage-source", + directory: fileURLToPath(new URL("./grok-usage-source/", import.meta.url)), + contribute: grokUsageSource, + }, + { + id: "kimi-usage-source", + directory: fileURLToPath(new URL("./kimi-usage-source/", import.meta.url)), + contribute: kimiUsageSource, + }, + { + id: "minimax-usage-source", + directory: fileURLToPath(new URL("./minimax-usage-source/", import.meta.url)), + contribute: minimaxUsageSource, + }, + { + id: "zai-usage-source", + directory: fileURLToPath(new URL("./zai-usage-source/", import.meta.url)), + contribute: zaiUsageSource, + }, ]; diff --git a/packages/server/src/plugins/kimi-usage-source/icon.svg b/packages/server/src/plugins/kimi-usage-source/icon.svg new file mode 100644 index 0000000000..4f7547cf79 --- /dev/null +++ b/packages/server/src/plugins/kimi-usage-source/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/server/src/plugins/kimi-usage-source/index.server.ts b/packages/server/src/plugins/kimi-usage-source/index.server.ts new file mode 100644 index 0000000000..eeb938a255 --- /dev/null +++ b/packages/server/src/plugins/kimi-usage-source/index.server.ts @@ -0,0 +1,15 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { KimiQuotaProvider } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "kimi", + label: "Kimi", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + fetch: async (_input) => new KimiQuotaProvider({ logger: console }).fetchUsage(), + }); + return () => {}; +} diff --git a/packages/server/src/plugins/kimi-usage-source/paseo-plugin.json b/packages/server/src/plugins/kimi-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..c81434ce3b --- /dev/null +++ b/packages/server/src/plugins/kimi-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "kimi-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/packages/server/src/services/quota-fetcher/providers/kimi.ts b/packages/server/src/plugins/kimi-usage-source/server/usage.ts similarity index 90% rename from packages/server/src/services/quota-fetcher/providers/kimi.ts rename to packages/server/src/plugins/kimi-usage-source/server/usage.ts index 1e62fd6eeb..2d27f6293e 100644 --- a/packages/server/src/services/quota-fetcher/providers/kimi.ts +++ b/packages/server/src/plugins/kimi-usage-source/server/usage.ts @@ -1,10 +1,7 @@ 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, @@ -12,7 +9,10 @@ import { toneFromUsedPct, unavailableUsage, windowFromUsedPct, -} from "../usage.js"; + type UsageReport, + type UsageWindow, + type UsageApiFetch, +} from "@getpaseo/plugin/server/usage"; const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; @@ -165,7 +165,7 @@ function windowFromFields(input: { id: string; label: string; fields: KimiUsageFields; -}): ProviderUsageWindow { +}): UsageWindow { const usedPct = usedPctFromFields(input.fields); return windowFromUsedPct({ id: input.id, @@ -189,10 +189,10 @@ function uniqueWindowId(baseId: string, seenIds: Set): string { function kimiUsageWindowsFromPayload( payload: unknown, - logger: Pick, -): ProviderUsageWindow[] { + logger: Pick, +): UsageWindow[] { const response = KimiUsageResponseSchema.parse(payload); - const windows: ProviderUsageWindow[] = []; + const windows: UsageWindow[] = []; const seenWindowIds = new Set(); const usage = parseUsageFields(response.usage); @@ -250,48 +250,44 @@ type KimiAuth = z.infer; type KimiCredentials = KimiAuth & { access_token: string }; interface KimiQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; + logger: Console; + fetch?: UsageApiFetch; homeDir?: string; } -export class KimiQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "kimi"; - readonly displayName = "Kimi"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; +export class KimiQuotaProvider { + private readonly logger: Console; + private readonly fetchApi: UsageApiFetch; private readonly homeDir?: string; constructor(options: KimiQuotaProviderOptions) { - this.logger = options.logger.child({ module: "kimi-quota-provider" }); + this.logger = options.logger; this.fetchApi = options.fetch ?? fetch; this.homeDir = options.homeDir; } - async fetchUsage(): Promise { + async fetchUsage(): Promise { const credentials = await this.readCredentials(); - if (!credentials) return unavailableUsage(this); + if (!credentials) return unavailableUsage(); 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); + return unavailableUsage(); } const windows = kimiUsageWindowsFromPayload(await res.json(), this.logger); + if (windows[0]) windows[0].headline = true; return { - providerId: this.providerId, - displayName: this.displayName, + account: { key: "default" }, status: "available", - planLabel: null, + planLabel: undefined, windows, balances: [], details: [], - error: null, }; } diff --git a/packages/server/src/plugins/kimi-usage-source/shared/input.ts b/packages/server/src/plugins/kimi-usage-source/shared/input.ts new file mode 100644 index 0000000000..79498e9cd5 --- /dev/null +++ b/packages/server/src/plugins/kimi-usage-source/shared/input.ts @@ -0,0 +1,2 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); diff --git a/packages/server/src/plugins/minimax-usage-source/icon.svg b/packages/server/src/plugins/minimax-usage-source/icon.svg new file mode 100644 index 0000000000..01cd8219e3 --- /dev/null +++ b/packages/server/src/plugins/minimax-usage-source/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/server/src/plugins/minimax-usage-source/index.server.ts b/packages/server/src/plugins/minimax-usage-source/index.server.ts new file mode 100644 index 0000000000..8989230452 --- /dev/null +++ b/packages/server/src/plugins/minimax-usage-source/index.server.ts @@ -0,0 +1,15 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { MiniMaxQuotaProvider } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "minimax", + label: "MiniMax", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + fetch: async (_input) => new MiniMaxQuotaProvider({ logger: console }).fetchUsage(), + }); + return () => {}; +} diff --git a/packages/server/src/plugins/minimax-usage-source/paseo-plugin.json b/packages/server/src/plugins/minimax-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..61ecc81c8c --- /dev/null +++ b/packages/server/src/plugins/minimax-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "minimax-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/packages/server/src/services/quota-fetcher/providers/minimax.ts b/packages/server/src/plugins/minimax-usage-source/server/usage.ts similarity index 89% rename from packages/server/src/services/quota-fetcher/providers/minimax.ts rename to packages/server/src/plugins/minimax-usage-source/server/usage.ts index ffcff7267b..ef2236739a 100644 --- a/packages/server/src/services/quota-fetcher/providers/minimax.ts +++ b/packages/server/src/plugins/minimax-usage-source/server/usage.ts @@ -1,17 +1,17 @@ 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, + type UsageApiFetch, +} from "@getpaseo/plugin/server/usage"; const MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io"; const MINIMAX_CN_BASE_URL = "https://api.minimaxi.com"; @@ -74,8 +74,8 @@ interface MiniMaxResolvedAuth { } interface MiniMaxQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; + logger: Console; + fetch?: UsageApiFetch; configPath?: string; credentialsPath?: string; env?: NodeJS.ProcessEnv; @@ -105,16 +105,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 +132,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,12 +152,9 @@ function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): ProviderU }); } -export class MiniMaxQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "minimax"; - readonly displayName = "MiniMax"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; +export class MiniMaxQuotaProvider { + private readonly logger: Console; + private readonly fetchApi: UsageApiFetch; private readonly configPath: string; private readonly credentialsPath: string; private readonly env: NodeJS.ProcessEnv; @@ -175,9 +169,9 @@ export class MiniMaxQuotaProvider implements ProviderUsageFetcher { this.now = options.now ?? Date.now; } - async fetchUsage(): Promise { + async fetchUsage(): Promise { const auth = await this.resolveAuth(); - if (!auth) return unavailableUsage(this); + if (!auth) return unavailableUsage(); const res = await fetchProviderApi(this.fetchApi, `${auth.baseUrl}/v1/token_plan/remains`, { headers: { @@ -188,7 +182,7 @@ export class MiniMaxQuotaProvider implements ProviderUsageFetcher { if (!res.ok) { this.logger.debug({ status: res.status }, "MiniMax usage fetch failed"); - return unavailableUsage(this); + return unavailableUsage(); } const resp = MiniMaxQuotaResponseSchema.parse(await res.json()); @@ -199,12 +193,12 @@ export class MiniMaxQuotaProvider implements ProviderUsageFetcher { { statusCode, statusMsg: resp.base_resp?.status_msg }, "MiniMax usage unavailable", ); - return unavailableUsage(this); + return unavailableUsage(); } const models = resp.model_remains ?? []; - const windows: ProviderUsageWindow[] = []; + const windows: UsageWindow[] = []; for (const model of models) { const name = model.model_name ?? "token-plan"; const intervalWindow = toIntervalWindow(name, model); @@ -212,16 +206,15 @@ export class MiniMaxQuotaProvider implements ProviderUsageFetcher { const weeklyWindow = toWeeklyWindow(name, model); if (weeklyWindow) windows.push(weeklyWindow); } + if (windows[0]) windows[0].headline = true; return { - providerId: this.providerId, - displayName: this.displayName, + account: { key: "default" }, status: windows.length > 0 ? "available" : "unavailable", - planLabel: null, + planLabel: undefined, windows, balances: [], details: [], - error: null, }; } diff --git a/packages/server/src/plugins/minimax-usage-source/shared/input.ts b/packages/server/src/plugins/minimax-usage-source/shared/input.ts new file mode 100644 index 0000000000..79498e9cd5 --- /dev/null +++ b/packages/server/src/plugins/minimax-usage-source/shared/input.ts @@ -0,0 +1,2 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/plugins/usage-sources.test.ts similarity index 82% rename from packages/server/src/services/quota-fetcher/service.test.ts rename to packages/server/src/plugins/usage-sources.test.ts index a7adb37d3e..8faca659d2 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/plugins/usage-sources.test.ts @@ -5,16 +5,13 @@ 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"; +import { ClaudeQuotaProvider } from "./claude-usage-source/server/usage.js"; +import { CopilotQuotaProvider } from "./copilot-usage-source/server/usage.js"; +import { CursorQuotaProvider } from "./cursor-usage-source/server/usage.js"; +import { GrokQuotaProvider } from "./grok-usage-source/server/usage.js"; +import { KimiQuotaProvider } from "./kimi-usage-source/server/usage.js"; +import { MiniMaxQuotaProvider } from "./minimax-usage-source/server/usage.js"; +import { ZaiQuotaProvider } from "./zai-usage-source/server/usage.js"; function writeClaudeCredentials( dir: string, @@ -31,13 +28,6 @@ function writeClaudeCredentials( ); } -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"); } @@ -127,18 +117,6 @@ function makeClaudeResponse( }; } -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(); @@ -166,187 +144,12 @@ function createLogger() { 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}`); - } + 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; @@ -411,49 +214,64 @@ describe("real provider usage fetchers", () => { 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, - }); + const fetchers = [ + new ClaudeQuotaProvider({ + logger, + claudeHome, + claudeKeychainReader: options.keychain ?? (async () => null), + platform: options.platform, + 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"), + }), + ]; + return { + listUsage: async () => ({ + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: await Promise.all( + fetchers.map(async (fetcher) => { + const report = await fetcher.fetchUsage(); + const id = fetcher.constructor.name.replace("QuotaProvider", "").toLowerCase(); + return { + providerId: id === "minimax" ? "minimax" : id, + displayName: id, + status: report.status, + planLabel: report.planLabel ?? null, + windows: report.windows, + balances: report.balances ?? [], + details: report.details ?? [], + error: report.error ?? null, + } satisfies ProviderUsage; + }), + ), + }), + }; } it("fetches Claude usage, coerces API numbers, and attaches HTTP timeout signals", async () => { @@ -574,91 +392,6 @@ describe("real provider usage fetchers", () => { ); }); - 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( @@ -1426,37 +1159,6 @@ describe("usage bars escalate as they fill", () => { 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 diff --git a/packages/server/src/plugins/zai-usage-source/icon.svg b/packages/server/src/plugins/zai-usage-source/icon.svg new file mode 100644 index 0000000000..d552d2a3d0 --- /dev/null +++ b/packages/server/src/plugins/zai-usage-source/icon.svg @@ -0,0 +1 @@ +Z.ai diff --git a/packages/server/src/plugins/zai-usage-source/index.server.ts b/packages/server/src/plugins/zai-usage-source/index.server.ts new file mode 100644 index 0000000000..3d064cf873 --- /dev/null +++ b/packages/server/src/plugins/zai-usage-source/index.server.ts @@ -0,0 +1,15 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { inputSchema } from "./shared/input.js"; +import { ZaiQuotaProvider } from "./server/usage.js"; + +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "zai", + label: "Z.ai", + icon: "icon.svg", + input: inputSchema, + discover: async () => [{}], + fetch: async (_input) => new ZaiQuotaProvider({ logger: console }).fetchUsage(), + }); + return () => {}; +} diff --git a/packages/server/src/plugins/zai-usage-source/paseo-plugin.json b/packages/server/src/plugins/zai-usage-source/paseo-plugin.json new file mode 100644 index 0000000000..4b65f4a4e3 --- /dev/null +++ b/packages/server/src/plugins/zai-usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "zai-usage-source", "requirements": { "paseo": ">=0.9.2" } } diff --git a/packages/server/src/services/quota-fetcher/providers/zai.ts b/packages/server/src/plugins/zai-usage-source/server/usage.ts similarity index 61% rename from packages/server/src/services/quota-fetcher/providers/zai.ts rename to packages/server/src/plugins/zai-usage-source/server/usage.ts index e8884cf2c4..0eae65d239 100644 --- a/packages/server/src/services/quota-fetcher/providers/zai.ts +++ b/packages/server/src/plugins/zai-usage-source/server/usage.ts @@ -1,8 +1,12 @@ -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"; +import { + ApiOptionalStringSchema, + fetchProviderApi, + unavailableUsage, + type UsageReport, + type UsageDetail, + type UsageApiFetch, +} from "@getpaseo/plugin/server/usage"; const ZaiUsageResponseSchema = z.object({ data: z @@ -18,25 +22,22 @@ const ZaiUsageResponseSchema = z.object({ }); interface ZaiQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; + logger: Console; + fetch?: UsageApiFetch; } -export class ZaiQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "zai"; - readonly displayName = "Z.ai"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; +export class ZaiQuotaProvider { + private readonly logger: Console; + private readonly fetchApi: UsageApiFetch; constructor(options: ZaiQuotaProviderOptions) { this.logger = options.logger; this.fetchApi = options.fetch ?? fetch; } - async fetchUsage(): Promise { + async fetchUsage(): Promise { const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"]; - if (!token) return unavailableUsage(this); + if (!token) return unavailableUsage(); const res = await fetchProviderApi( this.fetchApi, @@ -51,14 +52,14 @@ export class ZaiQuotaProvider implements ProviderUsageFetcher { if (!res.ok) { this.logger.debug({ status: res.status }, "Z.ai usage fetch failed"); - return unavailableUsage(this); + return unavailableUsage(); } const resp = ZaiUsageResponseSchema.parse(await res.json()); const sub = resp.data?.[0]; - if (!sub) return unavailableUsage(this); + if (!sub) return unavailableUsage(); - const details: ProviderUsageDetail[] = []; + 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) { @@ -66,14 +67,12 @@ export class ZaiQuotaProvider implements ProviderUsageFetcher { } return { - providerId: this.providerId, - displayName: this.displayName, + account: { key: "default" }, status: "available", - planLabel: sub.productName || null, + planLabel: sub.productName || undefined, windows: [], balances: [], details, - error: null, }; } } diff --git a/packages/server/src/plugins/zai-usage-source/shared/input.ts b/packages/server/src/plugins/zai-usage-source/shared/input.ts new file mode 100644 index 0000000000..79498e9cd5 --- /dev/null +++ b/packages/server/src/plugins/zai-usage-source/shared/input.ts @@ -0,0 +1,2 @@ +import { z } from "zod"; +export const inputSchema = z.object({}).strict(); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 54a2accbd1..86b3003164 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -146,6 +146,10 @@ export class PluginService { return this.usageSources.listReports(options); } + listLegacyUsage() { + return this.usageSources.listLegacyUsage(); + } + subscribeProviderRegistrations(listener: () => void): () => void { this.providerListeners.add(listener); return () => this.providerListeners.delete(listener); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 5ece13c69b..274bfea02b 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -83,3 +83,48 @@ test("turns fetch and discovery failures into error reports", async () => { "error", ]); }); + +test("maps discovered reports to the legacy provider usage response", async () => { + const registry = new UsageSourceRegistry(() => Date.parse("2026-06-19T00:00:00.000Z")); + registry.register({ + id: "fixture", + label: "Fixture Source", + discover: async () => [{}], + fetch: async () => ({ + account: { key: "one" }, + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 42, headline: true }], + }), + }); + registry.register({ + id: "missing", + label: "Missing Source", + discover: async () => [{}], + fetch: async () => ({ account: { key: "default" }, status: "unavailable", windows: [] }), + }); + expect(await registry.listLegacyUsage()).toEqual({ + fetchedAt: "2026-06-19T00:00:00.000Z", + providers: [ + { + providerId: "fixture", + displayName: "Fixture Source", + status: "available", + planLabel: null, + windows: [{ id: "weekly", label: "Weekly", usedPct: 42, headline: true }], + balances: [], + details: [], + error: null, + }, + { + providerId: "missing", + displayName: "Missing Source", + status: "unavailable", + planLabel: null, + windows: [], + balances: [], + details: [], + error: null, + }, + ], + }); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index 1a677a2f4e..bba9e33989 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -1,5 +1,9 @@ import { createHash } from "node:crypto"; -import { UsageReportSchema, type UsageReportEntry } from "@getpaseo/protocol/messages"; +import { + UsageReportSchema, + type UsageReportEntry, + type ProviderUsage, +} from "@getpaseo/protocol/messages"; export interface UsageSource { id: string; @@ -48,6 +52,24 @@ export class UsageSourceRegistry { return [...unique.values()]; } + // COMPAT(providerUsageList): added in v0.9.2, remove after 2027-03-26. + async listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }> { + const reports = await this.listReports(); + return { + 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, + })), + }; + } + fetch( sourceId: string, input: unknown, diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 29c3ba3378..e60dd95209 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -522,6 +522,7 @@ 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 62dbdf8af8..287990a497 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, UsageReportEntry } 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"; @@ -226,7 +230,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, @@ -507,6 +510,7 @@ export interface SessionOptions { catalog(): Array<{ id: string; clientBundle: string }>; invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; listUsageReports(options?: { forceRefresh?: boolean }): Promise; + listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills; mcpBaseUrl?: string | null; @@ -515,7 +519,6 @@ export interface SessionOptions { tts: Resolvable; terminalManager: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; - providerUsageService: ProviderUsageService; hubExecutionAgents?: HubExecutionAgents; hubRelationships?: HubRelationshipManagement; serviceProxy?: ServiceProxySubsystem; @@ -828,7 +831,6 @@ export class Session { tts, terminalManager, providerSnapshotManager, - providerUsageService, serviceProxy, scriptRuntimeStore, workspaceSetupSnapshots, @@ -985,7 +987,10 @@ export class Session { listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config), }, providerSnapshotManager, - providerUsageService, + listLegacyUsage: async () => { + if (!pluginRuntime) throw new Error("Plugin runtime is unavailable"); + return pluginRuntime.listLegacyUsage(); + }, logger: this.sessionLogger, }); this.agentConfigSession = new AgentConfigSession({ 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..eff3af67b8 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,7 @@ interface MakeOptions { supportsCustomModeIcons?: boolean; supportsCompactProviderSnapshots?: boolean; snapshot?: Partial; - usage?: { [K in keyof ProviderUsageService]?: unknown }; + usage?: () => Promise<{ fetchedAt: string; providers: [] }>; host?: Partial; } @@ -73,7 +72,8 @@ function makeSubsystem(options: MakeOptions = {}) { const subsystem = new ProviderCatalogSession({ host, providerSnapshotManager, - providerUsageService: createStub(options.usage ?? {}), + listLegacyUsage: + options.usage ?? (async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] })), logger: pino({ level: "silent" }), }); function pushSnapshotChange( @@ -301,10 +301,8 @@ 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"); - }, + usage: async () => { + throw new Error("quota service down"); }, }); @@ -388,10 +386,7 @@ 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: [], - }), + listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }), 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..a57cce5bbe 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,7 @@ 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 type { ProviderUsage } from "@getpaseo/protocol/messages"; import { expandTilde } from "../../../utils/path.js"; // COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any @@ -56,7 +56,7 @@ export interface ProviderCatalogSessionHost { export interface ProviderCatalogSessionOptions { host: ProviderCatalogSessionHost; providerSnapshotManager: ProviderSnapshotManager; - providerUsageService: ProviderUsageService; + listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; logger: pino.Logger; } @@ -70,14 +70,14 @@ export interface ProviderCatalogSessionOptions { export class ProviderCatalogSession { private readonly host: ProviderCatalogSessionHost; private readonly providerSnapshotManager: ProviderSnapshotManager; - private readonly providerUsageService: ProviderUsageService; + private readonly listLegacyUsage: ProviderCatalogSessionOptions["listLegacyUsage"]; 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.listLegacyUsage = options.listLegacyUsage; this.logger = options.logger; } @@ -491,7 +491,7 @@ export class ProviderCatalogSession { msg: Extract, ): Promise { try { - const usage = await this.providerUsageService.listUsage(); + const usage = await this.listLegacyUsage(); this.host.emit({ type: "provider.usage.list.response", payload: { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index e2acb7038e..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, 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/claude-keychain.test.ts b/packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts deleted file mode 100644 index 0f6be01f32..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./claude.js"; - -const SERVICE = "Claude Code-credentials"; - -afterEach(() => { - vi.unstubAllEnvs(); -}); - -describe("claudeKeychainAccount", () => { - it("uses a supported username verbatim", () => { - expect(claudeKeychainAccount("thomas.benoit")).toBe("thomas.benoit"); - }); - - it("falls back to claude-code-user when the username carries a rejected character", () => { - expect(claudeKeychainAccount("first.last@example.com")).toBe("claude-code-user"); - }); - - it("reads the username from $USER when no account is given", () => { - vi.stubEnv("USER", "ci-runner"); - expect(claudeKeychainAccount()).toBe("ci-runner"); - - vi.stubEnv("USER", "first.last@example.com"); - expect(claudeKeychainAccount()).toBe("claude-code-user"); - }); -}); - -describe("readClaudeKeychainCredentials", () => { - it("looks the item up by account and stops there when it exists", async () => { - const run = vi.fn(async () => JSON.stringify({ claudeAiOauth: { accessToken: "at_fresh" } })); - - const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); - - expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_fresh" } }); - expect(run).toHaveBeenCalledTimes(1); - expect(run).toHaveBeenCalledWith([ - "find-generic-password", - "-a", - "claude-code-user", - "-w", - "-s", - SERVICE, - ]); - }); - - it("falls back to the account-less lookup when no item matches the account", async () => { - const run = vi.fn(async (args: string[]) => - args.includes("-a") ? null : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), - ); - - const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); - - expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); - expect(run).toHaveBeenNthCalledWith(2, ["find-generic-password", "-w", "-s", SERVICE]); - }); - - it("falls through when the account item carries no access token", async () => { - const run = vi.fn(async (args: string[]) => - args.includes("-a") - ? JSON.stringify({ claudeAiOauth: { subscriptionType: "team" } }) - : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), - ); - - const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); - - expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); - expect(run).toHaveBeenCalledTimes(2); - }); - - it("is null when the Keychain holds no item at all", async () => { - const run = vi.fn(async () => null); - - expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toBeNull(); - }); - - it("tries the next lookup when the item does not parse", async () => { - const run = vi.fn(async (args: string[]) => - args.includes("-a") - ? "not-json" - : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), - ); - - expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toEqual({ - claudeAiOauth: { accessToken: "at_legacy" }, - }); - }); -}); 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/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/services/quota-fetcher/usage.test.ts b/packages/server/src/services/quota-fetcher/usage.test.ts deleted file mode 100644 index 8e37583e30..0000000000 --- a/packages/server/src/services/quota-fetcher/usage.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { balanceToneFromRemaining, toneFromUsedPct, usedPctOf } from "./usage.js"; - -describe("toneFromUsedPct", () => { - // Thresholds must match deriveTone in the app's provider-usage/tone.ts, which is what - // the client applies when a window arrives without a tone. - it.each([ - [0, "ok"], - [69.9, "ok"], - [70, "warning"], - [90, "warning"], - [90.1, "danger"], - [100, "danger"], - [150, "danger"], - ])("%s%% used is %s", (usedPct, expected) => { - expect(toneFromUsedPct(usedPct)).toBe(expected); - }); - - it("is neutral when the percentage is unknown", () => { - expect(toneFromUsedPct(null)).toBe("default"); - expect(toneFromUsedPct(undefined)).toBe("default"); - }); -}); - -describe("usedPctOf", () => { - it("computes a percentage of the limit", () => { - expect(usedPctOf(15.79, 42.5)).toBeCloseTo(37.15, 2); - }); - - it("is unknown when either side is missing", () => { - expect(usedPctOf(null, 100)).toBeNull(); - expect(usedPctOf(50, null)).toBeNull(); - }); - - // A zero limit would divide to Infinity and render as a full red bar. - it("is unknown when the limit is zero or negative", () => { - expect(usedPctOf(50, 0)).toBeNull(); - expect(usedPctOf(50, -1)).toBeNull(); - }); -}); - -describe("balanceToneFromRemaining", () => { - // Kept for balances with no limit, where no percentage can be computed. It only - // escalates at exhaustion, which is why anything with a limit should use - // toneFromUsedPct instead. - it("stays ok until nothing is left", () => { - expect(balanceToneFromRemaining(0.01)).toBe("ok"); - expect(balanceToneFromRemaining(0)).toBe("danger"); - expect(balanceToneFromRemaining(null)).toBe("default"); - }); -}); diff --git a/packages/server/src/services/quota-fetcher/usage.ts b/packages/server/src/services/quota-fetcher/usage.ts deleted file mode 100644 index fa766f5778..0000000000 --- a/packages/server/src/services/quota-fetcher/usage.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { z } from "zod"; -import type { - ProviderUsage, - ProviderUsageBalance, - ProviderUsageTone, - ProviderUsageWindow, -} from "../../server/messages.js"; -import type { ProviderApiFetch } from "./provider.js"; - -const PROVIDER_HTTP_TIMEOUT_MS = 15_000; - -export const ApiNumberSchema = z.coerce.number().finite(); -export const ApiNullableNumberSchema = z.preprocess( - (value) => (value == null ? null : value), - ApiNumberSchema.nullable(), -); -export const ApiOptionalStringSchema = z.preprocess( - (value) => (value == null ? undefined : value), - z.coerce.string().optional(), -); - -export function fetchProviderApi( - fetchApi: ProviderApiFetch, - input: RequestInfo | URL, - init: RequestInit = {}, -): Promise { - return fetchApi(input, { - ...init, - signal: init.signal ?? AbortSignal.timeout(PROVIDER_HTTP_TIMEOUT_MS), - }); -} - -export function unavailableUsage(provider: { - providerId: string; - displayName: string; - error?: string | null; -}): ProviderUsage { - return { - providerId: provider.providerId, - displayName: provider.displayName, - status: provider.error ? "error" : "unavailable", - planLabel: null, - windows: [], - balances: [], - details: [], - error: provider.error ?? null, - }; -} - -export function windowFromUsedPct(input: { - id: string; - label: string; - utilizationPct: number | null | undefined; - resetsAt?: string | null; - tone?: ProviderUsageWindow["tone"]; -}): ProviderUsageWindow { - const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null; - const window: ProviderUsageWindow = { - id: input.id, - label: input.label, - usedPct, - remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), - resetsAt: input.resetsAt ?? null, - }; - if (input.tone) { - window.tone = input.tone; - } - return window; -} - -/** - * The tone scale for anything measured against a known limit, windows and balances alike. - * - * Thresholds match `deriveTone` in the app's provider-usage/tone.ts, which is what the - * client falls back to when a window arrives without a tone. Healthy is "ok" rather than - * "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 { - if (typeof usedPct !== "number") return "default"; - if (usedPct > 90) return "danger"; - if (usedPct >= 70) return "warning"; - return "ok"; -} - -/** - * Tone for a balance with no known limit, where a percentage cannot be computed and the - * only signal is whether anything is left. Prefer `toneFromUsedPct` when a limit exists: - * this one stays "ok" until the balance is completely spent. - */ -export function balanceToneFromRemaining( - remaining: number | null | undefined, -): ProviderUsageBalance["tone"] { - if (typeof remaining !== "number") return "default"; - if (remaining <= 0) return "danger"; - return "ok"; -} - -/** Percentage of a limit consumed, or null when either side is unknown. */ -export function usedPctOf( - used: number | null | undefined, - limit: number | null | undefined, -): number | null { - if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return null; - return (used / limit) * 100; -} - -export function toIsoStringOrNull(timestampMs: number): string | null { - const date = new Date(timestampMs); - return Number.isFinite(date.getTime()) ? date.toISOString() : null; -} diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 658cb15346..abeb85a413 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -304,6 +304,34 @@ 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.2 or newer.** Server plugins can register a usage source with `server.registerUsageSource()` and import its 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" }], + fetch: async (value) => { + const { account } = input.parse(value); + return { account: { key: account }, status: "available", windows: [] }; + }, + }); + return () => {}; +} +``` + +`discover()` supplies host accounts for `usage.list_reports`; return `[]` when none are configured. The daemon validates input against `input` inside the plugin process, caches each source/input result for five minutes, and deduplicates reports by source and `account.key`. `fetch()` returns a `UsageReport` with `status` (`available`, `unavailable`, or `error`), optional `planLabel`, and generic `windows`, `balances`, and `details`. Set `headline: true` on the window to show 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