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