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