From 05e94ab52ebf20ee6012c2bafbd892856643a7e5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 2 Oct 2026 00:30:11 +0100 Subject: [PATCH] Keep usage logins visible and explain unavailable quota (#5876) * fix(usage): keep existing logins visible and explain unavailable quota Credential discovery must preserve accounts whose login exists even when it has expired. Keep vendor failures on the discovered account and isolate discovery failures without inventing provider cards. * test(usage): align credential route tests with account discovery --- docs/providers.md | 11 +- packages/app/e2e/browser/usage-screen.spec.ts | 116 ++++++++++- .../app/e2e/browser/usage-settings.spec.ts | 17 +- .../e2e/browser/usage-sidebar-item.spec.ts | 5 +- .../app/e2e/support/helpers/usage-login.ts | 66 ++++++ .../app/e2e/support/helpers/usage-reports.ts | 9 +- .../e2e/support/helpers/usage-sidebar-item.ts | 3 +- packages/app/src/usage/card.tsx | 28 ++- packages/app/src/usage/copy.test.ts | 18 ++ packages/app/src/usage/copy.ts | 12 ++ packages/app/src/usage/pinned.test.ts | 15 ++ packages/app/src/usage/pinned.ts | 1 + packages/app/src/usage/types.ts | 5 +- packages/app/src/usage/window-bar.tsx | 73 +++++-- packages/client/src/daemon-client.test.ts | 167 +++++---------- packages/client/src/daemon-client.ts | 48 +++-- packages/plugin/src/server/usage.ts | 44 ++-- packages/protocol/src/messages.ts | 33 ++- packages/server/src/server/plugins/index.ts | 9 +- .../server/plugins/plugin-process-protocol.ts | 9 - .../src/server/plugins/plugin-process.ts | 18 +- .../src/server/plugins/runtime.posix.test.ts | 1 - packages/server/src/server/plugins/runtime.ts | 11 - .../usage-source-directory/index.server.ts | 13 +- .../usage-source/index.server.ts | 13 +- .../server/plugins/usage-sources.e2e.test.ts | 79 +++++-- .../plugins/usage-sources/index.test.ts | 192 +++++++++--------- .../src/server/plugins/usage-sources/index.ts | 168 +++++++-------- plugins/claude-usage-source/index.server.ts | 3 +- .../server/keychain.test.ts | 47 +++-- .../claude-usage-source/server/stores.test.ts | 108 +++++----- plugins/claude-usage-source/server/stores.ts | 1 - .../claude-usage-source/server/usage.test.ts | 185 +++++++++++++---- plugins/claude-usage-source/server/usage.ts | 108 ++++++---- plugins/codex-usage-source/index.server.ts | 3 +- .../codex-usage-source/server/stores.test.ts | 70 ++++--- plugins/codex-usage-source/server/stores.ts | 1 - .../codex-usage-source/server/usage.test.ts | 68 +++++-- plugins/codex-usage-source/server/usage.ts | 34 ++-- plugins/copilot-usage-source/index.server.ts | 5 +- .../copilot-usage-source/server/usage.test.ts | 57 +++++- plugins/copilot-usage-source/server/usage.ts | 67 +++--- plugins/copilot-usage-source/shared/input.ts | 8 +- plugins/cursor-usage-source/index.server.ts | 5 +- .../cursor-usage-source/server/usage.test.ts | 68 +++++-- plugins/cursor-usage-source/server/usage.ts | 107 +++++----- plugins/cursor-usage-source/shared/input.ts | 8 +- plugins/grok-usage-source/index.server.ts | 5 +- .../grok-usage-source/server/usage.test.ts | 57 +++++- plugins/grok-usage-source/server/usage.ts | 50 +++-- plugins/grok-usage-source/shared/input.ts | 8 +- plugins/kimi-usage-source/index.server.ts | 5 +- .../kimi-usage-source/server/usage.test.ts | 65 +++++- plugins/kimi-usage-source/server/usage.ts | 111 ++++------ plugins/kimi-usage-source/shared/input.ts | 8 +- plugins/minimax-usage-source/index.server.ts | 5 +- .../minimax-usage-source/server/usage.test.ts | 78 +++++-- plugins/minimax-usage-source/server/usage.ts | 164 ++++++--------- plugins/minimax-usage-source/shared/input.ts | 8 +- plugins/muse-provider/server/usage.ts | 18 +- plugins/muse-provider/test/provider.test.ts | 24 ++- .../opencode-go-usage-source/index.server.ts | 3 +- .../server/usage.test.ts | 52 ++--- .../opencode-go-usage-source/server/usage.ts | 20 +- .../opencode-go-usage-source/shared/input.ts | 3 +- plugins/zai-usage-source/index.server.ts | 5 +- plugins/zai-usage-source/server/usage.test.ts | 57 +++++- plugins/zai-usage-source/server/usage.ts | 24 ++- plugins/zai-usage-source/shared/input.ts | 8 +- public-docs/plugins/reference.md | 114 +++++++++-- 70 files changed, 1919 insertions(+), 1110 deletions(-) create mode 100644 packages/app/e2e/support/helpers/usage-login.ts create mode 100644 packages/app/src/usage/copy.test.ts diff --git a/docs/providers.md b/docs/providers.md index 0036c2e6f2..9aa2fe8bf6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -187,15 +187,8 @@ promise for completion: equal results, including equal discovery timestamps, emi ## Usage sources -Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and required `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. - -Create a built-in source under `plugins/-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources). - -`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`. - -### Credentials are read only - -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. +See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the +contract, account identity, login fallback, and read-only credential rules. --- diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 6512a8bd8f..71f3dfd8cc 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -1,3 +1,4 @@ +import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import path from "node:path"; import type { Page } from "@playwright/test"; @@ -12,7 +13,38 @@ import { type UsageListRequest, type UsageReportsFixture, } from "../support/helpers/usage-reports"; -import { expectPinnedUsage, usageItem } from "../support/helpers/usage-sidebar-item"; +import { + installLoginUsage, + openUsage, + refreshLoginUsage, + hoverUsageWindow, +} from "../support/helpers/usage-login"; +import { expectPinnedUsage, togglePin, usageItem } from "../support/helpers/usage-sidebar-item"; + +const emptyHome = path.join(tmpdir(), `paseo-usage-empty-${randomUUID()}`); +test.use({ + e2eDaemonEnvironment: { + HOME: emptyHome, + USERPROFILE: emptyHome, + CODEX_HOME: emptyHome, + CLAUDE_CONFIG_DIR: emptyHome, + XDG_DATA_HOME: emptyHome, + PI_CODING_AGENT_DIR: emptyHome, + COPILOT_TOKEN: "", + GITHUB_TOKEN: "", + GITHUB_PAT: "", + CURSOR_ACCESS_TOKEN: "", + CURSOR_TOKEN: "", + GROK_API_KEY: "", + GROK_TOKEN: "", + KIMI_TOKEN: "", + KIMI_API_KEY: "", + KIMI_CODE_HOME: emptyHome, + MINIMAX_API_KEY: "", + ZAI_API_KEY: "", + GLM_API_KEY: "", + }, +}); function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] { return usage.listRequests().filter((request) => request.forceRefresh); @@ -73,7 +105,10 @@ test.describe("usage screen", () => { fetchedAt: "2026-01-01T00:00:00.000Z", sourceId: "beta", sourceLabel: "Beta plan", - report: { status: "unavailable", windows: [] }, + report: { + status: "unavailable", + problem: { kind: "no_quota", detail: "No active coding plan" }, + }, }, ], ], @@ -244,3 +279,80 @@ test.describe("usage screen", () => { } }); }); + +const loginWindows: UsageReportEntry["report"] = { + status: "available", + windows: [ + { id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct: 31 }, + { id: "weekly", label: "Weekly", shortLabel: "wk", summary: true, usedPct: 54 }, + ], +}; + +test("expired login refreshes to windows with visible pin toggles", async ({ page }) => { + const fixture = await installLoginUsage({ + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 3_600_000).toISOString(), + refreshedBy: "claude", + }, + }); + try { + await gotoAppShell(page); + await openUsage(page); + await expect( + page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("Unavailable", { exact: true })).toBeVisible(); + await qaScreenshot(page, "usage-expired-login"); + await fixture.setReport(loginWindows); + await refreshLoginUsage(page); + await expect( + page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + ).toHaveCount(0); + await expectPinnedUsage(page, ["31% 5h", "54% wk"]); + const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / }); + await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "0"); + await hoverUsageWindow(page, "Weekly"); + await expect(page.getByText("Pin", { exact: true })).toBeVisible(); + await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1"); + await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute( + "fill", + "none", + ); + await qaScreenshot(page, "usage-pin-hover"); + await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly"); + await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1"); + await expect(row.getByTestId("usage-pin-glyph-pinned").locator("svg")).not.toHaveAttribute( + "fill", + "none", + ); + await expectPinnedUsage(page, ["54% wk"]); + await qaScreenshot(page, "usage-pin-selected"); + await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly"); + await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1"); + await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute( + "fill", + "none", + ); + await expectPinnedUsage(page, ["31% 5h", "54% wk"]); + } finally { + await fixture.cleanup(); + } +}); + +test("compact usage rows always show the pin glyph", async ({ page }) => { + const fixture = await installLoginUsage(loginWindows); + try { + await page.setViewportSize({ width: 390, height: 844 }); + await gotoAppShell(page); + await openUsage(page); + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByTestId("usage-pin-glyph-unpinned")).toHaveCount(2); + await expect(card.getByTestId("usage-pin-glyph-unpinned").nth(0)).toHaveCSS("opacity", "1"); + await expect(card.getByTestId("usage-pin-glyph-unpinned").nth(1)).toHaveCSS("opacity", "1"); + await qaScreenshot(page, "usage-pin-compact"); + } finally { + await fixture.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 5ad817aeb1..617bd121b5 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -18,7 +18,9 @@ function forcedRefreshCount(usage: UsageReportsFixture): number { function report(input: { sourceId: string; sourceLabel: string; - report: Partial; + report: + | Partial> + | Exclude; }): UsageReportEntry { return { id: `${input.sourceId}:account`, @@ -27,11 +29,14 @@ function report(input: { sourceId: input.sourceId, sourceLabel: input.sourceLabel, icon: ICON, - report: { - status: "available", - windows: [], - ...input.report, - }, + report: + input.report.status === "error" || input.report.status === "unavailable" + ? input.report + : { + status: "available", + windows: [], + ...input.report, + }, }; } diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 0b1f04fed7..8a1d4bb7f0 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -262,7 +262,10 @@ test("without summary data the footer drops the Usage item and keeps the Usage i fetchedAt: new Date().toISOString(), sourceId: "alpha", sourceLabel: "Alpha plan", - report: { status: "unavailable", windows: [] }, + report: { + status: "unavailable", + problem: { kind: "no_quota", detail: "No active coding plan" }, + }, }, ], ], diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts new file mode 100644 index 0000000000..5a2bef072d --- /dev/null +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -0,0 +1,66 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { UsageReport } from "@getpaseo/protocol/messages"; +import type { Page } from "@playwright/test"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { pluginRequirements } from "./plugin-fixture"; + +/** A real store-backed plugin on the worker's isolated daemon. */ +export async function installLoginUsage(initialReport: UsageReport) { + const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const store = path.join(directory, "report.json"); + const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report)); + const cleanup = async () => { + try { + await client.removePlugin("login-journey"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + } finally { + await client.close(); + await rm(directory, { recursive: true, force: true }); + } + }; + try { + await setReport(initialReport); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "login-journey", requirements: pluginRequirements }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import {readFile} from "node:fs/promises"; +import {z} from "zod"; +export default function contribute(server) { + server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}), + discover: async () => [{key: "account", input: {}}], + fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")), + }); + return () => {}; +}`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installPluginSource({ source: directory }); + return { setReport, cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} + +export async function openUsage(page: Page) { + await page.goto("/usage"); +} + +export async function refreshLoginUsage(page: Page) { + await page.getByRole("button", { name: "Refresh Claude", exact: true }).click(); +} + +export async function hoverUsageWindow(page: Page, window: string) { + await page + .getByRole("checkbox", { name: new RegExp(`^Pin Claude ${window}, `) }) + .getByTestId("usage-pin-glyph-unpinned") + .hover(); +} diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index 4c853045fc..b379c27263 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -165,8 +165,13 @@ export async function installUsageReportsFixture( providerId: entry.sourceId, displayName: entry.sourceLabel, fetchedAt: entry.fetchedAt, - ...entry.report, - planLabel: entry.report.planLabel ?? null, + status: entry.report.status, + windows: entry.report.status === "available" ? entry.report.windows : [], + balances: entry.report.status === "available" ? entry.report.balances : undefined, + details: entry.report.status === "available" ? entry.report.details : undefined, + planLabel: + entry.report.status === "available" ? (entry.report.planLabel ?? null) : null, + error: entry.report.status === "error" ? entry.report.error : null, })), }, } diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 7f73ec7897..d85ab3724f 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -36,8 +36,7 @@ import { z } from "zod"; export default function contribute(server) { server.registerUsageSource({ id: "tall-usage", label: "Scrolling account", input: z.object({}), - discover: async () => [{}], - identify: async () => ({ key: "scrolling-account" }), + discover: async () => [{key: "scrolling-account", input: {}}], fetch: async () => ({ status: "available", windows: Array.from({ length: 20 }, (_, i) => ({ id: String(i), label: "Window " + (i + 1), usedPct: 25, })) }), diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index 5175c76224..8260273e06 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -27,6 +27,19 @@ function statusText(report: UsageReport): string | null { return report.status === "error" ? "Error" : "Unavailable"; } +function reportContent(report: UsageReport) { + if (report.status === "available") + return { + windows: report.windows, + balances: report.balances ?? [], + details: report.details ?? [], + message: undefined, + }; + const message = + report.status === "unavailable" ? usageCopy.problem(report.problem) : report.error; + return { windows: [], balances: [], details: [], message }; +} + const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -48,8 +61,7 @@ export function UsageCard({ const usage = entry.report; const status = statusText(usage); const footer = entry.account.label ?? null; - const balances = usage.balances ?? []; - const details = usage.details ?? []; + const { windows, balances, details, message } = reportContent(usage); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -71,7 +83,9 @@ export function UsageCard({ {entry.sourceLabel} - {usage.planLabel ? : null} + {usage.status === "available" && usage.planLabel ? ( + + ) : null} {status ? ( @@ -88,15 +102,15 @@ export function UsageCard({ /> - {usage.error ? ( + {message ? ( - {usage.error} + {message} ) : null} - {usage.windows.length > 0 || balances.length > 0 ? ( + {windows.length > 0 || balances.length > 0 ? ( - {usage.windows.map((window) => ( + {windows.map((window) => ( ))} {balances.map((balance) => ( diff --git a/packages/app/src/usage/copy.test.ts b/packages/app/src/usage/copy.test.ts new file mode 100644 index 0000000000..a5176eba93 --- /dev/null +++ b/packages/app/src/usage/copy.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; +import { usageCopy } from "./copy"; + +test.each([ + [ + { kind: "expired", expiresAt: "2026-10-01T09:00:00Z", refreshedBy: "claude" }, + "Login expired 3h ago. Run claude to refresh it.", + ], + [{ kind: "expired", expiresAt: "2026-10-01T09:00:00Z" }, "Login expired 3h ago. Sign in again."], + [ + { kind: "rejected", status: 403, refreshedBy: "codex" }, + "Login rejected (HTTP 403). Run codex to refresh it.", + ], + [{ kind: "rejected", status: 401 }, "Login rejected (HTTP 401). Sign in again."], + [{ kind: "no_quota", detail: "No active coding plan" }, "No active coding plan"], +] as const)("problem %j", (problem, sentence) => { + expect(usageCopy.problem(problem, new Date("2026-10-01T12:00:00Z"))).toBe(sentence); +}); diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index 731450f30d..92721b69bd 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -1,6 +1,18 @@ +import type { UsageProblem } from "@getpaseo/protocol/messages"; +import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time"; + // User-facing copy for the usage surfaces, kept in one file so localization is a // single-file change. export const usageCopy = { + problem: (problem: UsageProblem, now: Date = new Date()): string => { + if (problem.kind === "no_quota") return problem.detail; + const remedy = problem.refreshedBy + ? `Run ${problem.refreshedBy} to refresh it.` + : "Sign in again."; + if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`; + const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now)); + return `Login expired ${ago}. ${remedy}`; + }, title: "Usage", planUsage: "Plan usage", options: "Usage options", diff --git a/packages/app/src/usage/pinned.test.ts b/packages/app/src/usage/pinned.test.ts index 24ba59be7a..c73a8ddc34 100644 --- a/packages/app/src/usage/pinned.test.ts +++ b/packages/app/src/usage/pinned.test.ts @@ -255,3 +255,18 @@ describe("resolvePinnedUsage", () => { ]); }); }); + +it("keeps unavailable and error reports out of the sidebar summary", () => { + const expired: UsageReportEntry = { + ...claude, + report: { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-10-01T00:00:00.000Z", refreshedBy: "claude" }, + }, + }; + const failed: UsageReportEntry = { + ...codex, + report: { status: "error", error: "Store deleted" }, + }; + expect(resolvePinnedUsage([expired, failed], preferences([]))).toEqual([]); +}); diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts index 003d56d4e3..f05f64cdbc 100644 --- a/packages/app/src/usage/pinned.ts +++ b/packages/app/src/usage/pinned.ts @@ -66,6 +66,7 @@ function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] * or its first window with a percent when the source marks none. */ function summaryWindows(entry: UsageReportEntry, preferences: UsagePreferences): UsageWindow[] { + if (entry.report.status !== "available") return []; const withPercent = entry.report.windows.filter( (window) => displayPercent(window, preferences.displayAs) !== null, ); diff --git a/packages/app/src/usage/types.ts b/packages/app/src/usage/types.ts index 2e2485b2d5..58b8e31c98 100644 --- a/packages/app/src/usage/types.ts +++ b/packages/app/src/usage/types.ts @@ -3,8 +3,9 @@ import type { ProviderUsageTone, UsageReportEntry } from "@getpaseo/protocol/mes export type { UsageReportEntry }; export type UsageReport = UsageReportEntry["report"]; export type UsageTone = ProviderUsageTone; -export type UsageWindow = UsageReport["windows"][number]; -export type UsageBalance = NonNullable[number]; +export type AvailableUsageReport = Extract; +export type UsageWindow = AvailableUsageReport["windows"][number]; +export type UsageBalance = NonNullable[number]; export type UsageBalanceUnit = UsageBalance["unit"]; /** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */ diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 4412836cc4..7ad06e423a 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,6 +1,11 @@ +import { Pin } from "lucide-react-native"; import { useMemo } from "react"; import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { usageCopy } from "./copy"; import { formatDisplayPct, formatResetLabel } from "./format"; import { UsageMeter } from "./meter"; import { displayPercent, usageWindowRowLabel } from "./model"; @@ -31,6 +36,7 @@ export function UsageWindowBar({ pinLabel: string; pinTestID: string; }) { + const isCompact = useIsCompactFormFactor(); const shownPct = displayPercent(window, displayAs); const tone = windowTone(window); @@ -63,6 +69,8 @@ export function UsageWindowBar({ isAtRisk={isAtRisk} percent={shownPct ?? 0} tone={tone} + pinVisible={Boolean(hovered) || isNative || isCompact} + pinned={pinned} /> )} @@ -77,6 +85,8 @@ function WindowRowContent({ isAtRisk, percent, tone, + pinVisible, + pinned, }: { highlight: StyleProp; label: string; @@ -85,27 +95,66 @@ function WindowRowContent({ isAtRisk: boolean; percent: number; tone: UsageTone; + pinVisible: boolean; + pinned: boolean; }) { return ( <> - - - {label} - - - {value} - {trailing ? ( - {` · ${trailing}`} - ) : null} - + + + + + {label} + + + {value} + {trailing ? ( + {` · ${trailing}`} + ) : null} + + + + + - ); } +const ThemedPin = withUnistyles(Pin); + +function UsagePinGlyph({ visible, pinned }: { visible: boolean; pinned: boolean }) { + const iconMapping = useMemo( + () => (theme: { colors: { foregroundMuted: string } }) => ({ + color: theme.colors.foregroundMuted, + fill: pinned ? theme.colors.foregroundMuted : "none", + }), + [pinned], + ); + return ( + + + + + + + + {usageCopy.pin} + + + ); +} + const styles = StyleSheet.create((theme) => ({ + contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + windowContent: { flex: 1, gap: 3 }, + pin: { width: 12, alignItems: "center" }, + pinHidden: { width: 12, alignItems: "center", opacity: 0 }, + tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm }, row: { gap: 3, // The highlight bleeds into the card padding so the label and bar stay on the card's rail. diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 3554fb8a0e..9870db034d 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1,4 +1,3 @@ -import { legacyUsageIcon } from "./legacy-usage-icons.js"; import { readFileSync } from "node:fs"; import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; import { z } from "zod"; @@ -6575,138 +6574,84 @@ test("sends provider.usage.list.request and resolves provider.usage.list.respons }); }); -test.each(["claude", "codex", "copilot", "cursor", "zai", "grok", "kimi", "minimax"])( - "released-host %s icon matches its source plugin", - (sourceId) => { - const icon = readFileSync( - new URL(`../../../plugins/${sourceId}-usage-source/icon.svg`, import.meta.url), - "utf8", - ); - expect(legacyUsageIcon(sourceId)).toBe(icon); - }, -); - -test("unknown released-host sources have no icon", () => { - expect(legacyUsageIcon("unknown")).toBeUndefined(); - expect(legacyUsageIcon("toString")).toBeUndefined(); -}); - -test("maps released-host usage reports and filters report IDs", async () => { - const mock = createMockTransport(); - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger: createMockLogger(), - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - const connected = client.connect(); - mock.triggerOpen({ features: { providerUsageList: true } }); - await connected; - const providers = [ - { - providerId: "claude", - displayName: "Claude", +test.each([ + { + status: "available", + report: { status: "available", - planLabel: null, - fetchedAt: null, - windows: [{ id: "session", label: "Session", usedPct: 25 }], - error: null, - }, - { - providerId: "codex", - displayName: "Codex", - status: "error", - planLabel: "Pro", - fetchedAt: "2026-09-29T00:00:00.000Z", windows: [], - balances: [], - details: [], - error: "unavailable", + balances: undefined, + details: undefined, + planLabel: undefined, }, - ]; - const extraProviders = ["copilot", "cursor", "zai", "grok", "kimi", "minimax", "unknown"].map( - (providerId) => Object.assign({}, providers[0]!, { providerId, displayName: providerId }), - ); - providers.push(...extraProviders); - const reports = [ - { - id: "claude", - sourceId: "claude", - sourceLabel: "Claude", - icon: readFileSync( - new URL("../../../plugins/claude-usage-source/icon.svg", import.meta.url), - "utf8", - ), - account: {}, - fetchedAt: "2026-09-30T00:00:00.000Z", - report: { - status: "available", - planLabel: undefined, - windows: providers[0]!.windows, - balances: undefined, - details: undefined, - error: undefined, - }, - }, - { - id: "codex", - sourceId: "codex", - sourceLabel: "Codex", - icon: readFileSync( - new URL("../../../plugins/codex-usage-source/icon.svg", import.meta.url), - "utf8", - ), - account: {}, - fetchedAt: "2026-09-29T00:00:00.000Z", - report: { - status: "error", - planLabel: "Pro", - windows: [], - balances: [], - details: [], - error: "unavailable", - }, - }, - ]; - reports.push( - ...extraProviders.map((provider) => - Object.assign({}, reports[0]!, { - id: provider.providerId, - sourceId: provider.providerId, - sourceLabel: provider.displayName, - icon: legacyUsageIcon(provider.providerId), - }), - ), - ); - for (const reportIds of [undefined, ["codex", "missing"], []]) { + }, + { status: "error", report: { status: "error", error: "" } }, + { + status: "unavailable", + report: { status: "unavailable", problem: { kind: "no_quota", detail: "" } }, + }, +] as const)( + "maps released-host $status usage and filters report IDs", + async ({ status, report }) => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ features: { providerUsageList: true } }); + await connected; const result = client.listUsageReports({ requestId: "legacy-usage", - reportIds, + reportIds: ["claude"], forceRefresh: true, }); - expect(parseSentFrame(mock.sent.at(-1))).toEqual({ + expect(parseSentFrame(mock.sent[0])).toEqual({ type: "provider.usage.list.request", requestId: "legacy-usage", }); + const provider = { + providerId: "claude", + displayName: "Claude", + status, + windows: [], + planLabel: null, + fetchedAt: null, + error: null, + }; mock.triggerMessage( wrapSessionMessage({ type: "provider.usage.list.response", payload: { requestId: "legacy-usage", fetchedAt: "2026-09-30T00:00:00.000Z", - providers, + providers: [provider, { ...provider, providerId: "codex" }], }, }), ); expect(await result).toStrictEqual({ requestId: "legacy-usage", - reports: - reportIds === undefined ? reports : reports.filter((entry) => reportIds.includes(entry.id)), + reports: [ + { + id: "claude", + sourceId: "claude", + sourceLabel: "Claude", + icon: readFileSync( + new URL("../../../plugins/claude-usage-source/icon.svg", import.meta.url), + "utf8", + ), + account: {}, + fetchedAt: "2026-09-30T00:00:00.000Z", + report, + }, + ], }); - } -}); + }, +); test("sends close_items_request and resolves close_items_response", async () => { const logger = createMockLogger(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 145aad7447..ab5427fe31 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1187,8 +1187,8 @@ interface PingProbe { drivesLivenessFailure: boolean; } +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. export function supportsUsageReports(features: ServerInfoStatusPayload["features"]): boolean { - // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. return features?.usageSources === true || features?.providerUsageList === true; } @@ -5278,22 +5278,36 @@ export class DaemonClient { .filter( (provider) => !options?.reportIds || options.reportIds.includes(provider.providerId), ) - .map((provider) => ({ - id: provider.providerId, - sourceId: provider.providerId, - sourceLabel: provider.displayName, - icon: legacyUsageIcon(provider.providerId), - account: {}, - fetchedAt: provider.fetchedAt ?? payload.fetchedAt, - report: { - status: provider.status, - windows: provider.windows, - balances: provider.balances ?? undefined, - details: provider.details ?? undefined, - planLabel: provider.planLabel ?? undefined, - error: provider.error ?? undefined, - }, - })), + .map((provider) => { + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. + // 0.10 reports have no typed problems; preserve their unavailable badge and error text. + let report: UsageListReportsPayload["reports"][number]["report"]; + if (provider.status === "available") { + report = { + status: "available", + windows: provider.windows, + balances: provider.balances ?? undefined, + details: provider.details ?? undefined, + planLabel: provider.planLabel ?? undefined, + }; + } else if (provider.status === "error") { + report = { status: "error", error: provider.error ?? "" }; + } else { + report = { + status: "unavailable", + problem: { kind: "no_quota", detail: provider.error ?? "" }, + }; + } + return { + id: provider.providerId, + sourceId: provider.providerId, + sourceLabel: provider.displayName, + icon: legacyUsageIcon(provider.providerId), + account: {}, + fetchedAt: provider.fetchedAt ?? payload.fetchedAt, + report, + }; + }), }; } return this.sendNamespacedCorrelatedSessionRequest({ diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index 11906dd955..ebf00c3b2f 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -38,13 +38,28 @@ export interface UsageDetail { tone?: UsageWindow["tone"]; } -export interface UsageReport { - status: "available" | "unavailable" | "error"; - planLabel?: string; - windows: UsageWindow[]; - balances?: UsageBalance[]; - details?: UsageDetail[]; - error?: string; +export type UsageProblem = + | { kind: "expired"; expiresAt: string; refreshedBy?: string } + | { kind: "rejected"; status: number; refreshedBy?: string } + | { kind: "no_quota"; detail: string }; + +export type UsageReport = + | { + status: "available"; + planLabel?: string; + windows: UsageWindow[]; + balances?: UsageBalance[]; + details?: UsageDetail[]; + } + | { status: "unavailable"; problem: UsageProblem } + | { status: "error"; error: string }; + +export interface UsageAccount { + /** Stable across token rotation; [A-Za-z0-9._-]{1,128}. Never a credential or raw email. */ + key: string; + label?: string; + /** Store locator, opaque to the daemon. */ + input: JsonValue; } export interface UsageSourceRegistration { @@ -52,10 +67,10 @@ export interface UsageSourceRegistration { label: string; icon?: string; input: ZodType; - /** Stable account identity, resolved without fetching usage. */ - identify(input: unknown): Promise<{ key: string; label?: string } | null>; + /** Every account whose login exists on this machine. Empty when none. */ + discover(): Promise; + /** Re-reads the login store; never writes it. */ fetch(input: unknown): Promise; - discover(): Promise; } export function windowFromUsedPct(input: { @@ -122,11 +137,6 @@ export function hashAccountKey(value: string): string { return createHash("sha256").update(value).digest("hex"); } -export function unavailableUsage(): UsageReport { - return { - status: "unavailable", - windows: [], - balances: [], - details: [], - }; +export function unavailable(problem: UsageProblem): UsageReport { + return { status: "unavailable", problem }; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 861ddf4a37..a4f883604e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6245,14 +6245,30 @@ export const ProviderUsageListResponseMessageSchema = z.object({ }), }); -export const UsageReportSchema = z.object({ - status: ProviderUsageStatusSchema, - planLabel: z.string().optional(), - windows: z.array(ProviderUsageWindowSchema), - balances: z.array(ProviderUsageBalanceSchema).optional(), - details: z.array(ProviderUsageDetailSchema).optional(), - error: z.string().optional(), -}); +export const UsageProblemSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("expired"), + expiresAt: z.iso.datetime(), + refreshedBy: z.string().optional(), + }), + z.object({ + kind: z.literal("rejected"), + status: z.number().int(), + refreshedBy: z.string().optional(), + }), + z.object({ kind: z.literal("no_quota"), detail: z.string() }), +]); +export const UsageReportSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("available"), + planLabel: z.string().optional(), + windows: z.array(ProviderUsageWindowSchema), + balances: z.array(ProviderUsageBalanceSchema).optional(), + details: z.array(ProviderUsageDetailSchema).optional(), + }), + z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), + z.object({ status: z.literal("error"), error: z.string() }), +]); export const UsageReportEntrySchema = z.object({ id: z.string(), account: z.object({ label: z.string().optional() }), @@ -7131,6 +7147,7 @@ export type ProviderDiagnosticResponseMessage = z.infer< typeof ProviderDiagnosticResponseMessageSchema >; export type ProviderUsageTone = z.infer; +export type UsageProblem = z.infer; export type UsageReport = z.infer; export type UsageReportEntry = z.infer; export type UsageListReportsResponseMessage = z.infer; diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index edf88de4ca..167216dbc1 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -40,7 +40,6 @@ interface PluginRuntimePort { clearLogs(pluginId: string): void; getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[]; getUsageSourceRegistrations(pluginId: string): readonly PluginUsageSourceMetadata[]; - identifyUsage: PluginRuntime["identifyUsage"]; fetchUsage: PluginRuntime["fetchUsage"]; discoverUsage: PluginRuntime["discoverUsage"]; connectProvider: PluginRuntime["connectProvider"]; @@ -80,7 +79,7 @@ export class PluginService { private readonly errors = new Map(); private readonly listeners = new Set<(pluginId: string) => void>(); private readonly providers = new Map(); - private readonly usageSources = new UsageSourceRegistry(); + private readonly usageSources: UsageSourceRegistry; private readonly usageSourceIdsByPlugin = new Map(); private readonly providerIdsByPlugin = new Map(); private readonly providerListeners = new Set<() => void>(); @@ -96,6 +95,7 @@ export class PluginService { private readonly dependencies: PluginServiceDependencies = {}, ) { this.logger = logger.child({ module: "plugin-service" }); + this.usageSources = new UsageSourceRegistry(Date.now, 300_000, this.logger); this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion, { @@ -574,11 +574,6 @@ export class PluginService { throw new Error(`Invalid usage discovery from ${source.id}`); return result; }, - identify: (input) => - this.runtime.identifyUsage(pluginId, source.id, input) as Promise<{ - key: string; - label?: string; - } | null>, fetch: (input) => { return this.runtime.fetchUsage(pluginId, source.id, input); }, diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 6ae2cf140f..9b543b2a75 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -51,7 +51,6 @@ export type PluginProcessRequest = } | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } | { type: "hook.cancel"; requestId: string } - | { type: "usage.identify"; requestId: string; sourceId: string; input: unknown } | { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown } | { type: "usage.discover"; requestId: string; sourceId: string } | { type: "invoke"; requestId: string; method: string; input: unknown } @@ -191,14 +190,6 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis }) .strict(), z.object({ type: z.literal("hook.cancel"), requestId: z.string() }).strict(), - z - .object({ - type: z.literal("usage.identify"), - requestId: z.string(), - sourceId: z.string(), - input: z.unknown(), - }) - .strict(), z .object({ type: z.literal("usage.fetch"), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index ee1d17310a..93c9a3d922 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -339,21 +339,14 @@ export function createPluginWorker(options: { } function handleUsageRequest( - message: Extract< - PluginProcessRequest, - { type: "usage.identify" | "usage.fetch" | "usage.discover" } - >, + message: Extract, ): void { void (async () => { const source = usageSources.get(message.sourceId); if (!source) throw new Error(`Unknown usage source: ${message.sourceId}`); if (message.type === "usage.discover") return jsonTransportValue(await source.discover()); const input = await source.input.parseAsync(message.input); - return jsonTransportValue( - message.type === "usage.identify" - ? await source.identify(input) - : await source.fetch(input), - ); + return jsonTransportValue(await source.fetch(input)); })().then( (output) => send({ type: "result", requestId: message.requestId, output }), (error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }), @@ -364,7 +357,6 @@ export function createPluginWorker(options: { if ( message.type === "provider.status" || message.type === "provider.catalog_key" || - message.type === "usage.identify" || message.type === "usage.fetch" || message.type === "usage.discover" ) { @@ -453,11 +445,7 @@ export function createPluginWorker(options: { ); return; } - if ( - message.type === "usage.identify" || - message.type === "usage.fetch" || - message.type === "usage.discover" - ) { + if (message.type === "usage.fetch" || message.type === "usage.discover") { handleUsageRequest(message); return; } diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 5615bef6ac..8dd80ce499 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -2040,7 +2040,6 @@ export default function contribute(server) { id: "missing-discovery", label: "Missing discovery", input: { parseAsync: async (value) => value }, - identify: async () => ({ key: "account" }), fetch: async () => ({ status: "available", windows: [] }), }); return () => {}; diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 9db0853718..a26e7cbfea 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -395,17 +395,6 @@ export class PluginRuntime { return this.plugins.get(pluginId)?.usageSources ?? []; } - identifyUsage(pluginId: string, sourceId: string, input: unknown): Promise { - const loaded = this.plugins.get(pluginId); - if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); - return this.request(loaded, { - type: "usage.identify", - requestId: randomUUID(), - sourceId, - input, - }); - } - fetchUsage(pluginId: string, sourceId: string, input: unknown): Promise { const loaded = this.plugins.get(pluginId); if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts index 5845073a7c..0663e0070d 100644 --- a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts +++ b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts @@ -13,12 +13,21 @@ export default function contribute(server: PluginServerContext) { { account: "one" }, { account: "bad", extra: true }, { account: "throws" }, + { account: "expired" }, ]; - return inputs; + return inputs.map((input) => ({ key: String(input.account), input })); }, - identify: async (input) => ({ key: (input as { account: string }).account }), fetch: async (input) => { const account = (input as { account: string }).account; + if (account === "expired") + return { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 3_600_000).toISOString(), + refreshedBy: "claude", + }, + }; if (account === "throws") throw new Error("fixture failure"); fetches++; return { diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts index 347e8050d0..12d7ced848 100644 --- a/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts +++ b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts @@ -13,12 +13,21 @@ export default function contribute(server: PluginServerContext) { { account: "one" }, { account: "bad", extra: true }, { account: "throws" }, + { account: "expired" }, ]; - return inputs; + return inputs.map((input) => ({ key: String(input.account), input })); }, - identify: async (input) => ({ key: (input as { account: string }).account }), fetch: async (input) => { const account = (input as { account: string }).account; + if (account === "expired") + return { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 3_600_000).toISOString(), + refreshedBy: "claude", + }, + }; if (account === "throws") throw new Error("fixture failure"); fetches++; return { diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts index df81a8fbb2..0b2b3ca9a2 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -22,7 +22,7 @@ test("lists built-in and subprocess usage; validates input and isolates fetch er try { await client.connect(); const first = await client.listUsageReports(); - expect(first.reports).toHaveLength(3); + expect(first.reports).toHaveLength(4); expect(first.reports[0]?.icon).toContain(" entry.id === "fixture:expired"); + expect(expired?.report).toEqual({ + status: "unavailable", + problem: { kind: "expired", expiresAt: expect.any(String), refreshedBy: "claude" }, + }); + const legacy = await client.listProviderUsage(); + expect(legacy.providers.find((provider) => provider.displayName === "Fixture")?.status).toBe( + "available", + ); + expect(legacy.providers.find((provider) => provider.status === "unavailable")?.error).toBe( + "Login expired 1h ago. Run claude to refresh it.", + ); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(subprocessDirectory, "fixture-directory"); const both = await client.listUsageReports({ forceRefresh: true }); - expect(both.reports).toHaveLength(6); + expect(both.reports).toHaveLength(8); await client.patchDaemonConfig({ pluginsEnabled: false }); - await expect.poll(async () => (await client.listUsageReports()).reports.length).toBe(3); + await expect.poll(async () => (await client.listUsageReports()).reports.length).toBe(4); } finally { await client.close(); await daemon.close(); @@ -81,8 +93,7 @@ import { account } from './server/account.js'; export default function contribute(server) { server.registerUsageSource({ id: 'fixture', label: 'Fixture', input: z.object({}), - discover: async () => [{}], - identify: async () => account, + discover: async () => [{...account, input: {}}], fetch: async () => ({ status: 'available', windows: [{ id: 'session', label: 'Session', usedPct: 37 }] }), }); return () => {}; @@ -183,16 +194,12 @@ test("built-in sources deduplicate cross-harness accounts and fall back without await cp(path.join(resolveBuiltinPluginsRoot(), `${source}-usage-source`), directory, { recursive: true, }); - const identifyCall = - source === "codex" - ? "identify(inputSchema.parse(input), lookup)" - : "identify(inputSchema.parse(input), fetchApi, Date.now, lookup)"; // Inject home/env/HTTP at the source boundary; compile and register the real readers. await writeFile( path.join(directory, "index.server.ts"), ` import { inputSchema } from './shared/input.js'; -import { discover, identify, fetchUsage } from './server/usage.js'; +import { discover, fetchUsage } from './server/usage.js'; const lookup = { home: ${JSON.stringify(home)}, env: ${JSON.stringify(env)}, platform: 'linux' }; async function fetchApi(url, init) { const token = new Headers(init.headers).get('Authorization'); @@ -206,8 +213,7 @@ async function fetchApi(url, init) { } export default function contribute(server) { server.registerUsageSource({ id: '${source}', label: '${source}', input: inputSchema, - discover: () => discover(lookup), - identify: (input) => ${identifyCall}, + discover: () => discover(lookup${source === "claude" ? ", fetchApi" : ""}), fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup), }); return () => {}; @@ -268,3 +274,52 @@ export default function contribute(server) { await rm(root, { recursive: true, force: true }); } }, 60_000); + +test("a killed discovery subprocess logs kimi once and produces no provider card", async () => { + const { readFile } = await import("node:fs/promises"); + const { default: pino } = await import("pino"); + const root = await mkdtemp(path.join(os.tmpdir(), "usage-crash-")); + const log = path.join(root, "daemon.log"); + const destination = pino.destination({ dest: log, sync: true }); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: true, + logger: pino({ level: "warn" }, destination), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await writeFile( + path.join(root, "paseo-plugin.json"), + JSON.stringify({ id: "crashing-usage", requirements: { paseo: ">=0.9.2" } }), + ); + await writeFile( + path.join(root, "index.server.ts"), + ` +import {z} from "zod"; +export default function contribute(server) { + server.registerUsageSource({id: "kimi", label: "Kimi", input: z.object({}), + discover: async () => {process.exit(1);}, + fetch: async () => {throw new Error("discovery must never produce an account");}, + }); + return () => {}; +}`, + ); + await client.connect(); + await client.installDirectoryPlugin(root, "crashing-usage"); + expect((await client.listUsageReports({ forceRefresh: true })).reports).toEqual([]); + const warnings = (await readFile(log, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + .filter((entry) => entry.msg === "Usage source discovery failed"); + expect(warnings.map(({ level, sourceId }) => ({ level, sourceId }))).toEqual([ + { level: 40, sourceId: "kimi" }, + ]); + expect((await client.listUsageReports({ forceRefresh: true })).reports).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + destination.end(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 64fd87c566..17ad174a29 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -3,17 +3,13 @@ import { UsageSourceRegistry } from "./index.js"; function source(input: { id: string; - discover?: () => Promise; - identify?: (value: unknown) => Promise<{ key: string; label?: string } | null>; + discover?: () => Promise>; fetch?: (value: unknown) => Promise; }) { return { id: input.id, label: input.id, discover: input.discover ?? (async () => []), - identify: - input.identify ?? - (async (value: unknown) => ({ key: (value as { account: string }).account })), fetch: input.fetch ?? (async () => ({ status: "available", windows: [] })), }; } @@ -24,7 +20,7 @@ test("discovery preserves account IDs and updates input after token rotation", a registry.register( source({ id: "codex", - discover: async () => [{ account: "work", token }], + discover: async () => [{ key: "work", input: { token } }], fetch: async (input) => ({ status: "available", windows: [{ id: "token", label: (input as { token: string }).token }], @@ -47,7 +43,7 @@ test("coalesces per account, caches errors, and refreshes only requested IDs", a registry.register( source({ id: "source", - discover: async () => [{ account: "a" }, { account: "b" }], + discover: async () => ["a", "b"].map((account) => ({ key: account, input: { account } })), fetch: async (input) => { const account = (input as { account: string }).account; counts.set(account, (counts.get(account) ?? 0) + 1); @@ -76,28 +72,14 @@ test("coalesces per account, caches errors, and refreshes only requested IDs", a expect(counts.get("a")).toBe(3); }); -test("invalid keys become source errors and missing identities produce no report", async () => { - const registry = new UsageSourceRegistry(); - registry.register( - source({ - id: "source", - discover: async () => [{ account: "bad:key" }, { account: "none" }], - identify: async (input) => { - const account = (input as { account: string }).account; - return account === "none" ? null : { key: account }; - }, - }), - ); - const reports = await registry.listReports(); - expect(reports.map((entry) => entry.id)).toEqual(["source:!error"]); - expect(reports[0]?.report.status).toBe("error"); -}); - test("legacy listing uses oldest fetchedAt", async () => { let now = 1000; const registry = new UsageSourceRegistry(() => now); registry.register( - source({ id: "source", discover: async () => [{ account: "a" }, { account: "b" }] }), + source({ + id: "source", + discover: async () => ["a", "b"].map((account) => ({ key: account, input: { account } })), + }), ); await registry.listReports(); now = 2000; @@ -115,7 +97,7 @@ test("concurrent requests for the same ID share one vendor fetch", async () => { registry.register( source({ id: "coalesced", - discover: async () => [{ account: "one" }], + discover: async () => [{ key: "one", input: {} }], fetch: async () => { fetches++; return response; @@ -130,49 +112,38 @@ test("concurrent requests for the same ID share one vendor fetch", async () => { expect(fetches).toBe(1); }); -test("source failure IDs cannot collide with an account named error", async () => { - const registry = new UsageSourceRegistry(); - registry.register( - source({ - id: "source", - discover: async () => [{ account: "error" }, { account: "bad:key" }], - }), - ); - const reports = await registry.listReports(); - expect(reports).toHaveLength(2); - expect(reports.find((entry) => entry.id === "source:error")?.report.status).toBe("available"); - expect(reports.find((entry) => entry.report.status === "error")?.id).toMatch( - /^source:[^A-Za-z0-9._-]/, - ); -}); - -test("expired cached entries are pruned when a new report is written", async () => { +test("rediscovery removes an old account from targeted refreshes", async () => { let now = 0; const registry = new UsageSourceRegistry(() => now, 100); let account = "old"; - registry.register(source({ id: "source", discover: async () => [{ account }] })); + registry.register(source({ id: "source", discover: async () => [{ key: account, input: {} }] })); await registry.listReports(); await registry.listReports({ reportIds: ["source:old"] }); now = 101; account = "new"; await registry.listReports(); await registry.listReports({ reportIds: ["source:new"] }); - const cache = Reflect.get(registry, "cache") as Map; - expect([...cache.keys()]).toEqual(["source:new"]); + expect(await registry.listReports({ reportIds: ["source:old"] })).toEqual([]); }); -test("discovery failures have an ID outside the account namespace", async () => { - const registry = new UsageSourceRegistry(); +test("discovery failures log once and produce no card", async () => { + const warnings: unknown[][] = []; + const registry = new UsageSourceRegistry(Date.now, 300_000, { + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }); + const err = new Error("subprocess stopped"); registry.register( source({ - id: "source", + id: "kimi", discover: async () => { - throw new Error("discovery failed"); + throw err; }, }), ); - const discovered = await registry.listReports(); - expect(discovered.map((entry) => entry.id)).toEqual(["source:!error"]); + expect(await registry.listReports()).toEqual([]); + expect(warnings).toEqual([[{ sourceId: "kimi", err }, "Usage source discovery failed"]]); }); test("legacy listing distinguishes labeled accounts and preserves unlabeled names", async () => { @@ -180,11 +151,12 @@ test("legacy listing distinguishes labeled accounts and preserves unlabeled name registry.register( source({ id: "claude", - discover: async () => [{ account: "work" }, { account: "personal" }, { account: "default" }], - identify: async (input) => { - const { account } = input as { account: string }; - return { key: account, label: account === "default" ? undefined : account }; - }, + discover: async () => + ["work", "personal", "default"].map((key) => ({ + key, + label: key === "default" ? undefined : key, + input: {}, + })), }), ); expect( @@ -199,16 +171,14 @@ test("duplicate logins follow discovery order and fall back on unavailable, erro registry.register( source({ id: "codex", - discover: async () => [0, 1, 2], - identify: async (input) => { - if (input === 0) await new Promise((resolve) => setTimeout(resolve, 10)); - return { key: "same" }; - }, + discover: async () => [0, 1, 2].map((input) => ({ key: "same", input })), fetch: async (input) => { calls.push(Number(input)); if (input === 0) { if (failure === "throw") throw new Error("revoked"); - return { status: failure, windows: [] }; + return failure === "unavailable" + ? { status: failure, problem: { kind: "rejected", status: 401 } } + : { status: failure, error: "revoked" }; } return { status: "available", @@ -224,56 +194,88 @@ test("duplicate logins follow discovery order and fall back on unavailable, erro } }); -test("targeted refresh skips a login that changed accounts and preserves fallback", async () => { - let firstAccount = "original"; - const fetched: unknown[] = []; +test("targeted refresh re-reads the login without discovering again", async () => { + let discoveries = 0; + let value = "old"; const registry = new UsageSourceRegistry(); registry.register( source({ id: "source", - discover: async () => [0, 1], - identify: async (input) => ({ key: input === 0 ? firstAccount : "original" }), - fetch: async (input) => { - fetched.push(input); - return { status: "available", windows: [] }; + discover: async () => { + discoveries++; + return [{ key: "original", input: {} }]; }, + fetch: async () => ({ status: "available", windows: [{ id: "session", label: value }] }), }), ); await registry.listReports(); - firstAccount = "different"; + value = "new"; const reports = await registry.listReports({ reportIds: ["source:original"], forceRefresh: true, }); - expect(reports[0]?.id).toBe("source:original"); - expect(reports[0]?.report.status).toBe("available"); - expect(fetched).toEqual([0, 1]); + expect(reports[0]?.report).toEqual({ + status: "available", + windows: [{ id: "session", label: "new" }], + }); + expect(discoveries).toBe(1); }); -test("discovery identifies a source's logins concurrently and keeps their order", async () => { +test("a thrown fetch is an error card", async () => { const registry = new UsageSourceRegistry(); - const fetched: unknown[] = []; - let release!: () => void; - const allStarted = new Promise((resolve) => (release = resolve)); - let started = 0; registry.register( source({ - id: "claude", - discover: async () => [0, 1, 2], - // Each identification waits until all three started; serial identification never finishes. - identify: async (input) => { - if (++started === 3) release(); - await allStarted; - if (input === 0) await new Promise((resolve) => setTimeout(resolve, 10)); - return { key: "same" }; - }, - fetch: async (input) => { - fetched.push(input); - return { status: "unavailable", windows: [] }; + id: "source", + discover: async () => [{ key: "one", input: {} }], + fetch: async () => { + throw new Error("Store deleted"); }, }), ); - const reports = await registry.listReports(); - expect(reports.map((entry) => entry.id)).toEqual(["claude:same"]); - expect(fetched).toEqual([0, 1, 2]); + expect((await registry.listReports())[0]?.report).toEqual({ + status: "error", + error: "Store deleted", + }); +}); + +test("an unavailable login falls back to the next available login", async () => { + const calls: unknown[] = []; + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => [1, 2].map((input) => ({ key: "same", input })), + fetch: async (input) => { + calls.push(input); + return input === 1 + ? { + status: "unavailable", + problem: { kind: "expired", expiresAt: "1970-01-01T00:00:00.000Z" }, + } + : { status: "available", windows: [] }; + }, + }), + ); + expect( + (await registry.listReports()).map((entry) => ({ id: entry.id, report: entry.report })), + ).toEqual([{ id: "source:same", report: { status: "available", windows: [] } }]); + expect(calls).toEqual([1, 2]); +}); + +test("failed fallbacks preserve the final problem", async () => { + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => [1, 2].map((input) => ({ key: "same", input })), + fetch: async (input) => ({ + status: "unavailable", + problem: { kind: "rejected", status: input === 1 ? 401 : 403 }, + }), + }), + ); + expect((await registry.listReports())[0]?.report).toEqual({ + status: "unavailable", + problem: { kind: "rejected", status: 403 }, + }); }); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index bdc5c344d3..2d31798fbd 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -1,21 +1,24 @@ +import { z } from "zod"; +import type { Logger } from "pino"; import { UsageReportSchema, type ProviderUsage, type UsageReportEntry, + type UsageProblem, + type UsageReport, } from "@getpaseo/protocol/messages"; export interface UsageSource { id: string; label: string; icon?: string; - discover(): Promise; - identify(input: unknown): Promise<{ key: string; label?: string } | null>; + discover(): Promise; fetch(input: unknown): Promise; } interface KnownReport { source: UsageSource; - inputs: unknown[]; + inputs: [unknown, ...unknown[]]; label?: string; } @@ -29,6 +32,7 @@ export class UsageSourceRegistry { constructor( private readonly now: () => number = Date.now, private readonly ttlMs = 300_000, + private readonly logger: Pick = { warn: console.warn }, ) {} register(source: UsageSource): void { @@ -42,36 +46,15 @@ export class UsageSourceRegistry { for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); } - private async identify( - source: UsageSource, - input: unknown, - ): Promise<{ id: string; label?: string; error?: true } | null> { - try { - const account = await source.identify(input); - if (account === null) return null; - if ( - typeof account !== "object" || - (account.label !== undefined && typeof account.label !== "string") - ) - throw new Error(`Invalid account identity from ${source.id}`); - if (typeof account.key !== "string" || !/^[A-Za-z0-9._-]{1,128}$/.test(account.key)) - throw new Error(`Invalid account key from ${source.id}`); - return { id: `${source.id}:${account.key}`, label: account.label }; - } catch (error) { - const id = `${source.id}:!error`; - this.writeCache(id, this.errorEntry(source, id, error)); - return { id, error: true }; - } - } - async listReports( options: { forceRefresh?: boolean; reportIds?: string[] } = {}, ): Promise { const ids = options.reportIds ?? (await this.discoverReportIds()); return Promise.all( - [...new Set(ids)] - .filter((id) => this.known.has(id) || this.cache.has(id)) - .map((id) => this.fetchId(id, options.forceRefresh)), + [...new Set(ids)].flatMap((id) => { + const known = this.known.get(id); + return known ? [this.fetchId(id, known, options.forceRefresh)] : []; + }), ); } @@ -79,34 +62,35 @@ export class UsageSourceRegistry { const discovered = await Promise.all( [...this.sources.values()].map(async (source) => { try { - const inputs = await source.discover(); - if (!Array.isArray(inputs)) throw new Error("Usage discovery must return an array"); - const accounts = await Promise.all(inputs.map((input) => this.identify(source, input))); - // Group in discovery order so an account's logins keep the source's preference. + const accounts = z + .array( + z.object({ + key: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), + label: z.string().optional(), + input: z.json(), + }), + ) + .parse(await source.discover()); const knownReports = new Map(); const ids: string[] = []; - accounts.forEach((account, index) => { - if (account === null) return; - ids.push(account.id); - if (account.error) return; - const known = knownReports.get(account.id); - if (known) known.inputs.push(inputs[index]); - else - knownReports.set(account.id, { - source, - inputs: [inputs[index]], - label: account.label, - }); - }); + for (const account of accounts) { + const id = `${source.id}:${account.key}`; + ids.push(id); + const known = knownReports.get(id); + if (known) known.inputs.push(account.input); + else knownReports.set(id, { source, inputs: [account.input], label: account.label }); + } for (const key of this.known.keys()) { if (key.startsWith(`${source.id}:`)) this.known.delete(key); } for (const [id, known] of knownReports) this.known.set(id, known); return ids; } catch (error) { - const id = `${source.id}:!error`; - this.writeCache(id, this.errorEntry(source, id, error)); - return [id]; + for (const key of this.known.keys()) { + if (key.startsWith(`${source.id}:`)) this.known.delete(key); + } + this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); + return []; } }), ); @@ -129,45 +113,31 @@ export class UsageSourceRegistry { ? `${entry.sourceLabel} (${entry.account.label})` : 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, + planLabel: entry.report.status === "available" ? (entry.report.planLabel ?? null) : null, + windows: entry.report.status === "available" ? entry.report.windows : [], + balances: entry.report.status === "available" ? (entry.report.balances ?? []) : [], + details: entry.report.status === "available" ? (entry.report.details ?? []) : [], + error: legacyError(entry.report, this.now()), })), }; } - private fetchId(id: string, forceRefresh = false): Promise { - const known = this.known.get(id); + private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise { const cached = this.cache.get(id); - if (!known) return Promise.resolve(cached!.entry); if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) return Promise.resolve(cached.entry); const pending = this.pending.get(id); if (pending) return pending; const request = (async () => { - let entry: UsageReportEntry = { + const entry: UsageReportEntry = { id, sourceId: known.source.id, sourceLabel: known.source.label, icon: known.source.icon, account: { label: known.label }, fetchedAt: new Date(this.now()).toISOString(), - report: { status: "unavailable", windows: [] }, + report: await this.fetchWithFallback(known), }; - for (const input of known.inputs) { - try { - // A harness can switch accounts between discovery and a targeted refresh. - const account = await known.source.identify(input); - if (!account || `${known.source.id}:${account.key}` !== id) continue; - const report = UsageReportSchema.parse(await known.source.fetch(input)); - entry.report = report; - if (report.status === "available") break; - } catch (error) { - entry = this.errorEntry(known.source, id, error, known.label); - } - } this.writeCache(id, entry); return entry; })(); @@ -186,25 +156,43 @@ export class UsageSourceRegistry { this.cache.set(id, { at, entry }); } - private errorEntry( - source: UsageSource, - id: string, - error: unknown, - label?: string, - fetchedAt = new Date(this.now()).toISOString(), - ): UsageReportEntry { - return { - id, - sourceId: source.id, - sourceLabel: source.label, - icon: source.icon, - account: { label }, - fetchedAt, - report: { - status: "error", - windows: [], - error: error instanceof Error ? error.message : String(error), - }, - }; + private async fetchWithFallback(known: KnownReport): Promise { + let report = await this.fetchLogin(known.source, known.inputs[0]); + for (const input of known.inputs.slice(1)) { + if (report.status === "available") break; + report = await this.fetchLogin(known.source, input); + } + return report; + } + + private async fetchLogin(source: UsageSource, input: unknown): Promise { + try { + return UsageReportSchema.parse(await source.fetch(input)); + } catch (error) { + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } } } + +// The legacy RPC has only an error string; the current app owns its own presentation. +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +function legacyProblem(problem: UsageProblem, now: number): string { + if (problem.kind === "no_quota") return problem.detail; + const remedy = problem.refreshedBy + ? `Run ${problem.refreshedBy} to refresh it.` + : "Sign in again."; + if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`; + const elapsed = Math.max(0, now - Date.parse(problem.expiresAt)); + let ago = "just now"; + if (elapsed >= 86_400_000) ago = `${Math.floor(elapsed / 86_400_000)}d ago`; + else if (elapsed >= 3_600_000) ago = `${Math.floor(elapsed / 3_600_000)}h ago`; + else if (elapsed >= 60_000) ago = `${Math.floor(elapsed / 60_000)}m ago`; + return `Login expired ${ago}. ${remedy}`; +} + +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +function legacyError(report: UsageReport, now: number): string | null { + if (report.status === "error") return report.error; + if (report.status === "unavailable") return legacyProblem(report.problem, now); + return null; +} diff --git a/plugins/claude-usage-source/index.server.ts b/plugins/claude-usage-source/index.server.ts index fae161db58..82bcae58b9 100644 --- a/plugins/claude-usage-source/index.server.ts +++ b/plugins/claude-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { discover, fetchUsage, identify } from "./server/usage.js"; +import { discover, fetchUsage } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -9,7 +9,6 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input: inputSchema, discover: () => discover(), - identify, fetch: fetchUsage, }); return () => {}; diff --git a/plugins/claude-usage-source/server/keychain.test.ts b/plugins/claude-usage-source/server/keychain.test.ts index 8f5ee0bd99..f79153933f 100644 --- a/plugins/claude-usage-source/server/keychain.test.ts +++ b/plugins/claude-usage-source/server/keychain.test.ts @@ -90,11 +90,19 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { discover, fetchUsage } from "./usage.js"; +import { inputSchema } from "../shared/input.js"; function fixtureFetch(expectedToken: string): typeof fetch { return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { expect(init?.headers).toMatchObject({ Authorization: `Bearer ${expectedToken}` }); - return new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { status: 200 }); + return new Response( + JSON.stringify({ + account: { uuid: "fixture-account" }, + organization: { uuid: "fixture-org" }, + five_hour: { utilization: 12 }, + }), + { status: 200 }, + ); }) as unknown as typeof fetch; } @@ -125,30 +133,33 @@ describe("Claude credential routes", () => { join(dir, ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }), ); - const [input] = await discover({ - home: dir, - env: { CLAUDE_CONFIG_DIR: dir }, - platform: "linux", - }); - expect(input).toEqual({ route: { store: "claude", path: join(dir, ".credentials.json") } }); - expect((await fetchUsage(input!, fixtureFetch("fixture-default"))).status).toBe("available"); + const accounts = await discover( + { home: dir, env: { CLAUDE_CONFIG_DIR: dir }, platform: "linux" }, + fixtureFetch("fixture-default"), + ); + expect(accounts).toEqual([ + { + key: "fixture-account.fixture-org", + input: { route: { store: "claude", path: join(dir, ".credentials.json") } }, + }, + ]); + expect( + (await fetchUsage(inputSchema.parse(accounts[0]!.input), fixtureFetch("fixture-default"))) + .status, + ).toBe("available"); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it("stays unavailable when the routed file has no credentials", async () => { + it("throws without fetching when the routed file has no credentials", async () => { const dir = mkdtempSync(join(tmpdir(), "claude-empty-config-")); try { const fetchApi = vi.fn() as unknown as typeof fetch; - expect( - ( - await fetchUsage( - { route: { store: "claude", path: join(dir, ".credentials.json") } }, - fetchApi, - { platform: "linux" }, - ) - ).status, - ).toBe("unavailable"); + await expect( + fetchUsage({ route: { store: "claude", path: join(dir, ".credentials.json") } }, fetchApi, { + platform: "linux", + }), + ).rejects.toThrow("Claude login store no longer exists"); expect(fetchApi).not.toHaveBeenCalled(); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/plugins/claude-usage-source/server/stores.test.ts b/plugins/claude-usage-source/server/stores.test.ts index 7e0392b88b..2f8119f502 100644 --- a/plugins/claude-usage-source/server/stores.test.ts +++ b/plugins/claude-usage-source/server/stores.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; -import { discover, fetchUsage, identify } from "./usage.js"; +import { discover, fetchUsage } from "./usage.js"; let home: string; beforeEach(async () => { @@ -25,8 +25,21 @@ async function profile(_url: RequestInfo | URL, init?: RequestInit) { JSON.stringify({ account: { uuid: "pi-account" }, organization: { uuid: "pi-org" } }), ); } -async function account(input: Parameters[0], env: NodeJS.ProcessEnv = {}) { - return identify(input, profile, () => 1000, lookup(env)); +async function accountIdentity( + input: Parameters[0], + env: NodeJS.ProcessEnv = {}, +) { + const accounts = await discover(lookup(env), profile); + const account = accounts.find( + (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), + ); + if (!account) throw new Error("Expected discovered login"); + return { key: account.key, ...(account.label ? { label: account.label } : {}) }; +} +async function logins(options: Parameters[0]) { + return (await discover(options, profile)).map( + (account) => account.input as Parameters[0], + ); } async function database(path: string) { await mkdir((await import("node:path")).dirname(path), { recursive: true }); @@ -76,9 +89,9 @@ test("Pi uses its own OAuth identity and re-reads rotated tokens without writing await json(join(home, ".claude.json"), { oauthAccount: { accountUuid: "other", organizationUuid: "other-org" }, }); - const inputs = await discover(lookup()); + const inputs = await logins(lookup()); expect(inputs).toHaveLength(1); - expect(await account(inputs[0]!)).toEqual({ key: "pi-account.pi-org" }); + expect(await accountIdentity(inputs[0]!)).toEqual({ key: "pi-account.pi-org" }); await json(path, { anthropic: { type: "oauth", access: "fixture-rotated", accountId: "pi-account" }, }); @@ -103,18 +116,18 @@ test("Pi honors directory override and ignores API keys and malformed files", as const env = { PI_CODING_AGENT_DIR: directory }; const path = join(directory, "auth.json"); await json(path, { anthropic: { type: "oauth", access: "fixture-pi", accountId: "pi-account" } }); - expect(await discover(lookup(env))).toEqual([{ route: { store: "pi", path } }]); + expect(await logins(lookup(env))).toEqual([{ route: { store: "pi", path } }]); await json(path, { anthropic: { type: "api_key", key: "fixture-key" } }); - expect(await discover(lookup(env))).toEqual([]); + expect(await logins(lookup(env))).toEqual([]); await writeFile(path, "broken"); - expect(await discover(lookup(env))).toEqual([]); + expect(await logins(lookup(env))).toEqual([]); }); test("expired Pi tokens are unavailable without calling usage or refreshing", async () => { await json(join(home, ".pi", "agent", "auth.json"), { anthropic: { type: "oauth", access: "fixture-expired", accountId: "pi-account", expires: 500 }, }); - const [input] = await discover(lookup()); + const [input] = await logins(lookup()); expect( ( await fetchUsage( @@ -129,7 +142,7 @@ test("expired Pi tokens are unavailable without calling usage or refreshing", as }); for (const location of ["default", "profile", "xdg", "override", "config"]) { - test(`OMP discovers enabled live OAuth rows in ${location} store read-only`, async () => { + test(`OMP discovers enabled OAuth rows including expired logins in ${location} store read-only`, async () => { let path = join(home, ".omp", "agent", "agent.db"); let env: NodeJS.ProcessEnv = { OMP_AUTH_BROKER_URL: "https://broker.test" }; if (location === "profile") { @@ -151,9 +164,12 @@ for (const location of ["default", "profile", "xdg", "override", "config"]) { } await database(path); const before = await readFile(path); - const inputs = await discover(lookup(env)); - expect(inputs).toEqual([{ route: { store: "omp", path, credentialId: 1 } }]); - expect(await account(inputs[0]!, env)).toEqual({ key: "pi-account.pi-org" }); + const inputs = await logins(lookup(env)); + expect(inputs).toEqual([ + { route: { store: "omp", path, credentialId: 1 } }, + { route: { store: "omp", path, credentialId: 2 } }, + ]); + expect(await accountIdentity(inputs[0]!, env)).toEqual({ key: "pi-account.pi-org" }); expect( ( await fetchUsage( @@ -175,56 +191,52 @@ test("missing SQLite skips only OMP", async () => { await json(join(home, ".pi", "agent", "auth.json"), { anthropic: { type: "oauth", access: "fixture-pi" }, }); - const inputs = await discover({ ...lookup(), sqlite: () => undefined }); + const inputs = await logins({ ...lookup(), sqlite: () => undefined }); expect(inputs).toEqual([ { route: { store: "pi", path: join(home, ".pi", "agent", "auth.json") } }, ]); }); -test("Claude file and keychain are independent discovery candidates", async () => { - const path = join(home, ".claude", ".credentials.json"); - await json(path, { claudeAiOauth: { accessToken: "fixture-file" } }); - const inputs = await discover({ +test("Claude Keychain login replaces the fallback file", async () => { + await json(join(home, ".claude", ".credentials.json"), { + claudeAiOauth: { accessToken: "fixture-file", expiresAt: 500 }, + }); + const inputs = await logins({ ...lookup(), platform: "darwin", readKeychainCredentials: async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } }), }); - expect(inputs).toEqual([{ route: { store: "claude", path } }, { route: { store: "keychain" } }]); + expect(inputs).toEqual([{ route: { store: "keychain" } }]); }); test("Claude account metadata alone does not discover a login", async () => { await json(join(home, ".claude.json"), { oauthAccount: { accountUuid: "old", organizationUuid: "old-org" }, }); - expect(await discover(lookup())).toEqual([]); - expect( - await identify( - { route: { store: "claude", path: join(home, ".claude", ".credentials.json") } }, - profile, - Date.now, - lookup(), - ), - ).toBeNull(); + expect(await logins(lookup())).toEqual([]); }); test("keychain identity belongs to its token even when Claude Code metadata names another account", async () => { await json(join(home, ".claude.json"), { oauthAccount: { accountUuid: "stale", organizationUuid: "stale-org" }, }); - const identity = await identify({ route: { store: "keychain" } }, profile, () => 1000, { - ...lookup(), - platform: "darwin", - readKeychainCredentials: async () => ({ - claudeAiOauth: { accessToken: "fixture-keychain-other" }, - }), - }); - expect(identity).toEqual({ key: "pi-account.pi-org" }); + const [identity] = await discover( + { + ...lookup(), + platform: "darwin", + readKeychainCredentials: async () => ({ + claudeAiOauth: { accessToken: "fixture-keychain-other" }, + }), + }, + profile, + ); + expect(identity).toEqual({ key: "pi-account.pi-org", input: { route: { store: "keychain" } } }); }); test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => { const path = join(home, ".omp", "agent", "agent.db"); await database(path); - const [input] = await discover(lookup()); + const [input] = await logins(lookup()); const { DatabaseSync } = await import("node:sqlite"); const db = new DatabaseSync(path); db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run( @@ -246,17 +258,15 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login ).toBe("unavailable"); db.prepare("UPDATE auth_credentials SET disabled_cause = 'revoked' WHERE id = 1").run(); db.close(); - expect( - ( - await fetchUsage( - input!, - async () => { - throw new Error("disabled login must not fetch"); - }, - lookup(), - ) - ).status, - ).toBe("unavailable"); + await expect( + fetchUsage( + input!, + async () => { + throw new Error("must not fetch"); + }, + lookup(), + ), + ).rejects.toThrow("login store no longer exists"); }); test("Claude discovery honors CLAUDE_CONFIG_DIR", async () => { @@ -268,7 +278,7 @@ test("Claude discovery honors CLAUDE_CONFIG_DIR", async () => { await json(join(ignoredDirectory, ".credentials.json"), { claudeAiOauth: { accessToken: "fixture-other" }, }); - const inputs = await discover( + const inputs = await logins( lookup({ CLAUDE_CONFIG_DIR: directory, CLAUDE_HOME: ignoredDirectory }), ); expect(inputs).toEqual([ diff --git a/plugins/claude-usage-source/server/stores.ts b/plugins/claude-usage-source/server/stores.ts index 0c41c1478c..5da17a1e51 100644 --- a/plugins/claude-usage-source/server/stores.ts +++ b/plugins/claude-usage-source/server/stores.ts @@ -97,7 +97,6 @@ function ompRows(path: string, lookup: StoreLookup) { if (!row.success) return []; try { const oauth = oauthSchema.parse(JSON.parse(row.data.data)); - if (oauth.expires === undefined || oauth.expires <= (lookup.now ?? Date.now)()) return []; return [{ id: row.data.id, oauth }]; } catch { return []; diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index f282a25ae9..289076a755 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -2,9 +2,9 @@ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fetchUsage } from "./usage.js"; +import { fetchUsage, discover } from "./usage.js"; import { inputSchema, type UsageInput } from "../shared/input.js"; -import type { UsageReport } from "@getpaseo/plugin/server/usage"; +import { hashAccountKey, type UsageReport } from "@getpaseo/plugin/server/usage"; function credentialInput(directory: string): UsageInput { return { route: { store: "claude", path: join(directory, ".credentials.json") } }; @@ -189,15 +189,13 @@ describe("claude usage source", () => { }); }); - it("returns unavailable Claude usage when credentials are missing", async () => { - fetchApi = vi.fn() as never; - - const result = await service().listUsage(); - const claude = findProvider(result, "claude"); - - expect(claude.status).toBe("unavailable"); - expect(fetchApi).not.toHaveBeenCalled(); - }); + it.each(["empty home", "unrelated files"])( + "discovers no Claude logins in %s", + async (scenario) => { + if (scenario === "unrelated files") writeFileSync(join(homeDir, "unrelated.json"), "{}"); + expect(await discover({ home: homeDir, env: {}, platform: "linux" })).toEqual([]); + }, + ); it("returns unavailable on 401 without refreshing or rewriting credentials", async () => { writeClaudeCredentials(claudeHome, "at_expired", "rt_valid"); @@ -639,9 +637,8 @@ describe("Claude usage source scoped limit reconciliation", () => { }); }); -it("identify uses the credential token profile instead of stale Claude config", async () => { +it("discovery uses the credential token profile instead of stale Claude config", async () => { const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); - const { identify } = await import("./usage.js"); const directory = await mkdtemp(join(tmpdir(), "claude-identity-")); try { await writeFile( @@ -656,7 +653,7 @@ it("identify uses the credential token profile instead of stale Claude config", ); writeClaudeCredentials(directory, "fixture-local-account"); expect( - await identify( + await accountFor( credentialInput(directory), async () => jsonResponse({ @@ -676,7 +673,6 @@ it("identify uses the credential token profile instead of stale Claude config", }); it("Claude credentials without account metadata use a cached OAuth profile identity", async () => { - const { identify } = await import("./usage.js"); let calls = 0; const fetchProfile: typeof fetch = async () => { calls++; @@ -690,13 +686,13 @@ it("Claude credentials without account metadata use a cached OAuth profile ident }; const lookup = tokenLookup("fixture-claude-token"); expect( - await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup), + await accountFor(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup), ).toEqual({ key: "account-uuid.org-uuid", label: "test@example.com", }); expect( - await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup), + await accountFor(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup), ).toEqual({ key: "account-uuid.org-uuid", label: "test@example.com", @@ -705,7 +701,6 @@ it("Claude credentials without account metadata use a cached OAuth profile ident }); it("identifies the same credential route that fetch uses when oauthAccount is absent", async () => { - const { identify } = await import("./usage.js"); const directory = mkdtempSync(join(tmpdir(), "claude-credential-identity-")); try { writeClaudeCredentials(directory, "fixture-credential-token"); @@ -721,7 +716,7 @@ it("identifies the same credential route that fetch uses when oauthAccount is ab ); expect(usageRequested).toBe(true); let profileRequested = false; - const account = await identify( + const account = await accountFor( credentialInput(directory), async () => { profileRequested = true; @@ -741,7 +736,6 @@ it("identifies the same credential route that fetch uses when oauthAccount is ab }); it("expires cached token profiles after five minutes", async () => { - const { identify } = await import("./usage.js"); let now = 0; let calls = 0; const fetchProfile: typeof fetch = async () => { @@ -750,16 +744,15 @@ it("expires cached token profiles after five minutes", async () => { }; const lookup = tokenLookup("ttl-fixture-token"); const input = credentialInput(lookup.claudeHome); - await identify(input, fetchProfile, () => now, lookup); - await identify(input, fetchProfile, () => now, lookup); + await accountFor(input, fetchProfile, () => now, lookup); + await accountFor(input, fetchProfile, () => now, lookup); expect(calls).toBe(1); now = 300_001; - await identify(input, fetchProfile, () => now, lookup); + await accountFor(input, fetchProfile, () => now, lookup); expect(calls).toBe(2); }); -it("identify and fetch share the explicit macOS keychain route", async () => { - const { identify } = await import("./usage.js"); +it("discovery and fetch share the explicit macOS keychain route", async () => { const directory = mkdtempSync(join(tmpdir(), "claude-keychain-identity-")); try { const lookup = { @@ -780,7 +773,7 @@ it("identify and fetch share the explicit macOS keychain route", async () => { lookup, ); expect(usageRequested).toBe(true); - const account = await identify( + const account = await accountFor( { route: { store: "keychain" } }, async () => jsonResponse({ @@ -796,8 +789,7 @@ it("identify and fetch share the explicit macOS keychain route", async () => { } }); -it("identify and fetch share the explicit Claude credential route", async () => { - const { identify } = await import("./usage.js"); +it("discovery and fetch share the explicit Claude credential route", async () => { const directory = mkdtempSync(join(tmpdir(), "claude-home-identity-")); try { writeClaudeCredentials(directory, "home-fixture-token"); @@ -812,7 +804,7 @@ it("identify and fetch share the explicit Claude credential route", async () => lookup, ); expect(usageRequested).toBe(true); - const identity = await identify( + const identity = await accountFor( credentialInput(directory), async () => jsonResponse({ @@ -829,7 +821,6 @@ it("identify and fetch share the explicit Claude credential route", async () => }); it("bounds cached profiles across many token rotations", async () => { - const { identify } = await import("./usage.js"); let calls = 0; const fetchProfile: typeof fetch = async () => { calls++; @@ -837,10 +828,10 @@ it("bounds cached profiles across many token rotations", async () => { }; for (let index = 0; index < 129; index++) { const lookup = tokenLookup(`rotation-fixture-${index}`); - await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup); + await accountFor(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup); } const lookup = tokenLookup("rotation-fixture-0"); - await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup); + await accountFor(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup); expect(calls).toBe(130); }); @@ -863,7 +854,6 @@ function tokenLookup(accessToken: string) { } it("discovers a Pi OAuth login independently of the CLI login", async () => { - const { discover } = await import("./usage.js"); const { mkdir, mkdtemp, writeFile, rm } = await import("node:fs/promises"); const directory = await mkdtemp(join(tmpdir(), "usage-pi-discovery-")); try { @@ -875,10 +865,137 @@ it("discovers a Pi OAuth login independently of the CLI login", async () => { }), ); const inputs = await discover({ home: directory, env: {}, platform: "linux" }); - expect(inputs).toContainEqual({ + expect(inputs.map((account) => account.input)).toContainEqual({ route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") }, }); } finally { await rm(directory, { recursive: true, force: true }); } }); + +it("keeps an expired Keychain login without calling the network and prefers Keychain over the fallback file", async () => { + const directory = mkdtempSync(join(tmpdir(), "claude-expired-")); + try { + writeClaudeCredentials(directory, "stale-file"); + const lookup = { + home: directory, + claudeHome: directory, + env: {}, + platform: "darwin" as const, + now: () => 10_800_000, + readKeychainCredentials: async () => ({ + claudeAiOauth: { accessToken: "expired-keychain", expiresAt: 0 }, + }), + }; + let calls = 0; + const network: typeof fetch = async () => { + calls++; + throw new Error("must not call network"); + }; + const accounts = await discover(lookup, network); + expect(accounts).toEqual([ + { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { route: { store: "keychain" } } }, + ]); + expect(await fetchUsage(accounts[0]!.input as UsageInput, network, lookup)).toEqual({ + status: "unavailable", + problem: { kind: "expired", expiresAt: "1970-01-01T00:00:00.000Z", refreshedBy: "claude" }, + }); + expect(calls).toBe(0); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +async function accountFor( + input: UsageInput, + fetchApi: typeof fetch, + now: () => number, + lookup: Parameters[0] = {}, +) { + const accounts = await discover({ ...lookup, now }, fetchApi); + const account = accounts.find( + (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), + ); + if (!account) throw new Error("Expected discovered login"); + return { key: account.key, ...(account.label ? { label: account.label } : {}) }; +} +it.each([401, 403])("fetch reports rejected usage HTTP %i", async (status) => { + const lookup = tokenLookup(`usage-rejection-${status}`); + expect( + await fetchUsage( + credentialInput(lookup.claudeHome), + async () => new Response(null, { status }), + lookup, + ), + ).toEqual({ + status: "unavailable", + problem: { kind: "rejected", status, refreshedBy: "claude" }, + }); +}); + +it.each([401, 403])("a rejected profile HTTP %i keeps a locator-keyed account", async (status) => { + const lookup = tokenLookup(`profile-rejection-${status}`); + const api: typeof fetch = async () => new Response(null, { status }); + const accounts = await discover(lookup, api); + expect(accounts).toEqual([ + { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: credentialInput(lookup.claudeHome) }, + ]); + let fetches = 0; + expect( + await fetchUsage( + credentialInput(lookup.claudeHome), + async () => { + fetches++; + throw new Error("profile already rejected"); + }, + lookup, + ), + ).toEqual({ + status: "unavailable", + problem: { kind: "rejected", status, refreshedBy: "claude" }, + }); + expect(fetches).toBe(0); +}); + +it.each([ + { failure: "HTTP 500", networkError: false, error: "Claude usage API returned 500" }, + { failure: "a network error", networkError: true, error: "Network unavailable" }, +])( + "keeps a locator-keyed login when the profile fails with $failure", + async ({ networkError, error }) => { + const lookup = tokenLookup(`profile-failure-${networkError}`); + const input = credentialInput(lookup.claudeHome); + const api: typeof fetch = async () => { + if (networkError) throw new Error(error); + return new Response(null, { status: 500 }); + }; + const accounts = await discover(lookup, api); + expect(accounts).toEqual([{ key: hashAccountKey(JSON.stringify(input.route)), input }]); + await expect(fetchUsage(accounts[0]!.input as UsageInput, api, lookup)).rejects.toThrow(error); + }, +); + +it("re-reads a refreshed login at the original locator", async () => { + const lookup = tokenLookup("expiring-token"); + writeFileSync( + join(lookup.claudeHome, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "expiring-token", expiresAt: 0 } }), + ); + const [account] = await discover(lookup, async () => { + throw new Error("expired token must not fetch"); + }); + expect(await fetchUsage(account!.input as UsageInput, fetch, lookup)).toEqual({ + status: "unavailable", + problem: { kind: "expired", expiresAt: "1970-01-01T00:00:00.000Z", refreshedBy: "claude" }, + }); + writeClaudeCredentials(lookup.claudeHome, "rotated-token"); + const report = await fetchUsage( + account!.input as UsageInput, + async (_url, init) => { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer rotated-token"); + return jsonResponse(makeClaudeResponse()); + }, + lookup, + ); + expect(report.status).toBe("available"); +}); diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index 34464d8b53..03509bba21 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -9,7 +9,8 @@ import { z } from "zod"; import { hashAccountKey, toneFromUsedPct, - unavailableUsage, + unavailable, + type UsageAccount, windowFromUsedPct, type UsageReport, type UsageWindow, @@ -27,6 +28,7 @@ const ClaudeCredentialsSchema = z.object({ claudeAiOauth: z .object({ accessToken: z.string().optional(), + expiresAt: z.number().optional(), refreshToken: z.string().optional(), subscriptionType: z.string().optional(), rateLimitTier: z.string().optional(), @@ -356,20 +358,41 @@ async function keychainCredentialRecord( return parsed.success ? toCredentialRecord(parsed.data) : null; } -export async function discover(lookup: ClaudeCredentialLookup = {}): Promise { +export async function discover( + lookup: ClaudeCredentialLookup = {}, + fetchApi: typeof fetch = fetch, +): Promise { + // Claude Code only reads the file when its Keychain login is empty. + const primary: UsageInput = (await keychainCredentialRecord(lookup)) + ? { route: { store: "keychain" } } + : { route: { store: "claude", path: claudeCredentialPath(lookup) } }; const candidates: UsageInput[] = [ - { route: { store: "claude", path: claudeCredentialPath(lookup) } }, - ]; - if ((lookup.platform ?? process.platform) === "darwin") - candidates.push({ route: { store: "keychain" } }); - candidates.push( + primary, { route: { store: "pi", path: piAuthPath(lookup) } }, ...discoverOmp(lookup).map((route) => ({ route })), - ); - const present: UsageInput[] = []; - for (const input of candidates) - if (await resolveClaudeCredentials(input, lookup)) present.push(input); - return present; + ]; + const accounts: UsageAccount[] = []; + for (const input of candidates) { + const credentials = await resolveClaudeCredentials(input, lookup); + if (!credentials) continue; + const fallback = { key: hashAccountKey(JSON.stringify(input.route)), input }; + if (credentials.expires !== undefined && credentials.expires <= (lookup.now ?? Date.now)()) { + accounts.push(fallback); + continue; + } + try { + const profile = await readProfile( + credentials.oauth.accessToken, + fetchApi, + lookup.now ?? Date.now, + ); + accounts.push("status" in profile ? fallback : { ...profile, input }); + } catch { + // Keep the login visible; fetching usage reports the vendor failure. + accounts.push(fallback); + } + } + return accounts; } /** Re-read the selected login; the harness owns token refresh. */ @@ -397,7 +420,9 @@ async function readCredentialFile(path: string): Promise { + async function callClaudeApi(token: string): Promise { const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", { signal: AbortSignal.timeout(15_000), headers: { @@ -442,28 +467,38 @@ export async function fetchUsage( "anthropic-beta": CLAUDE_OAUTH_BETA, }, }); - if (res.status === 401 || res.status === 403) return "NEEDS_AUTH"; + if (res.status === 401 || res.status === 403) return res.status; if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`); return ClaudeUsageResponseSchema.parse(await res.json()); } const credentials = await resolveClaudeCredentials(input, credentialLookup); + if (!credentials) throw new Error("Claude login store no longer exists"); + const refreshedBy = + input.route.store === "claude" || input.route.store === "keychain" + ? "claude" + : input.route.store; if ( - !credentials || - (credentials.expires !== undefined && - credentials.expires <= (credentialLookup.now ?? Date.now)()) - ) { - return unavailableUsage(); - } + credentials.expires !== undefined && + credentials.expires <= (credentialLookup.now ?? Date.now)() + ) + return unavailable({ + kind: "expired", + expiresAt: new Date(credentials.expires).toISOString(), + refreshedBy, + }); + const profile = await cachedProfile( + credentials.oauth.accessToken, + (credentialLookup.now ?? Date.now)(), + ); + if (profile && "status" in profile) + return unavailable({ kind: "rejected", status: profile.status, refreshedBy }); const { oauth } = credentials; const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); const resp = await callClaudeApi(oauth.accessToken); - if (resp === "NEEDS_AUTH") { - // Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md. - return unavailableUsage(); - } + if (typeof resp === "number") return unavailable({ kind: "rejected", status: resp, refreshedBy }); const scoped = reconcileScopedLimits( legacyScopedLimits(resp), @@ -498,26 +533,27 @@ export async function fetchUsage( } // The OAuth usage endpoint meters the active organization selected by the token. -type ClaudeIdentity = { key: string; label?: string } | null; +type ClaudeIdentity = { key: string; label?: string } | { status: number }; const PROFILE_TTL_MS = 300_000; const PROFILE_CACHE_LIMIT = 128; const profileCache = new Map }>(); +function cachedProfile(token: string, now: number): Promise | undefined { + pruneProfileCache(now); + return profileCache.get(hashAccountKey(token))?.result; +} + function pruneProfileCache(now: number): void { for (const [key, entry] of profileCache) { if (now - entry.at >= PROFILE_TTL_MS) profileCache.delete(key); } } -export async function identify( - input: UsageInput, - fetchApi: typeof fetch = fetch, - now: () => number = Date.now, - credentialLookup: ClaudeCredentialLookup = {}, -) { - const credentials = await resolveClaudeCredentials(input, credentialLookup); - if (!credentials) return null; - const token = credentials.oauth.accessToken; +async function readProfile( + token: string, + fetchApi: typeof fetch, + now: () => number, +): Promise { const tokenHash = hashAccountKey(token); pruneProfileCache(now()); const pending = profileCache.get(tokenHash); @@ -536,7 +572,7 @@ export async function identify( "anthropic-beta": CLAUDE_OAUTH_BETA, }, }); - if (response.status === 401 || response.status === 403) return null; + if (response.status === 401 || response.status === 403) return { status: response.status }; if (!response.ok) throw new Error(`Claude profile API returned ${response.status}`); const profile = z .object({ diff --git a/plugins/codex-usage-source/index.server.ts b/plugins/codex-usage-source/index.server.ts index 665860aa5a..786bfe5486 100644 --- a/plugins/codex-usage-source/index.server.ts +++ b/plugins/codex-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { discover, fetchUsage, identify } from "./server/usage.js"; +import { discover, fetchUsage } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -9,7 +9,6 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input: inputSchema, discover: () => discover(), - identify, fetch: fetchUsage, }); return () => {}; diff --git a/plugins/codex-usage-source/server/stores.test.ts b/plugins/codex-usage-source/server/stores.test.ts index 191c92d25f..3368a95147 100644 --- a/plugins/codex-usage-source/server/stores.test.ts +++ b/plugins/codex-usage-source/server/stores.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; -import { discover, fetchUsage, identify } from "./usage.js"; +import { discover, fetchUsage } from "./usage.js"; let home: string; beforeEach(async () => { @@ -19,8 +19,21 @@ async function json(path: string, value: unknown) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify(value)); } -async function account(input: Parameters[0], env: NodeJS.ProcessEnv = {}) { - return identify(input, lookup(env)); +async function accountIdentity( + input: Parameters[0], + env: NodeJS.ProcessEnv = {}, +) { + const accounts = await discover(lookup(env)); + const account = accounts.find( + (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), + ); + if (!account) throw new Error("Expected discovered login"); + return { key: account.key, ...(account.label ? { label: account.label } : {}) }; +} +async function logins(options: Parameters[0]) { + return (await discover(options)).map( + (account) => account.input as Parameters[0], + ); } async function database(path: string) { await mkdir((await import("node:path")).dirname(path), { recursive: true }); @@ -70,9 +83,9 @@ test("Pi uses its own OAuth identity and re-reads rotated tokens without writing await json(join(home, ".claude.json"), { oauthAccount: { accountUuid: "other", organizationUuid: "other-org" }, }); - const inputs = await discover(lookup()); + const inputs = await logins(lookup()); expect(inputs).toHaveLength(1); - expect(await account(inputs[0]!)).toEqual({ key: "pi-account" }); + expect(await accountIdentity(inputs[0]!)).toEqual({ key: "pi-account" }); await json(path, { "openai-codex": { type: "oauth", access: "fixture-rotated", accountId: "pi-account" }, }); @@ -99,11 +112,11 @@ test("Pi honors directory override and ignores API keys and malformed files", as await json(path, { "openai-codex": { type: "oauth", access: "fixture-pi", accountId: "pi-account" }, }); - expect(await discover(lookup(env))).toEqual([{ route: { store: "pi", path } }]); + expect(await logins(lookup(env))).toEqual([{ route: { store: "pi", path } }]); await json(path, { "openai-codex": { type: "api_key", key: "fixture-key" } }); - expect(await discover(lookup(env))).toEqual([]); + expect(await logins(lookup(env))).toEqual([]); await writeFile(path, "broken"); - expect(await discover(lookup(env))).toEqual([]); + expect(await logins(lookup(env))).toEqual([]); }); test("expired Pi tokens are unavailable without calling usage or refreshing", async () => { @@ -115,7 +128,7 @@ test("expired Pi tokens are unavailable without calling usage or refreshing", as expires: 500, }, }); - const [input] = await discover(lookup()); + const [input] = await logins(lookup()); expect( ( await fetchUsage( @@ -152,9 +165,12 @@ for (const location of ["default", "profile", "xdg", "override", "config"]) { } await database(path); const before = await readFile(path); - const inputs = await discover(lookup(env)); - expect(inputs).toEqual([{ route: { store: "omp", path, credentialId: 1 } }]); - expect(await account(inputs[0]!, env)).toEqual({ key: "omp-account" }); + const inputs = await logins(lookup(env)); + expect(inputs).toEqual([ + { route: { store: "omp", path, credentialId: 1 } }, + { route: { store: "omp", path, credentialId: 2 } }, + ]); + expect(await accountIdentity(inputs[0]!, env)).toEqual({ key: "omp-account" }); expect( ( await fetchUsage( @@ -176,7 +192,7 @@ test("missing SQLite skips only OMP", async () => { await json(join(home, ".pi", "agent", "auth.json"), { "openai-codex": { type: "oauth", access: "fixture-pi" }, }); - const inputs = await discover({ ...lookup(), sqlite: () => undefined }); + const inputs = await logins({ ...lookup(), sqlite: () => undefined }); expect(inputs).toEqual([ { route: { store: "pi", path: join(home, ".pi", "agent", "auth.json") } }, ]); @@ -199,17 +215,17 @@ test("Codex and OpenCode discover accounts in preference order with XDG and CODE await json(join(data, "opencode", "auth.json"), { openai: { type: "oauth", access: "fixture-open", accountId: "same" }, }); - const inputs = await discover(lookup(env)); + const inputs = await logins(lookup(env)); expect(inputs.map((input) => input.route.store)).toEqual(["codex", "codex", "opencode"]); - for (const input of inputs) expect(await account(input, env)).toEqual({ key: "same" }); + for (const input of inputs) expect(await accountIdentity(input, env)).toEqual({ key: "same" }); await json(join(data, "opencode", "auth.json"), { openai: { type: "api", key: "fixture-key" } }); - expect(await discover(lookup(env))).toHaveLength(2); + expect(await logins(lookup(env))).toHaveLength(2); }); test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => { const path = join(home, ".omp", "agent", "agent.db"); await database(path); - const [input] = await discover(lookup()); + const [input] = await logins(lookup()); const { DatabaseSync } = await import("node:sqlite"); const db = new DatabaseSync(path); db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run( @@ -231,15 +247,13 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login ).toBe("unavailable"); db.prepare("UPDATE auth_credentials SET disabled_cause = 'revoked' WHERE id = 1").run(); db.close(); - expect( - ( - await fetchUsage( - input!, - async () => { - throw new Error("disabled login must not fetch"); - }, - lookup(), - ) - ).status, - ).toBe("unavailable"); + await expect( + fetchUsage( + input!, + async () => { + throw new Error("must not fetch"); + }, + lookup(), + ), + ).rejects.toThrow("login store no longer exists"); }); diff --git a/plugins/codex-usage-source/server/stores.ts b/plugins/codex-usage-source/server/stores.ts index 2f20beb24e..7f997d7841 100644 --- a/plugins/codex-usage-source/server/stores.ts +++ b/plugins/codex-usage-source/server/stores.ts @@ -97,7 +97,6 @@ function ompRows(path: string, lookup: StoreLookup) { if (!row.success) return []; try { const oauth = oauthSchema.parse(JSON.parse(row.data.data)); - if (oauth.expires === undefined || oauth.expires <= (lookup.now ?? Date.now)()) return []; return [{ id: row.data.id, oauth }]; } catch { return []; diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index 27aab6e6ac..c70066c886 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; -import { fetchUsage, identify } from "./usage.js"; +import { fetchUsage, discover } from "./usage.js"; import { inputSchema, type CodexUsageInput } from "../shared/input.js"; @@ -80,12 +80,12 @@ test("discovery input requires a route and rejects raw credentials", () => { expect(() => inputSchema.parse({})).toThrow(); }); -test("identify returns a key when fetch finds Codex token credentials", async () => { +test("discovery returns a key when fetch finds Codex token credentials", async () => { await writeAuth("fixture-supplied", "account-supplied"); await fetchUsage(authInput(fixtureHome), (_url, init) => response(init?.headers ?? {}, "account-supplied"), ); - expect(await identify(authInput(fixtureHome))).toEqual({ key: "account-supplied" }); + expect(await accountFor(authInput(fixtureHome))).toEqual({ key: "account-supplied" }); }); test("coerces credit balance and marks a 96 percent window dangerous", async () => { @@ -136,12 +136,10 @@ test("summarizes the session and weekly windows by default, not code review", as ]); }); -test("HTML usage body is unavailable", async () => { - const report = await fetchUsage( - authInput(fixtureHome), - async () => new Response("Login", { status: 200 }), - ); - expect(report.status).toBe("unavailable"); +test("HTML usage body is an error", async () => { + await expect( + fetchUsage(authInput(fixtureHome), async () => new Response("Login")), + ).rejects.toThrow("Codex usage API returned HTML"); }); test("401 leaves auth.json byte for byte unchanged and makes no refresh request", async () => { @@ -178,14 +176,16 @@ test("auth account claim survives token rotation", async () => { const token = (suffix: string) => `header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "work-id" }, suffix })).toString("base64url")}.signature`; await writeAuth(token("first")); - expect(await identify(authInput(fixtureHome))).toEqual({ key: "work-id" }); + expect(await accountFor(authInput(fixtureHome))).toEqual({ key: "work-id" }); await writeAuth(token("second")); - expect(await identify(authInput(fixtureHome))).toEqual({ key: "work-id" }); + expect(await accountFor(authInput(fixtureHome))).toEqual({ key: "work-id" }); await writeAuth("opaque-token"); - expect(await identify(authInput(fixtureHome))).toBeNull(); + expect(await accountFor(authInput(fixtureHome))).toEqual({ + key: expect.stringMatching(/^[a-f0-9]{64}$/), + }); }); -test("identify reads an email label from auth id_token when access token has no profile", async () => { +test("discovery reads an email label from auth id_token when access token has no profile", async () => { const home = await mkdtemp(join(tmpdir(), "usage-codex-label-")); try { process.env["CODEX_HOME"] = home; @@ -196,7 +196,7 @@ test("identify reads an email label from auth id_token when access token has no tokens: { account_id: "account-id", access_token: "opaque-token", id_token: idToken }, }), ); - expect(await identify(authInput(home))).toEqual({ + expect(await accountFor(authInput(home))).toEqual({ key: "account-id", label: "id-owner@example.test", }); @@ -206,7 +206,6 @@ test("identify reads an email label from auth id_token when access token has no }); test("discovers a Pi OAuth login independently of the CLI login", async () => { - const { discover } = await import("./usage.js"); const directory = await mkdtemp(join(tmpdir(), "usage-pi-discovery-")); try { await mkdir(join(directory, ".pi", "agent"), { recursive: true }); @@ -217,10 +216,47 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => { }), ); const inputs = await discover({ home: directory, env: {}, platform: "linux" }); - expect(inputs).toContainEqual({ + expect(inputs.map((account) => account.input)).toContainEqual({ route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") }, }); } finally { await rm(directory, { recursive: true, force: true }); } }); + +async function accountFor(input: CodexUsageInput) { + const accounts = await discover({ + home: fixtureHome, + env: { + CODEX_HOME: + input.route.store === "codex" + ? (await import("node:path")).dirname(input.route.path) + : fixtureHome, + }, + platform: "linux", + }); + const account = accounts.find( + (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), + ); + if (!account) throw new Error("Expected discovered login"); + return { key: account.key, ...(account.label ? { label: account.label } : {}) }; +} + +test.each(["empty home", "unrelated files"])( + "discovers no Codex accounts in %s", + async (scenario) => { + const home = await mkdtemp(join(tmpdir(), "codex-no-login-")); + try { + if (scenario === "unrelated files") await writeFile(join(home, "unrelated.json"), "{}"); + expect(await discover({ home, env: {}, platform: "linux" })).toEqual([]); + } finally { + await rm(home, { recursive: true, force: true }); + } + }, +); + +test.each([401, 403])("reports a Codex login rejected with HTTP %i", async (status) => { + expect( + await fetchUsage(authInput(fixtureHome), async () => new Response(null, { status })), + ).toEqual({ status: "unavailable", problem: { kind: "rejected", status, refreshedBy: "codex" } }); +}); diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index 1feda550c0..106a77336d 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -3,6 +3,9 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { balanceToneFromRemaining, + hashAccountKey, + unavailable, + type UsageAccount, toneFromUsedPct, windowFromUsedPct, type UsageReport, @@ -39,7 +42,7 @@ interface Auth { expires?: number; } -export async function discover(lookup: StoreLookup = {}): Promise { +export async function discover(lookup: StoreLookup = {}): Promise { const env = lookup.env ?? process.env; const home = lookup.home ?? homedir(); const paths = [ @@ -59,8 +62,11 @@ export async function discover(lookup: StoreLookup = {}): Promise ({ route })), ); - const present: CodexUsageInput[] = []; - for (const input of candidates) if (await readAuth(input, lookup)) present.push(input); + const present: UsageAccount[] = []; + for (const input of candidates) { + const auth = await readAuth(input, lookup); + if (auth) present.push({ ...accountIdentity(auth, input), input }); + } return present; } @@ -104,8 +110,14 @@ export async function fetchUsage( lookup: StoreLookup = {}, ): Promise { const auth = await readAuth(input, lookup); - if (!auth || (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)())) - return { status: "unavailable", windows: [] }; + if (!auth) throw new Error("Codex login store no longer exists"); + const refreshedBy = input.route.store; + if (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)()) + return unavailable({ + kind: "expired", + expiresAt: new Date(auth.expires).toISOString(), + refreshedBy, + }); const headers: Record = { Authorization: `Bearer ${auth.token}`, Accept: "application/json", @@ -117,10 +129,10 @@ export async function fetchUsage( signal: AbortSignal.timeout(15_000), }); if (response.status === 401 || response.status === 403) - return { status: "unavailable", windows: [] }; + return unavailable({ kind: "rejected", status: response.status, refreshedBy }); if (!response.ok) throw new Error(`Codex usage API returned ${response.status}`); const text = await response.text(); - if (text.trim().startsWith("<")) return { status: "unavailable", windows: [] }; + if (text.trim().startsWith("<")) throw new Error("Codex usage API returned HTML"); const usage = responseSchema.parse(JSON.parse(text)); const windows = [ usageWindow( @@ -181,9 +193,7 @@ function claimString(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } -export async function identify(input: CodexUsageInput, lookup: StoreLookup = {}) { - const auth = await readAuth(input, lookup); - if (!auth) return null; +function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; label?: string } { const access = jwtClaims(auth.token); const id = jwtClaims(auth.idToken); const accessAuth = claimObject(access, "https://api.openai.com/auth"); @@ -193,11 +203,11 @@ export async function identify(input: CodexUsageInput, lookup: StoreLookup = {}) claimString(accessAuth?.["chatgpt_account_id"]) ?? claimString(access?.["chatgpt_account_id"]) ?? claimString(idAuth?.["chatgpt_account_id"]); - if (!key) return null; + const label = claimString(claimObject(access, "https://api.openai.com/profile")?.["email"]) ?? claimString(access?.["email"]) ?? claimString(claimObject(id, "https://api.openai.com/profile")?.["email"]) ?? claimString(id?.["email"]); - return { key, ...(label ? { label } : {}) }; + return { key: key ?? hashAccountKey(JSON.stringify(input.route)), ...(label ? { label } : {}) }; } diff --git a/plugins/copilot-usage-source/index.server.ts b/plugins/copilot-usage-source/index.server.ts index 79da4a578e..4f25223cb4 100644 --- a/plugins/copilot-usage-source/index.server.ts +++ b/plugins/copilot-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { fetchUsage, identify } from "./server/usage.js"; +import { fetchUsage, discover } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "GitHub Copilot", icon: "icon.svg", input: inputSchema, - discover: async () => [{}], - identify, + discover: () => discover(), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/copilot-usage-source/server/usage.test.ts b/plugins/copilot-usage-source/server/usage.test.ts index 9e34d83ad7..8333f28828 100644 --- a/plugins/copilot-usage-source/server/usage.test.ts +++ b/plugins/copilot-usage-source/server/usage.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { identify, fetchUsage } from "./usage.js"; +import { discover, fetchUsage } from "./usage.js"; import type { UsageReport } from "@getpaseo/plugin/server/usage"; function mockFetch(handlers: Map Response>): typeof fetch { @@ -65,14 +65,14 @@ describe("copilot usage source", () => { ) { return { listUsage: async () => { - const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + const report = await fetchFirst((url, init) => fetchApi(url, init)); return { providers: [ { providerId: "copilot", ...report, - error: report.error ?? null, - planLabel: report.planLabel ?? null, + error: report.status === "error" ? report.error : null, + planLabel: report.status === "available" ? (report.planLabel ?? null) : null, }, ], }; @@ -112,17 +112,60 @@ describe("copilot usage source", () => { }); }); -it("identify returns a key when fetch finds copilot credentials", async () => { +it("discovery returns a locator when fetch finds copilot credentials", async () => { const previous = process.env["COPILOT_TOKEN"]; try { process.env["COPILOT_TOKEN"] = "fixture-token"; let requested = false; - await fetchUsage({}, async () => { + await fetchFirst(async () => { requested = true; return new Response(null, { status: 401 }); }); expect(requested).toBe(true); - expect(await identify()).toEqual({ key: "default" }); + expect(await discover()).toEqual([ + { key: "default", input: { store: "env", locator: "COPILOT_TOKEN" } }, + ]); + } finally { + if (previous === undefined) delete process.env["COPILOT_TOKEN"]; + else process.env["COPILOT_TOKEN"] = previous; + } +}); + +describe("account discovery", () => { + it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const directory = await mkdtemp(join(tmpdir(), "usage-empty-")); + const original = { ...process.env }; + try { + for (const key of Object.keys(process.env)) delete process.env[key]; + process.env.HOME = directory; + process.env.USERPROFILE = directory; + if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}"); + expect(await discover()).toEqual([]); + } finally { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, original); + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +async function fetchFirst(fetchApi: typeof fetch) { + const accounts = await discover(); + const account = accounts[0]; + if (!account) throw new Error("No configured account"); + return fetchUsage(account.input as Parameters[0], fetchApi); +} + +it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => { + const previous = process.env["COPILOT_TOKEN"]; + try { + process.env["COPILOT_TOKEN"] = "fixture-rejected-login"; + const report = await fetchUsage( + { store: "env", locator: "COPILOT_TOKEN" }, + async () => new Response(null, { status }), + ); + expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } }); } finally { if (previous === undefined) delete process.env["COPILOT_TOKEN"]; else process.env["COPILOT_TOKEN"] = previous; diff --git a/plugins/copilot-usage-source/server/usage.ts b/plugins/copilot-usage-source/server/usage.ts index 27a5c84e35..78ae61bc9f 100644 --- a/plugins/copilot-usage-source/server/usage.ts +++ b/plugins/copilot-usage-source/server/usage.ts @@ -1,10 +1,11 @@ import type { UsageInput } from "../shared/input.js"; -import { existsSync, promises as fs } from "node:fs"; +import { promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { - unavailableUsage, + unavailable, + type UsageAccount, type UsageReport, type UsageDetail, } from "@getpaseo/plugin/server/usage"; @@ -19,39 +20,36 @@ const CopilotUsageResponseSchema = z.object({ quota_reset_date: ApiOptionalStringSchema, }); -async function readGithubCliToken(): Promise { - const candidates: string[] = []; - if (process.env["APPDATA"]) { - candidates.push(join(process.env["APPDATA"], "GitHub CLI", "hosts.yml")); +async function readToken(input: UsageInput): Promise { + if (input.store === "env") return process.env[input.locator]; + try { + const raw = await fs.readFile(input.locator, "utf8"); + return raw.match(/oauth_token:\s*["']?([a-zA-Z0-9_-]+)["']?/)?.[1]; + } catch { + return undefined; } - candidates.push(join(homedir(), ".config", "gh", "hosts.yml")); +} - for (const path of candidates) { - if (!existsSync(path)) continue; - try { - const raw = await fs.readFile(path, "utf8"); - const match = raw.match(/oauth_token:\s*["']?([a-zA-Z0-9_-]+)["']?/); - if (match?.[1]) return match[1]; - } catch { - continue; - } - } - return null; +export async function discover(): Promise { + const candidates: UsageInput[] = ["COPILOT_TOKEN", "GITHUB_TOKEN", "GITHUB_PAT"].map( + (locator) => ({ store: "env", locator }), + ); + if (process.env.APPDATA) + candidates.push({ + store: "file", + locator: join(process.env.APPDATA, "GitHub CLI", "hosts.yml"), + }); + candidates.push({ store: "file", locator: join(homedir(), ".config", "gh", "hosts.yml") }); + for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }]; + return []; } export async function fetchUsage( input: UsageInput, fetchApi: typeof fetch = fetch, ): Promise { - void input; - - const token = - process.env["COPILOT_TOKEN"] || - process.env["GITHUB_TOKEN"] || - process.env["GITHUB_PAT"] || - (await readGithubCliToken()); - - if (!token) return unavailableUsage(); + const token = await readToken(input); + if (!token) throw new Error("Copilot login store no longer exists"); const res = await fetchApi("https://api.github.com/copilot_internal/user", { signal: AbortSignal.timeout(15_000), @@ -65,9 +63,9 @@ export async function fetchUsage( }, }); - if (!res.ok) { - return unavailableUsage(); - } + if (res.status === 401 || res.status === 403) + return unavailable({ kind: "rejected", status: res.status }); + if (!res.ok) throw new Error(`Copilot usage API returned ${res.status}`); const resp = CopilotUsageResponseSchema.parse(await res.json()); const details: UsageDetail[] = resp.quota_reset_date @@ -82,12 +80,3 @@ export async function fetchUsage( details, }; } - -export async function identify() { - const token = - process.env["COPILOT_TOKEN"] || - process.env["GITHUB_TOKEN"] || - process.env["GITHUB_PAT"] || - (await readGithubCliToken()); - return token ? { key: "default" } : null; -} diff --git a/plugins/copilot-usage-source/shared/input.ts b/plugins/copilot-usage-source/shared/input.ts index 9ee4593341..4be5cce316 100644 --- a/plugins/copilot-usage-source/shared/input.ts +++ b/plugins/copilot-usage-source/shared/input.ts @@ -1,4 +1,8 @@ import { z } from "zod"; -export const inputSchema = z.object({}).strict(); - +export const inputSchema = z + .object({ + store: z.enum(["env", "file"]), + locator: z.string().min(1), + }) + .strict(); export type UsageInput = z.infer; diff --git a/plugins/cursor-usage-source/index.server.ts b/plugins/cursor-usage-source/index.server.ts index a4c1947cb4..078c321590 100644 --- a/plugins/cursor-usage-source/index.server.ts +++ b/plugins/cursor-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { fetchUsage, identify } from "./server/usage.js"; +import { fetchUsage, discover } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Cursor", icon: "icon.svg", input: inputSchema, - discover: async () => [{}], - identify, + discover: () => discover(), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/cursor-usage-source/server/usage.test.ts b/plugins/cursor-usage-source/server/usage.test.ts index acd841c14f..21cca3b192 100644 --- a/plugins/cursor-usage-source/server/usage.test.ts +++ b/plugins/cursor-usage-source/server/usage.test.ts @@ -3,7 +3,7 @@ import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { identify, fetchUsage } from "./usage.js"; +import { discover, fetchUsage } from "./usage.js"; import type { UsageReport } from "@getpaseo/plugin/server/usage"; // node:sqlite has no @types/node@20 typings; require it with a narrow local type. @@ -97,14 +97,14 @@ describe("cursor usage source", () => { ) { return { listUsage: async () => { - const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + const report = await fetchFirst((url, init) => fetchApi(url, init)); return { providers: [ { providerId: "cursor", ...report, - error: report.error ?? null, - planLabel: report.planLabel ?? null, + error: report.status === "error" ? report.error : null, + planLabel: report.status === "available" ? (report.planLabel ?? null) : null, }, ], }; @@ -250,26 +250,68 @@ describe("cursor usage source", () => { expect(cursor.status).toBe("available"); }); - it("stays unavailable when state.vscdb is unreadable", async () => { - const path = join(homeDir, ".config", "Cursor", "User", "globalStorage"); - mkdirSync(path, { recursive: true }); - writeFileSync(join(path, "state.vscdb"), "not a database"); - const cursor = findProvider(await service().listUsage(), "cursor"); - expect(cursor.status).toBe("unavailable"); + it("omits unreadable state.vscdb", async () => { + const directory = join(homeDir, ".config", "Cursor", "User", "globalStorage"); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "state.vscdb"), "invalid database"); + expect(await discover()).toEqual([]); }); }); -it("identify returns a key when fetch finds cursor credentials", async () => { +it("discovery returns a locator when fetch finds cursor credentials", async () => { const previous = process.env["CURSOR_ACCESS_TOKEN"]; try { process.env["CURSOR_ACCESS_TOKEN"] = "fixture-token"; let requested = false; - await fetchUsage({}, async () => { + await fetchFirst(async () => { requested = true; return new Response(null, { status: 401 }); }); expect(requested).toBe(true); - expect(await identify()).toEqual({ key: "default" }); + expect(await discover()).toEqual([ + { key: "default", input: { store: "env", locator: "CURSOR_ACCESS_TOKEN" } }, + ]); + } finally { + if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"]; + else process.env["CURSOR_ACCESS_TOKEN"] = previous; + } +}); + +describe("account discovery", () => { + it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const directory = await mkdtemp(join(tmpdir(), "usage-empty-")); + const original = { ...process.env }; + try { + for (const key of Object.keys(process.env)) delete process.env[key]; + process.env.HOME = directory; + process.env.USERPROFILE = directory; + if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}"); + expect(await discover()).toEqual([]); + } finally { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, original); + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +async function fetchFirst(fetchApi: typeof fetch) { + const accounts = await discover(); + const account = accounts[0]; + if (!account) throw new Error("No configured account"); + return fetchUsage(account.input as Parameters[0], fetchApi); +} + +it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => { + const previous = process.env["CURSOR_ACCESS_TOKEN"]; + try { + process.env["CURSOR_ACCESS_TOKEN"] = "fixture-rejected-login"; + const report = await fetchUsage( + { store: "env", locator: "CURSOR_ACCESS_TOKEN" }, + async () => new Response(null, { status }), + ); + expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } }); } finally { if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"]; else process.env["CURSOR_ACCESS_TOKEN"] = previous; diff --git a/plugins/cursor-usage-source/server/usage.ts b/plugins/cursor-usage-source/server/usage.ts index 15a9f6e7b3..d313b1d739 100644 --- a/plugins/cursor-usage-source/server/usage.ts +++ b/plugins/cursor-usage-source/server/usage.ts @@ -7,7 +7,8 @@ import { z } from "zod"; import { toneFromUsedPct, usedPctOf, - unavailableUsage, + unavailable, + type UsageAccount, type UsageReport, type UsageBalance, } from "@getpaseo/plugin/server/usage"; @@ -114,24 +115,7 @@ function cursorTokenFromDb(db: CursorStateDatabase): string | null { return null; } -async function readCursorTokenFromSqlite(homeDir: string): Promise { - const dbPaths: string[] = []; - if (process.env["APPDATA"]) { - dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); - } - dbPaths.push( - join( - homeDir, - "Library", - "Application Support", - "Cursor", - "User", - "globalStorage", - "state.vscdb", - ), - ); - dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb")); - +async function readCursorTokenFromSqlite(path: string): Promise { // Held in a variable so TypeScript skips module resolution: @types/node@20 has no // node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it. const sqliteSpecifier: string = "node:sqlite"; @@ -142,24 +126,19 @@ async function readCursorTokenFromSqlite(homeDir: string): Promise { - const path = join(homeDir, ".config", "cursor", "auth.json"); +async function readCursorTokenFromAuthJson(path: string): Promise { if (!existsSync(path)) return null; try { const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8"))); @@ -173,16 +152,8 @@ export async function fetchUsage( input: UsageInput, fetchApi: typeof fetch = fetch, ): Promise { - void input; - const homeDir = homedir(); - - const token = - process.env["CURSOR_ACCESS_TOKEN"] || - process.env["CURSOR_TOKEN"] || - (await readCursorTokenFromSqlite(homeDir)) || - (await readCursorTokenFromAuthJson(homeDir)); - - if (!token) return unavailableUsage(); + const token = await readToken(input); + if (!token) throw new Error("Cursor login store no longer exists"); const res = await fetchApi( "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", @@ -198,9 +169,9 @@ export async function fetchUsage( }, ); - if (!res.ok) { - return unavailableUsage(); - } + if (res.status === 401 || res.status === 403) + return unavailable({ kind: "rejected", status: res.status }); + if (!res.ok) throw new Error(`Cursor usage API returned ${res.status}`); const resp = CursorUsageResponseSchema.parse(await res.json()); const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd); @@ -230,12 +201,40 @@ export async function fetchUsage( }; } -export async function identify() { - const home = homedir(); +async function readToken(input: UsageInput): Promise { + if (input.store === "env") return process.env[input.locator]; const token = - process.env["CURSOR_ACCESS_TOKEN"] || - process.env["CURSOR_TOKEN"] || - (await readCursorTokenFromSqlite(home)) || - (await readCursorTokenFromAuthJson(home)); - return token ? { key: "default" } : null; + input.store === "sqlite" + ? await readCursorTokenFromSqlite(input.locator) + : await readCursorTokenFromAuthJson(input.locator); + return token ?? undefined; +} +export async function discover(): Promise { + const home = homedir(); + const candidates: UsageInput[] = ["CURSOR_ACCESS_TOKEN", "CURSOR_TOKEN"].map((locator) => ({ + store: "env", + locator, + })); + if (process.env.APPDATA) + candidates.push({ + store: "sqlite", + locator: join(process.env.APPDATA, "Cursor", "User", "globalStorage", "state.vscdb"), + }); + candidates.push( + ...[ + join( + home, + "Library", + "Application Support", + "Cursor", + "User", + "globalStorage", + "state.vscdb", + ), + join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb"), + ].map((locator) => ({ store: "sqlite" as const, locator })), + { store: "file", locator: join(home, ".config", "cursor", "auth.json") }, + ); + for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }]; + return []; } diff --git a/plugins/cursor-usage-source/shared/input.ts b/plugins/cursor-usage-source/shared/input.ts index 9ee4593341..016179eba1 100644 --- a/plugins/cursor-usage-source/shared/input.ts +++ b/plugins/cursor-usage-source/shared/input.ts @@ -1,4 +1,8 @@ import { z } from "zod"; -export const inputSchema = z.object({}).strict(); - +export const inputSchema = z + .object({ + store: z.enum(["env", "file", "sqlite"]), + locator: z.string().min(1), + }) + .strict(); export type UsageInput = z.infer; diff --git a/plugins/grok-usage-source/index.server.ts b/plugins/grok-usage-source/index.server.ts index 3c87562300..15a6f4efd9 100644 --- a/plugins/grok-usage-source/index.server.ts +++ b/plugins/grok-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { fetchUsage, identify } from "./server/usage.js"; +import { fetchUsage, discover } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Grok", icon: "icon.svg", input: inputSchema, - discover: async () => [{}], - identify, + discover: () => discover(), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/grok-usage-source/server/usage.test.ts b/plugins/grok-usage-source/server/usage.test.ts index 977c9a3fe9..0d95b0b229 100644 --- a/plugins/grok-usage-source/server/usage.test.ts +++ b/plugins/grok-usage-source/server/usage.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { identify, fetchUsage } from "./usage.js"; +import { discover, fetchUsage } from "./usage.js"; import type { UsageReport } from "@getpaseo/plugin/server/usage"; function writeGrokAuth(home: string, auth: Record): void { @@ -70,14 +70,14 @@ describe("grok usage source", () => { ) { return { listUsage: async () => { - const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + const report = await fetchFirst((url, init) => fetchApi(url, init)); return { providers: [ { providerId: "grok", ...report, - error: report.error ?? null, - planLabel: report.planLabel ?? null, + error: report.status === "error" ? report.error : null, + planLabel: report.status === "available" ? (report.planLabel ?? null) : null, }, ], }; @@ -267,17 +267,60 @@ describe("grok usage source", () => { }); }); -it("identify returns a key when fetch finds grok credentials", async () => { +it("discovery returns a locator when fetch finds grok credentials", async () => { const previous = process.env["GROK_TOKEN"]; try { process.env["GROK_TOKEN"] = "fixture-token"; let requested = false; - await fetchUsage({}, async () => { + await fetchFirst(async () => { requested = true; return new Response(null, { status: 401 }); }); expect(requested).toBe(true); - expect(await identify()).toEqual({ key: "default" }); + expect(await discover()).toEqual([ + { key: "default", input: { store: "env", locator: "GROK_TOKEN" } }, + ]); + } finally { + if (previous === undefined) delete process.env["GROK_TOKEN"]; + else process.env["GROK_TOKEN"] = previous; + } +}); + +describe("account discovery", () => { + it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const directory = await mkdtemp(join(tmpdir(), "usage-empty-")); + const original = { ...process.env }; + try { + for (const key of Object.keys(process.env)) delete process.env[key]; + process.env.HOME = directory; + process.env.USERPROFILE = directory; + if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}"); + expect(await discover()).toEqual([]); + } finally { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, original); + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +async function fetchFirst(fetchApi: typeof fetch) { + const accounts = await discover(); + const account = accounts[0]; + if (!account) throw new Error("No configured account"); + return fetchUsage(account.input as Parameters[0], fetchApi); +} + +it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => { + const previous = process.env["GROK_TOKEN"]; + try { + process.env["GROK_TOKEN"] = "fixture-rejected-login"; + const report = await fetchUsage( + { store: "env", locator: "GROK_TOKEN" }, + async () => new Response(null, { status }), + ); + expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } }); } finally { if (previous === undefined) delete process.env["GROK_TOKEN"]; else process.env["GROK_TOKEN"] = previous; diff --git a/plugins/grok-usage-source/server/usage.ts b/plugins/grok-usage-source/server/usage.ts index 65367070b4..5f73aac6b9 100644 --- a/plugins/grok-usage-source/server/usage.ts +++ b/plugins/grok-usage-source/server/usage.ts @@ -1,12 +1,13 @@ import type { UsageInput } from "../shared/input.js"; -import { existsSync, promises as fs } from "node:fs"; +import { promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { toneFromUsedPct, usedPctOf, - unavailableUsage, + unavailable, + type UsageAccount, windowFromUsedPct, type UsageReport, type UsageWindow, @@ -109,23 +110,8 @@ export async function fetchUsage( input: UsageInput, fetchApi: typeof fetch = fetch, ): Promise { - void input; - const homeDir = homedir(); - - async function readGrokToken(): Promise { - // homeDir override is for tests: Windows os.homedir() ignores $HOME (uses USERPROFILE). - const path = join(homeDir ?? homedir(), ".grok", "auth.json"); - if (!existsSync(path)) return null; - try { - return extractGrokTokenFromAuth(JSON.parse(await fs.readFile(path, "utf8"))); - } catch { - return null; - } - } - - const token = process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await readGrokToken()); - - if (!token) return unavailableUsage(); + const token = await readToken(input); + if (!token) throw new Error("Grok login store no longer exists"); // 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. @@ -138,9 +124,9 @@ export async function fetchUsage( }, }); - if (!res.ok) { - return unavailableUsage(); - } + if (res.status === 401 || res.status === 403) + return unavailable({ kind: "rejected", status: res.status }); + if (!res.ok) throw new Error(`Grok usage API returned ${res.status}`); const resp = GrokUsageResponseSchema.parse(await res.json()); const balance = grokMonthlyCreditBalance(resp); @@ -155,12 +141,22 @@ export async function fetchUsage( }; } -export async function identify() { - if (process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"]) return { key: "default" }; +async function readToken(input: UsageInput): Promise { + if (input.store === "env") return process.env[input.locator]; try { - const auth = JSON.parse(await fs.readFile(join(homedir(), ".grok", "auth.json"), "utf8")); - return extractGrokTokenFromAuth(auth) ? { key: "default" } : null; + return ( + extractGrokTokenFromAuth(JSON.parse(await fs.readFile(input.locator, "utf8"))) ?? undefined + ); } catch { - return null; + return undefined; } } +export async function discover(): Promise { + const candidates: UsageInput[] = ["GROK_API_KEY", "GROK_TOKEN"].map((locator) => ({ + store: "env", + locator, + })); + candidates.push({ store: "file", locator: join(homedir(), ".grok", "auth.json") }); + for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }]; + return []; +} diff --git a/plugins/grok-usage-source/shared/input.ts b/plugins/grok-usage-source/shared/input.ts index 9ee4593341..4be5cce316 100644 --- a/plugins/grok-usage-source/shared/input.ts +++ b/plugins/grok-usage-source/shared/input.ts @@ -1,4 +1,8 @@ import { z } from "zod"; -export const inputSchema = z.object({}).strict(); - +export const inputSchema = z + .object({ + store: z.enum(["env", "file"]), + locator: z.string().min(1), + }) + .strict(); export type UsageInput = z.infer; diff --git a/plugins/kimi-usage-source/index.server.ts b/plugins/kimi-usage-source/index.server.ts index a1df077baa..c6a9a978ff 100644 --- a/plugins/kimi-usage-source/index.server.ts +++ b/plugins/kimi-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { fetchUsage, identify } from "./server/usage.js"; +import { fetchUsage, discover } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Kimi", icon: "icon.svg", input: inputSchema, - discover: async () => [{}], - identify, + discover: () => discover(), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/kimi-usage-source/server/usage.test.ts b/plugins/kimi-usage-source/server/usage.test.ts index 9dc83ec587..c5ba74d004 100644 --- a/plugins/kimi-usage-source/server/usage.test.ts +++ b/plugins/kimi-usage-source/server/usage.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "nod import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { identify, fetchUsage } from "./usage.js"; +import { discover, fetchUsage } from "./usage.js"; import type { UsageReport } from "@getpaseo/plugin/server/usage"; function kimiCredentialPath(dir: string): string { @@ -85,14 +85,14 @@ describe("kimi usage source", () => { ) { return { listUsage: async () => { - const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + const report = await fetchFirst((url, init) => fetchApi(url, init)); return { providers: [ { providerId: "kimi", ...report, - error: report.error ?? null, - planLabel: report.planLabel ?? null, + error: report.status === "error" ? report.error : null, + planLabel: report.status === "available" ? (report.planLabel ?? null) : null, }, ], }; @@ -272,7 +272,7 @@ describe("Kimi usage source usage windows", () => { ], }), ); - const usage = await fetchUsage({}, fetchApi); + const usage = await fetchFirst(fetchApi); expect(usage).toMatchObject({ status: "available", @@ -315,7 +315,7 @@ describe("Kimi usage source usage windows", () => { ], }), ); - const usage = await fetchUsage({}, fetchApi); + const usage = await fetchFirst(fetchApi); expect(usage.windows).toHaveLength(2); expect(usage.windows[1]).toMatchObject({ @@ -340,7 +340,7 @@ describe("Kimi usage source usage windows", () => { ], }), ); - const usage = await fetchUsage({}, fetchApi); + const usage = await fetchFirst(fetchApi); expect(usage.windows).toEqual([ expect.objectContaining({ @@ -360,7 +360,7 @@ describe("Kimi usage source usage windows", () => { detail: { limit: "100", used: "10" }, }; const fetchApi = vi.fn(async () => jsonResponse({ limits: [duplicate, duplicate] })); - const usage = await fetchUsage({}, fetchApi); + const usage = await fetchFirst(fetchApi); expect(usage.windows.map((window) => window.id)).toEqual([ "coding_limit_300_time_unit_minute", @@ -369,17 +369,60 @@ describe("Kimi usage source usage windows", () => { }); }); -it("identify returns a key when fetch finds kimi credentials", async () => { +it("discovery returns a locator when fetch finds kimi credentials", async () => { const previous = process.env["KIMI_TOKEN"]; try { process.env["KIMI_TOKEN"] = "fixture-token"; let requested = false; - await fetchUsage({}, async () => { + await fetchFirst(async () => { requested = true; return new Response(null, { status: 401 }); }); expect(requested).toBe(true); - expect(await identify()).toEqual({ key: "default" }); + expect(await discover()).toEqual([ + { key: "default", input: { store: "env", locator: "KIMI_TOKEN" } }, + ]); + } finally { + if (previous === undefined) delete process.env["KIMI_TOKEN"]; + else process.env["KIMI_TOKEN"] = previous; + } +}); + +describe("account discovery", () => { + it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const directory = await mkdtemp(join(tmpdir(), "usage-empty-")); + const original = { ...process.env }; + try { + for (const key of Object.keys(process.env)) delete process.env[key]; + process.env.HOME = directory; + process.env.USERPROFILE = directory; + if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}"); + expect(await discover()).toEqual([]); + } finally { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, original); + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +async function fetchFirst(fetchApi: typeof fetch) { + const accounts = await discover(); + const account = accounts[0]; + if (!account) throw new Error("No configured account"); + return fetchUsage(account.input as Parameters[0], fetchApi); +} + +it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => { + const previous = process.env["KIMI_TOKEN"]; + try { + process.env["KIMI_TOKEN"] = "fixture-rejected-login"; + const report = await fetchUsage( + { store: "env", locator: "KIMI_TOKEN" }, + async () => new Response(null, { status }), + ); + expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } }); } finally { if (previous === undefined) delete process.env["KIMI_TOKEN"]; else process.env["KIMI_TOKEN"] = previous; diff --git a/plugins/kimi-usage-source/server/usage.ts b/plugins/kimi-usage-source/server/usage.ts index c590b1c984..095b187226 100644 --- a/plugins/kimi-usage-source/server/usage.ts +++ b/plugins/kimi-usage-source/server/usage.ts @@ -1,11 +1,12 @@ import type { UsageInput } from "../shared/input.js"; -import { existsSync, promises as fs } from "node:fs"; +import { promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { toneFromUsedPct, - unavailableUsage, + unavailable, + type UsageAccount, windowFromUsedPct, type UsageReport, type UsageWindow, @@ -251,9 +252,6 @@ export async function fetchUsage( input: UsageInput, fetchApi: typeof fetch = fetch, ): Promise { - void input; - const homeDir = homedir(); - async function callUsageApi(token: string): Promise { return fetchApi(KIMI_USAGE_URL, { signal: AbortSignal.timeout(15_000), @@ -264,52 +262,19 @@ export async function fetchUsage( }); } - async function readCredentials(): Promise { - const environmentToken = process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"]; - if (environmentToken) { - return { access_token: environmentToken }; - } - - for (const path of credentialPaths()) { - const credentials = await readCredentialFile(path); - if (credentials?.access_token) { - return { ...credentials, access_token: credentials.access_token }; - } - } - return null; - } - - function credentialPaths(): string[] { - const home = homeDir; - return [ - join( - process.env["KIMI_CODE_HOME"] || join(home, ".kimi-code"), - "credentials", - "kimi-code.json", - ), - join(home, ".kimi", "credentials", "kimi-code.json"), - ]; - } - - async function readCredentialFile(path: string): Promise { - if (!existsSync(path)) return null; - try { - return KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); - } catch { - return null; - } - } - - const credentials = await readCredentials(); - if (!credentials) return unavailableUsage(); + const credentials = await readCredentials(input); + if (!credentials) throw new Error("Kimi login store no longer exists"); + if (credentials.expires_at != null && credentials.expires_at * 1000 <= Date.now()) + return unavailable({ + kind: "expired", + expiresAt: new Date(credentials.expires_at * 1000).toISOString(), + }); const res = await callUsageApi(credentials.access_token); - if (!res.ok) { - // Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md. - - return unavailableUsage(); - } + if (res.status === 401 || res.status === 403) + return unavailable({ kind: "rejected", status: res.status }); + if (!res.ok) throw new Error(`Kimi usage API returned ${res.status}`); const windows = kimiUsageWindowsFromPayload(await res.json()); @@ -322,24 +287,36 @@ export async function fetchUsage( }; } -export async function identify() { - if (process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"]) return { key: "default" }; - const home = homedir(); - const paths = [ - join( - process.env["KIMI_CODE_HOME"] || join(home, ".kimi-code"), - "credentials", - "kimi-code.json", - ), - join(home, ".kimi", "credentials", "kimi-code.json"), - ]; - for (const path of paths) { - try { - const auth = KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); - if (auth.access_token) return { key: "default" }; - } catch { - continue; - } +async function readCredentials(input: UsageInput): Promise { + if (input.store === "env") { + const token = process.env[input.locator]; + return token ? { access_token: token } : undefined; + } + try { + const credentials = KimiAuthSchema.parse(JSON.parse(await fs.readFile(input.locator, "utf8"))); + return credentials.access_token + ? { ...credentials, access_token: credentials.access_token } + : undefined; + } catch { + return undefined; } - return null; +} +export async function discover(): Promise { + const candidates: UsageInput[] = ["KIMI_TOKEN", "KIMI_API_KEY"].map((locator) => ({ + store: "env", + locator, + })); + candidates.push( + ...[ + join( + process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"), + "credentials", + "kimi-code.json", + ), + join(homedir(), ".kimi", "credentials", "kimi-code.json"), + ].map((locator) => ({ store: "file" as const, locator })), + ); + for (const input of candidates) + if (await readCredentials(input)) return [{ key: "default", input }]; + return []; } diff --git a/plugins/kimi-usage-source/shared/input.ts b/plugins/kimi-usage-source/shared/input.ts index 9ee4593341..4be5cce316 100644 --- a/plugins/kimi-usage-source/shared/input.ts +++ b/plugins/kimi-usage-source/shared/input.ts @@ -1,4 +1,8 @@ import { z } from "zod"; -export const inputSchema = z.object({}).strict(); - +export const inputSchema = z + .object({ + store: z.enum(["env", "file"]), + locator: z.string().min(1), + }) + .strict(); export type UsageInput = z.infer; diff --git a/plugins/minimax-usage-source/index.server.ts b/plugins/minimax-usage-source/index.server.ts index 95d470867b..c98af742dd 100644 --- a/plugins/minimax-usage-source/index.server.ts +++ b/plugins/minimax-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { fetchUsage, identify } from "./server/usage.js"; +import { fetchUsage, discover } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "MiniMax", icon: "icon.svg", input: inputSchema, - discover: async () => [{}], - identify, + discover: () => discover(), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/minimax-usage-source/server/usage.test.ts b/plugins/minimax-usage-source/server/usage.test.ts index 8926b23980..57b58418ac 100644 --- a/plugins/minimax-usage-source/server/usage.test.ts +++ b/plugins/minimax-usage-source/server/usage.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { identify, fetchUsage } from "./usage.js"; +import { discover, fetchUsage } from "./usage.js"; import type { UsageReport } from "@getpaseo/plugin/server/usage"; function writeMiniMaxConfig(dir: string, payload: Record): void { @@ -83,14 +83,14 @@ describe("minimax usage source", () => { ) { return { listUsage: async () => { - const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + const report = await fetchFirst((url, init) => fetchApi(url, init)); return { providers: [ { providerId: "minimax", ...report, - error: report.error ?? null, - planLabel: report.planLabel ?? null, + error: report.status === "error" ? report.error : null, + planLabel: report.status === "available" ? (report.planLabel ?? null) : null, }, ], }; @@ -154,13 +154,8 @@ describe("minimax usage source", () => { }); }); - it("returns unavailable MiniMax usage when no credentials are configured", async () => { - fetchApi = vi.fn() as never; - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax.status).toBe("unavailable"); - expect(fetchApi).not.toHaveBeenCalled(); + it("omits MiniMax when no credentials are configured", async () => { + expect(await discover()).toEqual([]); }); it("reads MiniMax OAuth credentials from the CLI credentials file", async () => { @@ -215,7 +210,11 @@ describe("minimax usage source", () => { const miniMax = findProvider(await service().listUsage(), "minimax"); - expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null }); + expect(miniMax).toMatchObject({ + status: "unavailable", + problem: { kind: "no_quota" }, + error: null, + }); }); it("reports a null MiniMax token plan as unavailable even without base_resp", async () => { @@ -231,7 +230,11 @@ describe("minimax usage source", () => { const miniMax = findProvider(await service().listUsage(), "minimax"); - expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null }); + expect(miniMax).toMatchObject({ + status: "unavailable", + problem: { kind: "no_quota" }, + error: null, + }); }); it("still reads MiniMax windows when base_resp reports success", async () => { @@ -314,17 +317,60 @@ describe("minimax usage source", () => { }); }); -it("identify returns a key when fetch finds minimax credentials", async () => { +it("discovery returns a locator when fetch finds minimax credentials", async () => { const previous = process.env["MINIMAX_API_KEY"]; try { process.env["MINIMAX_API_KEY"] = "fixture-token"; let requested = false; - await fetchUsage({}, async () => { + await fetchFirst(async () => { requested = true; return new Response(null, { status: 401 }); }); expect(requested).toBe(true); - expect(await identify()).toEqual({ key: "default" }); + expect(await discover()).toEqual([ + { key: "default", input: { store: "env", locator: "MINIMAX_API_KEY" } }, + ]); + } finally { + if (previous === undefined) delete process.env["MINIMAX_API_KEY"]; + else process.env["MINIMAX_API_KEY"] = previous; + } +}); + +describe("account discovery", () => { + it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const directory = await mkdtemp(join(tmpdir(), "usage-empty-")); + const original = { ...process.env }; + try { + for (const key of Object.keys(process.env)) delete process.env[key]; + process.env.HOME = directory; + process.env.USERPROFILE = directory; + if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}"); + expect(await discover()).toEqual([]); + } finally { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, original); + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +async function fetchFirst(fetchApi: typeof fetch) { + const accounts = await discover(); + const account = accounts[0]; + if (!account) throw new Error("No configured account"); + return fetchUsage(account.input as Parameters[0], fetchApi); +} + +it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => { + const previous = process.env["MINIMAX_API_KEY"]; + try { + process.env["MINIMAX_API_KEY"] = "fixture-rejected-login"; + const report = await fetchUsage( + { store: "env", locator: "MINIMAX_API_KEY" }, + async () => new Response(null, { status }), + ); + expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } }); } finally { if (previous === undefined) delete process.env["MINIMAX_API_KEY"]; else process.env["MINIMAX_API_KEY"] = previous; diff --git a/plugins/minimax-usage-source/server/usage.ts b/plugins/minimax-usage-source/server/usage.ts index 4ae802a285..7bf602cff1 100644 --- a/plugins/minimax-usage-source/server/usage.ts +++ b/plugins/minimax-usage-source/server/usage.ts @@ -1,10 +1,11 @@ import type { UsageInput } from "../shared/input.js"; -import { existsSync, promises as fs } from "node:fs"; +import { promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { - unavailableUsage, + unavailable, + type UsageAccount, windowFromUsedPct, type UsageReport, type UsageWindow, @@ -148,83 +149,10 @@ export async function fetchUsage( input: UsageInput, fetchApi: typeof fetch = fetch, ): Promise { - void input; - const configPath = join(homedir(), ".mmx", "config.json"); - const credentialsPath = join(homedir(), ".mmx", "credentials.json"); - const env = process.env; - const now = Date.now; - - async function resolveAuth(): Promise { - const envToken = env["MINIMAX_API_KEY"]; - if (envToken) { - const envBase = env["MINIMAX_BASE_URL"]; - return { - token: envToken, - baseUrl: resolveBaseUrl({ baseUrl: envBase }), - }; - } - - const credentials = await readCredentials(); - if (credentials?.access_token && !isExpired(credentials.expires_at)) { - return { - token: credentials.access_token, - baseUrl: resolveBaseUrl({ baseUrl: credentials.resource_url }), - }; - } - - const config = await readConfig(); - if (config?.api_key) { - return { - token: config.api_key, - baseUrl: resolveBaseUrl({ - baseUrl: config.base_url, - region: config.region, - }), - }; - } - - if (config?.oauth?.access_token && !isExpired(config.oauth.expires_at)) { - return { - token: config.oauth.access_token, - baseUrl: resolveBaseUrl({ - baseUrl: config.oauth.resource_url ?? config.base_url, - region: config.region, - }), - }; - } - - return null; - } - - function isExpired(expiresAt: string | null | undefined): boolean { - if (!expiresAt) return false; - const parsed = Date.parse(expiresAt); - if (!Number.isFinite(parsed)) return false; - return parsed <= now(); - } - - async function readCredentials(): Promise | null> { - if (!existsSync(credentialsPath)) return null; - try { - const raw = JSON.parse(await fs.readFile(credentialsPath, "utf8")); - return MiniMaxCredentialsSchema.parse(raw); - } catch { - return null; - } - } - - async function readConfig(): Promise | null> { - if (!existsSync(configPath)) return null; - try { - const raw = JSON.parse(await fs.readFile(configPath, "utf8")); - return MiniMaxConfigSchema.parse(raw); - } catch { - return null; - } - } - - const auth = await resolveAuth(); - if (!auth) return unavailableUsage(); + const auth = await readAuth(input); + if (!auth) throw new Error("MiniMax login store no longer exists"); + if (auth.expiresAt && Date.parse(auth.expiresAt) <= Date.now()) + return unavailable({ kind: "expired", expiresAt: new Date(auth.expiresAt).toISOString() }); const res = await fetchApi(`${auth.baseUrl}/v1/token_plan/remains`, { signal: AbortSignal.timeout(15_000), @@ -234,15 +162,18 @@ export async function fetchUsage( }, }); - if (!res.ok) { - return unavailableUsage(); - } + if (res.status === 401 || res.status === 403) + return unavailable({ kind: "rejected", status: res.status }); + if (!res.ok) throw new Error(`MiniMax usage API returned ${res.status}`); const resp = MiniMaxQuotaResponseSchema.parse(await res.json()); const statusCode = resp.base_resp?.status_code; if (typeof statusCode === "number" && statusCode !== 0) { - return unavailableUsage(); + return unavailable({ + kind: "no_quota", + detail: resp.base_resp?.status_msg ?? "No active coding plan", + }); } const models = resp.model_remains ?? []; @@ -256,8 +187,10 @@ export async function fetchUsage( if (weeklyWindow) windows.push(weeklyWindow); } + if (windows.length === 0) + return unavailable({ kind: "no_quota", detail: "No active coding plan" }); return { - status: windows.length > 0 ? "available" : "unavailable", + status: "available", planLabel: undefined, windows, balances: [], @@ -265,24 +198,55 @@ export async function fetchUsage( }; } -export async function identify() { - if (process.env["MINIMAX_API_KEY"]) return { key: "default" }; - const home = join(homedir(), ".mmx"); - try { - const credentials = MiniMaxCredentialsSchema.parse( - JSON.parse(await fs.readFile(join(home, "credentials.json"), "utf8")), - ); - if (credentials.access_token) return { key: "default" }; - } catch { - /* Check config next. */ +async function readAuth( + input: UsageInput, +): Promise<(MiniMaxResolvedAuth & { expiresAt?: string }) | undefined> { + if (input.store === "env") { + const token = process.env[input.locator]; + return token + ? { token, baseUrl: resolveBaseUrl({ baseUrl: process.env.MINIMAX_BASE_URL }) } + : undefined; } try { - const config = MiniMaxConfigSchema.parse( - JSON.parse(await fs.readFile(join(home, "config.json"), "utf8")), - ); - if (config.api_key || config.oauth?.access_token) return { key: "default" }; + const raw: unknown = JSON.parse(await fs.readFile(input.locator, "utf8")); + if (input.store === "credentials") { + const credentials = MiniMaxCredentialsSchema.parse(raw); + return credentials.access_token + ? { + token: credentials.access_token, + baseUrl: resolveBaseUrl({ baseUrl: credentials.resource_url }), + expiresAt: credentials.expires_at ?? undefined, + } + : undefined; + } + const config = MiniMaxConfigSchema.parse(raw); + if (config.api_key) + return { + token: config.api_key, + baseUrl: resolveBaseUrl({ baseUrl: config.base_url, region: config.region }), + }; + return config.oauth?.access_token + ? { + token: config.oauth.access_token, + baseUrl: resolveBaseUrl({ + baseUrl: config.oauth.resource_url ?? config.base_url, + region: config.region, + }), + expiresAt: config.oauth.expires_at ?? undefined, + } + : undefined; } catch { - /* No configured account. */ + return undefined; } - return null; +} +export async function discover(): Promise { + const candidates: UsageInput[] = [ + { store: "env", locator: "MINIMAX_API_KEY" }, + { store: "credentials", locator: join(homedir(), ".mmx", "credentials.json") }, + { store: "config", locator: join(homedir(), ".mmx", "config.json") }, + ]; + const accounts: UsageAccount[] = []; + for (const input of candidates) + if (await readAuth(input)) accounts.push({ key: "default", input }); + return accounts; } diff --git a/plugins/minimax-usage-source/shared/input.ts b/plugins/minimax-usage-source/shared/input.ts index 9ee4593341..22e12a603f 100644 --- a/plugins/minimax-usage-source/shared/input.ts +++ b/plugins/minimax-usage-source/shared/input.ts @@ -1,4 +1,8 @@ import { z } from "zod"; -export const inputSchema = z.object({}).strict(); - +export const inputSchema = z + .object({ + store: z.enum(["env", "credentials", "config"]), + locator: z.string().min(1), + }) + .strict(); export type UsageInput = z.infer; diff --git a/plugins/muse-provider/server/usage.ts b/plugins/muse-provider/server/usage.ts index 36dae811d8..85cfb473b6 100644 --- a/plugins/muse-provider/server/usage.ts +++ b/plugins/muse-provider/server/usage.ts @@ -5,7 +5,7 @@ import type { UsageSourceRegistration } from "@getpaseo/plugin/server"; import type { ProviderLaunch } from "@getpaseo/plugin/server/provider"; import { hashAccountKey, - unavailableUsage, + unavailable, windowFromUsedPct, toneFromUsedPct, } from "@getpaseo/plugin/server/usage"; @@ -59,18 +59,19 @@ export class Usage { label: "Muse Code", icon: "icon.svg", input: inputSchema, - discover: async () => [...this.launches.keys()].map((account) => ({ account })), - identify: async (input) => { - const { account } = inputSchema.parse(input); - return this.launches.has(account) ? { key: account, label: "Muse Code" } : null; - }, + discover: async () => + [...this.launches.keys()].map((account) => ({ + key: account, + label: "Muse Code", + input: { account }, + })), fetch: async (input) => { const { account } = inputSchema.parse(input); const launch = this.launches.get(account); - if (!launch) return unavailableUsage(); + if (!launch) throw new Error("Muse login no longer exists"); try { const { usage } = await this.read(account, launch); - if (!usage) return unavailableUsage(); + if (!usage) return unavailable({ kind: "no_quota", detail: "No usage quota reported" }); return { status: "available", planLabel: usage.tier, @@ -105,7 +106,6 @@ export class Usage { } catch (error) { return { status: "error", - windows: [], error: error instanceof Error ? error.message : String(error), }; } diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index 1d346a0c29..4393cda7ee 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -1157,12 +1157,13 @@ for (const usage of [ await h.open(); const inputs = await h.usageSource.discover(); expect(inputs).toHaveLength(1); - const input = inputs[0]; - expect(await h.usageSource.identify(input)).toEqual({ - key: (input as { account: string }).account, + const account = inputs[0]!; + expect(account).toEqual({ + key: expect.stringMatching(/^[a-f0-9]{64}$/), label: "Muse Code", + input: { account: account.key }, }); - const report = await h.usageSource.fetch(input); + const report = await h.usageSource.fetch(account.input); if ("usage" in usage) expect(report).toMatchObject({ status: "available", @@ -1178,7 +1179,11 @@ for (const usage of [ { id: "weekly", usedPct: 105, remainingPct: 0, tone: "danger" }, ], }); - else expect(report).toEqual({ status: "unavailable", windows: [], balances: [], details: [] }); + else + expect(report).toEqual({ + status: "unavailable", + problem: { kind: "no_quota", detail: "No usage quota reported" }, + }); expect((await h.recorded()).filter((f) => f.method === "usage/read")).toHaveLength(1); }); } @@ -1303,10 +1308,13 @@ test("usage identity follows the resolved config directory across credential and await h.open(); const inputs = await h.usageSource.discover(); expect(inputs).toHaveLength(1); - const identity = await h.usageSource.identify(inputs[0]); - expect(identity).not.toBeNull(); - identities.push(identity!.key); + identities.push(inputs[0]!.key); } expect(identities[0]).toBe(identities[1]); expect(identities[2]).not.toBe(identities[0]); }); + +test.each(["no sessions", "unrelated environment"])("Muse discovery is empty with %s", async () => { + const { Usage } = await import("../server/usage.js"); + expect(await new Usage().registration().discover()).toEqual([]); +}); diff --git a/plugins/opencode-go-usage-source/index.server.ts b/plugins/opencode-go-usage-source/index.server.ts index cb47cc1ac6..b011eaf2f4 100644 --- a/plugins/opencode-go-usage-source/index.server.ts +++ b/plugins/opencode-go-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { discover, fetchUsage, identify } from "./server/usage.js"; +import { discover, fetchUsage } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -9,7 +9,6 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input: inputSchema, discover, - identify, fetch: fetchUsage, }); return () => {}; diff --git a/plugins/opencode-go-usage-source/server/usage.test.ts b/plugins/opencode-go-usage-source/server/usage.test.ts index 849fbe874f..72fbe0ee13 100644 --- a/plugins/opencode-go-usage-source/server/usage.test.ts +++ b/plugins/opencode-go-usage-source/server/usage.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; -import { discover, fetchUsage, identify } from "./usage.js"; +import { discover, fetchUsage } from "./usage.js"; import { inputSchema } from "../shared/input.js"; @@ -22,7 +22,7 @@ afterEach(async () => { test("discovery input rejects an explicit API key", () => { expect(inputSchema.safeParse({ apiKey: "unused" }).success).toBe(false); - expect(inputSchema.parse({})).toEqual({}); + expect(() => inputSchema.parse({})).toThrow(); }); const upstreamResponse = { @@ -39,7 +39,9 @@ test("discovers and fetches the default key from read-only auth.json", async () const content = JSON.stringify({ "opencode-go": { type: "api", key: "fixture-key" } }); try { await writeFile(path, content); - expect(await discover(path)).toEqual([{}]); + expect(await discover(path)).toEqual([ + { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { path } }, + ]); const requests: Array<{ url: string; authorization: string | null }> = []; const fetchApi = async (url: string | URL | Request, init?: RequestInit) => { requests.push({ @@ -48,8 +50,7 @@ test("discovers and fetches the default key from read-only auth.json", async () }); return Response.json(upstreamResponse); }; - const report = await fetchUsage({}, fetchApi as typeof fetch, path); - expect((await identify({}, path))?.key).toMatch(/^[a-f0-9]{64}$/); + const report = await fetchUsage({ path }, fetchApi as typeof fetch); expect(requests).toEqual([ { url: "https://opencode.ai/zen/go/v1/usage", authorization: "Bearer fixture-key" }, ]); @@ -75,38 +76,29 @@ test("omits default discovery when auth.json lacks an OpenCode Go API key", asyn try { await writeFile(path, JSON.stringify({ openai: { type: "oauth", access: "other-token" } })); expect(await discover(path)).toEqual([]); - const report = await fetchUsage( - {}, - async () => { + await expect( + fetchUsage({ path }, async () => { throw new Error("must not fetch"); - }, - path, - ); - expect(report).toEqual({ status: "unavailable", windows: [] }); + }), + ).rejects.toThrow("login store no longer exists"); } finally { await rm(directory, { recursive: true, force: true }); } }); test.each([401, 403])("maps HTTP %i to unavailable", async (status) => { - const report = await fetchUsage({}, async () => new Response(null, { status }), fixturePath); - expect(report.status).toBe("unavailable"); - expect(report.windows).toEqual([]); + const report = await fetchUsage( + { path: fixturePath }, + async () => new Response(null, { status }), + ); + expect(report).toEqual({ + status: "unavailable", + problem: { kind: "rejected", status, refreshedBy: "opencode" }, + }); }); -test("identify returns a key when fetch finds OpenCode Go credentials", async () => { - let requested = false; - await fetchUsage( - {}, - async () => { - requested = true; - return new Response(null, { status: 401 }); - }, - fixturePath, - ); - expect(requested).toBe(true); - const identity = await identify({}, fixturePath); - expect(identity?.key).toMatch(/^[a-f0-9]{64}$/); - expect(identity?.key).not.toBe("fixture-secret"); - expect(await identify({}, fixturePath)).toEqual(identity); +test.each(["missing file", "unrelated file"])("discovers no accounts for %s", async (scenario) => { + const path = join(fixtureDirectory, "missing.json"); + if (scenario === "unrelated file") await writeFile(path, "{}"); + expect(await discover(path)).toEqual([]); }); diff --git a/plugins/opencode-go-usage-source/server/usage.ts b/plugins/opencode-go-usage-source/server/usage.ts index 77e83613df..cd2905ca2e 100644 --- a/plugins/opencode-go-usage-source/server/usage.ts +++ b/plugins/opencode-go-usage-source/server/usage.ts @@ -4,6 +4,8 @@ import { join } from "node:path"; import { z } from "zod"; import { hashAccountKey, + unavailable, + type UsageAccount, toneFromUsedPct, windowFromUsedPct, type UsageReport, @@ -44,23 +46,22 @@ export async function readDefaultKey(path = authPath()): Promise } } -export async function discover(path = authPath()): Promise> { - return (await readDefaultKey(path)) ? [{}] : []; +export async function discover(path = authPath()): Promise { + return (await readDefaultKey(path)) ? [{ key: hashAccountKey(path), input: { path } }] : []; } export async function fetchUsage( - _input: Input, + input: Input, fetchApi: typeof fetch = fetch, - path = authPath(), ): Promise { - const apiKey = await readDefaultKey(path); - if (!apiKey) return { status: "unavailable", windows: [] }; + const apiKey = await readDefaultKey(input.path); + if (!apiKey) throw new Error("OpenCode Go login store no longer exists"); const response = await fetchApi("https://opencode.ai/zen/go/v1/usage", { headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, signal: AbortSignal.timeout(15_000), }); if (response.status === 401 || response.status === 403) - return { status: "unavailable", windows: [] }; + return unavailable({ kind: "rejected", status: response.status, refreshedBy: "opencode" }); if (!response.ok) throw new Error(`OpenCode Go usage API returned ${response.status}`); const data = responseSchema.parse(await response.json()); const windows = ( @@ -82,8 +83,3 @@ export async function fetchUsage( ); return { status: "available", planLabel: "Go", windows }; } - -export async function identify(_input: Input, path = authPath()) { - const key = await readDefaultKey(path); - return key ? { key: hashAccountKey(key) } : null; -} diff --git a/plugins/opencode-go-usage-source/shared/input.ts b/plugins/opencode-go-usage-source/shared/input.ts index 9ba7acfb94..6e3ffd1719 100644 --- a/plugins/opencode-go-usage-source/shared/input.ts +++ b/plugins/opencode-go-usage-source/shared/input.ts @@ -1,4 +1,3 @@ import { z } from "zod"; - -export const inputSchema = z.object({}).strict(); +export const inputSchema = z.object({ path: z.string().min(1) }).strict(); export type Input = z.infer; diff --git a/plugins/zai-usage-source/index.server.ts b/plugins/zai-usage-source/index.server.ts index b4fd86943a..78dfdf7fda 100644 --- a/plugins/zai-usage-source/index.server.ts +++ b/plugins/zai-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { fetchUsage, identify } from "./server/usage.js"; +import { fetchUsage, discover } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Z.ai", icon: "icon.svg", input: inputSchema, - discover: async () => [{}], - identify, + discover: () => discover(), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/zai-usage-source/server/usage.test.ts b/plugins/zai-usage-source/server/usage.test.ts index 164543c00d..d63602cd73 100644 --- a/plugins/zai-usage-source/server/usage.test.ts +++ b/plugins/zai-usage-source/server/usage.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { identify, fetchUsage } from "./usage.js"; +import { discover, fetchUsage } from "./usage.js"; import type { UsageReport } from "@getpaseo/plugin/server/usage"; function mockFetch(handlers: Map Response>): typeof fetch { @@ -65,14 +65,14 @@ describe("zai usage source", () => { ) { return { listUsage: async () => { - const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + const report = await fetchFirst((url, init) => fetchApi(url, init)); return { providers: [ { providerId: "zai", ...report, - error: report.error ?? null, - planLabel: report.planLabel ?? null, + error: report.status === "error" ? report.error : null, + planLabel: report.status === "available" ? (report.planLabel ?? null) : null, }, ], }; @@ -118,17 +118,60 @@ describe("zai usage source", () => { }); }); -it("identify returns a key when fetch finds zai credentials", async () => { +it("discovery returns a locator when fetch finds zai credentials", async () => { const previous = process.env["ZAI_API_KEY"]; try { process.env["ZAI_API_KEY"] = "fixture-token"; let requested = false; - await fetchUsage({}, async () => { + await fetchFirst(async () => { requested = true; return new Response(null, { status: 401 }); }); expect(requested).toBe(true); - expect(await identify()).toEqual({ key: "default" }); + expect(await discover()).toEqual([ + { key: "default", input: { store: "env", locator: "ZAI_API_KEY" } }, + ]); + } finally { + if (previous === undefined) delete process.env["ZAI_API_KEY"]; + else process.env["ZAI_API_KEY"] = previous; + } +}); + +describe("account discovery", () => { + it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const directory = await mkdtemp(join(tmpdir(), "usage-empty-")); + const original = { ...process.env }; + try { + for (const key of Object.keys(process.env)) delete process.env[key]; + process.env.HOME = directory; + process.env.USERPROFILE = directory; + if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}"); + expect(await discover()).toEqual([]); + } finally { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, original); + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +async function fetchFirst(fetchApi: typeof fetch) { + const accounts = await discover(); + const account = accounts[0]; + if (!account) throw new Error("No configured account"); + return fetchUsage(account.input as Parameters[0], fetchApi); +} + +it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => { + const previous = process.env["ZAI_API_KEY"]; + try { + process.env["ZAI_API_KEY"] = "fixture-rejected-login"; + const report = await fetchUsage( + { store: "env", locator: "ZAI_API_KEY" }, + async () => new Response(null, { status }), + ); + expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } }); } finally { if (previous === undefined) delete process.env["ZAI_API_KEY"]; else process.env["ZAI_API_KEY"] = previous; diff --git a/plugins/zai-usage-source/server/usage.ts b/plugins/zai-usage-source/server/usage.ts index 432990f374..9c07fa6d4e 100644 --- a/plugins/zai-usage-source/server/usage.ts +++ b/plugins/zai-usage-source/server/usage.ts @@ -1,7 +1,8 @@ import type { UsageInput } from "../shared/input.js"; import { z } from "zod"; import { - unavailableUsage, + unavailable, + type UsageAccount, type UsageReport, type UsageDetail, } from "@getpaseo/plugin/server/usage"; @@ -28,10 +29,8 @@ export async function fetchUsage( input: UsageInput, fetchApi: typeof fetch = fetch, ): Promise { - void input; - - const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"]; - if (!token) return unavailableUsage(); + const token = process.env[input.locator]; + if (!token) throw new Error("Z.ai login store no longer exists"); const res = await fetchApi("https://api.z.ai/api/biz/subscription/list", { signal: AbortSignal.timeout(15_000), @@ -41,13 +40,13 @@ export async function fetchUsage( }, }); - if (!res.ok) { - return unavailableUsage(); - } + if (res.status === 401 || res.status === 403) + return unavailable({ kind: "rejected", status: res.status }); + if (!res.ok) throw new Error(`Z.ai usage API returned ${res.status}`); const resp = ZaiUsageResponseSchema.parse(await res.json()); const sub = resp.data?.[0]; - if (!sub) return unavailableUsage(); + if (!sub) return unavailable({ kind: "no_quota", detail: "No active coding plan" }); const details: UsageDetail[] = []; if (sub.status) details.push({ id: "status", label: "Status", value: sub.status }); @@ -65,6 +64,9 @@ export async function fetchUsage( }; } -export async function identify() { - return process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"] ? { key: "default" } : null; +export async function discover(): Promise { + for (const locator of ["ZAI_API_KEY", "GLM_API_KEY"]) { + if (process.env[locator]) return [{ key: "default", input: { store: "env", locator } }]; + } + return []; } diff --git a/plugins/zai-usage-source/shared/input.ts b/plugins/zai-usage-source/shared/input.ts index 9ee4593341..0d34ddf4e4 100644 --- a/plugins/zai-usage-source/shared/input.ts +++ b/plugins/zai-usage-source/shared/input.ts @@ -1,4 +1,8 @@ import { z } from "zod"; -export const inputSchema = z.object({}).strict(); - +export const inputSchema = z + .object({ + store: z.literal("env"), + locator: z.string().min(1), + }) + .strict(); export type UsageInput = z.infer; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 80c21f4e8b..f92a6b06a4 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -309,13 +309,78 @@ SVG or URL. ### Usage sources -**Requires Paseo 0.9.3 or newer.** Server plugins register a usage source with `server.registerUsageSource()` and import types and helpers from `@getpaseo/plugin/server/usage`. +**Requires Paseo 0.11.** Register a source from your server entry with +`server.registerUsageSource()`. Import its contract and helpers from +`@getpaseo/plugin/server/usage`. + +A source implements two calls: + +```ts +interface UsageAccount { + key: string; + label?: string; + input: JsonValue; +} + +interface UsageSourceRegistration { + id: string; + label: string; + icon?: string; + input: ZodType; + discover(): Promise; + fetch(input: unknown): Promise; +} + +type UsageReport = + | { + status: "available"; + planLabel?: string; + windows: UsageWindow[]; + balances?: UsageBalance[]; + details?: UsageDetail[]; + } + | { status: "unavailable"; problem: UsageProblem } + | { status: "error"; error: string }; + +type UsageProblem = + | { kind: "expired"; expiresAt: string; refreshedBy?: string } + | { kind: "rejected"; status: number; refreshedBy?: string } + | { kind: "no_quota"; detail: string }; +``` + +Return every account whose login exists on the machine from `discover()`, including expired +logins. Return `[]` when none exists. Discovery takes no arguments and must work independently of +agent sessions and provider names. Inputs name credential stores; never put credentials in inputs +or reports. Paseo validates each input against your schema before calling `fetch()`. + +Use a stable account key: 1–128 characters from `[A-Za-z0-9._-]`. It identifies the account or +organization whose quota is metered and survives token rotation. Never use a credential or raw +email as the key. Use `hashAccountKey()` for sensitive stable identities or a store locator when +account metadata is unavailable. Labels can name accounts without becoming their identity. + +Return logins in preference order. Several entries with the same key become one card, with their +inputs tried in order until a report is `available`. An unavailable report, error report, or thrown +fetch falls through to the next input. If none succeeds, the card carries the last report. +Discovery failures are logged by the daemon and produce no card. + +Re-read the selected store in `fetch()` so the CLI's token rotations take effect. Never redeem +refresh tokens or write credential stores: refreshing elsewhere can invalidate the CLI's copy, +and rewriting parsed files can discard fields you do not model. If the store disappeared after +discovery, throw; the card shows the error until the next discovery removes it. + +Use `unavailable(problem)` to explain why an existing login cannot supply quota. It requires a +problem; there is no zero-argument form. `expiresAt` is an ISO timestamp. A rejected login carries +the upstream HTTP status. `refreshedBy` is a CLI name, such as `claude`, `codex`, `opencode`, `omp`, or +`pi`. Paseo owns the remedy sentence. Do not put instructions or user-facing sentences in that +field. For `no_quota`, `detail` is displayed verbatim. ```ts import { z } from "zod"; import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { hashAccountKey, unavailable } from "@getpaseo/plugin/server/usage"; +import { findLoginStores, readLogin, readQuota } from "./server/logins"; -const input = z.object({ account: z.string() }); +const input = z.object({ path: z.string() }).strict(); export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -323,31 +388,42 @@ export default function contribute(server: PluginServerContext) { label: "Example", icon: "icon.svg", input, - discover: async () => [{ account: "default" }], - identify: async (value) => { - const { account } = input.parse(value); - return { key: account }; + discover: async () => + (await findLoginStores()).map((path) => ({ + key: hashAccountKey(path), + input: { path }, + })), + fetch: async (value) => { + const { path } = input.parse(value); + const login = await readLogin(path); + if (login.expiresAt <= Date.now()) + return unavailable({ + kind: "expired", + expiresAt: new Date(login.expiresAt).toISOString(), + refreshedBy: "example", + }); + return readQuota(login); }, - fetch: async () => ({ status: "available", windows: [] }), }); return () => {}; } ``` -`discover()` is required and supplies configured inputs; return `[]` when no account is configured. `identify(input)` returns a stable account key and optional display label without fetching usage, or `null` when there are no credentials. The daemon combines the source ID and key as `:`. The key must be 1–128 characters from `[A-Za-z0-9._-]`, remain stable across token rotation, and identify the account or organization whose quota is metered. Never use a credential or raw email as the key; use `hashAccountKey(value)` when the only stable identity is sensitive. +Built-in Claude discovery prefers the macOS Keychain login and uses the Claude Code credential +file only when Keychain is empty. `CLAUDE_CONFIG_DIR` selects that file's directory. Fresh tokens +use the OAuth profile's account and organization IDs; expired or rejected profiles use a locator +hash. Codex prefers Codex CLI, OpenCode, Pi, then OMP and groups by the ChatGPT account ID from stored +metadata or JWT claims. Pi and OMP logins remain discoverable when expired. OMP requires +`node:sqlite`. -Return credential-store locators in preference order from `discover()`. The daemon groups inputs with the same source and account key into one card, keeps that order, and tries each login until `fetch()` returns `available`. An unavailable or error report, thrown failure, or missing login falls through to the next input. If none succeeds, the card carries the last attempted report (or `unavailable` if no login remains). Before fetching, the daemon identifies the login again and skips it if its account changed. Re-read credentials in `fetch()` so the harness's token rotations take effect; never refresh or write its tokens. Keep raw credentials out of discovery inputs and reports. +`usage.list_reports` discovers accounts when called without IDs. With IDs, it refreshes known +accounts without rediscovering identity. If a store switches accounts, the existing card shows the +new login's quota until the next discovery. Reports are cached for five minutes; `forceRefresh` +bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Clients gate the feature +on `server_info.features.usageSources`. The older `provider.usage.list` RPC maps the same reports +for 0.10 clients and renders problems into its `error` string. -Built-in subscription sources discover logins independently of agents and provider names: - -| Source | Login preference order | Account key | -| ------ | -------------------------------------------------------- | ----------------------------------------------------- | -| Codex | Codex CLI (`$CODEX_HOME`, `~/.codex`), OpenCode, Pi, OMP | ChatGPT account ID from stored metadata or JWT claims | -| Claude | Claude Code credential file, macOS keychain, Pi, OMP | OAuth account UUID and organization UUID | - -OpenCode honors `XDG_DATA_HOME`; Pi honors `PI_CODING_AGENT_DIR`. OMP honors its directory, profile, and XDG settings, reads enabled unexpired OAuth rows by credential ID, and is skipped when the host lacks `node:sqlite`. Claude Code honors `CLAUDE_CONFIG_DIR`. Each Claude login's OAuth profile supplies its identity. - -`usage.list_reports` discovers reports when called without IDs, or reads only the requested known IDs. It caches each report for five minutes and `forceRefresh` refreshes only the returned IDs. Each entry carries `id`, `account.label`, and `fetchedAt`; `fetch()` returns a `UsageReport` with `status` (`available`, `unavailable`, or `error`), optional `planLabel`, and generic `windows`, `balances`, and `details`. The icon is a path to a self-contained SVG under the plugin directory and follows the provider icon restrictions above. +The source icon follows the provider SVG restrictions above. ## Entry point and cleanup