Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 6166a7aca5 chore(release): cut 0.11.0-beta.3 2026-10-02 02:15:43 +02:00
Mohamed Boudra 31cd18e748 docs(changelog): add 0.11.0-beta.3 release notes 2026-10-02 01:57:28 +02:00
Mohamed Boudra 05e94ab52e Keep usage logins visible and explain unavailable quota (#5876)
* fix(usage): keep existing logins visible and explain unavailable quota

Credential discovery must preserve accounts whose login exists even when it has expired. Keep vendor failures on the discovered account and isolate discovery failures without inventing provider cards.

* test(usage): align credential route tests with account discovery
2026-10-02 00:30:11 +01:00
paseo-ai[bot] b5b43edd65 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-01 14:35:44 +00:00
86 changed files with 1991 additions and 1166 deletions
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## 0.11.0-beta.3 - 2026-10-02
### Added
- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876))
### Changed
- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876))
### Fixed
- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876))
- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876))
## 0.11.0-beta.2 - 2026-10-01
### Added
+2 -9
View File
@@ -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/<name>-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.
---
+1 -1
View File
@@ -1 +1 @@
sha256-wS6J8HSLqxaFmdX849bowzUkTnPueRM61rcdcvFEUuQ=
sha256-UZ9fPdCIJzghuxPXK9Kzt5fJmjyVwfaWsGiwXAh/zZI=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36415,7 +36415,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37481,12 +37481,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.0-beta.2",
"@getpaseo/protocol": "0.11.0-beta.2",
"@getpaseo/server": "0.11.0-beta.2",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/server": "0.11.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37733,10 +37733,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"dependencies": {
"@getpaseo/protocol": "0.11.0-beta.2",
"@getpaseo/relay": "0.11.0-beta.2",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/relay": "0.11.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37747,7 +37747,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37991,7 +37991,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38887,7 +38887,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38916,14 +38916,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.0-beta.2",
"@getpaseo/protocol": "0.11.0-beta.2",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38933,8 +38933,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.11.0-beta.2",
"@getpaseo/protocol": "0.11.0-beta.2",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38956,7 +38956,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38983,7 +38983,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39201,16 +39201,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.11.0-beta.2",
"@getpaseo/highlight": "0.11.0-beta.2",
"@getpaseo/plugin": "0.11.0-beta.2",
"@getpaseo/protocol": "0.11.0-beta.2",
"@getpaseo/relay": "0.11.0-beta.2",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/highlight": "0.11.0-beta.3",
"@getpaseo/plugin": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/relay": "0.11.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40131,7 +40131,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41736,7 +41736,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+114 -2
View File
@@ -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();
}
});
@@ -18,7 +18,9 @@ function forcedRefreshCount(usage: UsageReportsFixture): number {
function report(input: {
sourceId: string;
sourceLabel: string;
report: Partial<UsageReportEntry["report"]>;
report:
| Partial<Extract<UsageReportEntry["report"], { status: "available" }>>
| Exclude<UsageReportEntry["report"], { status: "available" }>;
}): 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,
},
};
}
@@ -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" },
},
},
],
],
@@ -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();
}
@@ -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,
})),
},
}
@@ -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,
})) }),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"private": true,
"main": "index.ts",
"scripts": {
+21 -7
View File
@@ -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({
<Text style={styles.name} numberOfLines={1}>
{entry.sourceLabel}
</Text>
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" size="xs" /> : null}
{usage.status === "available" && usage.planLabel ? (
<StatusBadge label={usage.planLabel} variant="muted" size="xs" />
) : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
@@ -88,15 +102,15 @@ export function UsageCard({
/>
</View>
{usage.error ? (
{message ? (
<Text style={styles.error} numberOfLines={3}>
{usage.error}
{message}
</Text>
) : null}
{usage.windows.length > 0 || balances.length > 0 ? (
{windows.length > 0 || balances.length > 0 ? (
<View style={styles.bars}>
{usage.windows.map((window) => (
{windows.map((window) => (
<PinnableWindowBar key={window.id} entry={entry} window={window} display={display} />
))}
{balances.map((balance) => (
+18
View File
@@ -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);
});
+12
View File
@@ -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",
+15
View File
@@ -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([]);
});
+1
View File
@@ -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,
);
+3 -2
View File
@@ -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<UsageReport["balances"]>[number];
export type AvailableUsageReport = Extract<UsageReport, { status: "available" }>;
export type UsageWindow = AvailableUsageReport["windows"][number];
export type UsageBalance = NonNullable<AvailableUsageReport["balances"]>[number];
export type UsageBalanceUnit = UsageBalance["unit"];
/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */
+61 -12
View File
@@ -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}
/>
)}
</Pressable>
@@ -77,6 +85,8 @@ function WindowRowContent({
isAtRisk,
percent,
tone,
pinVisible,
pinned,
}: {
highlight: StyleProp<ViewStyle>;
label: string;
@@ -85,27 +95,66 @@ function WindowRowContent({
isAtRisk: boolean;
percent: number;
tone: UsageTone;
pinVisible: boolean;
pinned: boolean;
}) {
return (
<>
<View style={highlight} pointerEvents="none" />
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value}>
{value}
{trailing ? (
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
) : null}
</Text>
<View style={styles.contentRow}>
<View style={styles.windowContent}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value}>
{value}
{trailing ? (
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
) : null}
</Text>
</View>
<UsageMeter percent={percent} tone={tone} />
</View>
<UsagePinGlyph visible={pinVisible} pinned={pinned} />
</View>
<UsageMeter percent={percent} tone={tone} />
</>
);
}
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 (
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<View
style={visible ? styles.pin : styles.pinHidden}
testID={pinned ? "usage-pin-glyph-pinned" : "usage-pin-glyph-unpinned"}
>
<ThemedPin size={12} uniProps={iconMapping} />
</View>
</TooltipTrigger>
<TooltipContent side="top">
<Text style={styles.tooltipText}>{usageCopy.pin}</Text>
</TooltipContent>
</Tooltip>
);
}
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.
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.0-beta.2",
"@getpaseo/protocol": "0.11.0-beta.2",
"@getpaseo/server": "0.11.0-beta.2",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/server": "0.11.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.11.0-beta.2",
"@getpaseo/relay": "0.11.0-beta.2",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/relay": "0.11.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
+56 -111
View File
@@ -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();
+31 -17
View File
@@ -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({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -70,8 +70,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.0-beta.2",
"@getpaseo/protocol": "0.11.0-beta.2",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -81,8 +81,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.11.0-beta.2",
"@getpaseo/protocol": "0.11.0-beta.2",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+27 -17
View File
@@ -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<UsageAccount[]>;
/** Re-reads the login store; never writes it. */
fetch(input: unknown): Promise<UsageReport>;
discover(): Promise<JsonValue[]>;
}
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 };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+25 -8
View File
@@ -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<typeof ProviderUsageToneSchema>;
export type UsageProblem = z.infer<typeof UsageProblemSchema>;
export type UsageReport = z.infer<typeof UsageReportSchema>;
export type UsageReportEntry = z.infer<typeof UsageReportEntrySchema>;
export type UsageListReportsResponseMessage = z.infer<typeof UsageListReportsResponseMessageSchema>;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.11.0-beta.2",
"@getpaseo/highlight": "0.11.0-beta.2",
"@getpaseo/plugin": "0.11.0-beta.2",
"@getpaseo/protocol": "0.11.0-beta.2",
"@getpaseo/relay": "0.11.0-beta.2",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/highlight": "0.11.0-beta.3",
"@getpaseo/plugin": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/relay": "0.11.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
+2 -7
View File
@@ -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<string, string>();
private readonly listeners = new Set<(pluginId: string) => void>();
private readonly providers = new Map<string, ProviderRegistration>();
private readonly usageSources = new UsageSourceRegistry();
private readonly usageSources: UsageSourceRegistry;
private readonly usageSourceIdsByPlugin = new Map<string, string[]>();
private readonly providerIdsByPlugin = new Map<string, readonly string[]>();
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);
},
@@ -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<PluginProcessRequest> = 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"),
@@ -339,21 +339,14 @@ export function createPluginWorker(options: {
}
function handleUsageRequest(
message: Extract<
PluginProcessRequest,
{ type: "usage.identify" | "usage.fetch" | "usage.discover" }
>,
message: Extract<PluginProcessRequest, { type: "usage.fetch" | "usage.discover" }>,
): void {
void (async () => {
const source = usageSources.get(message.sourceId);
if (!source) throw new Error(`Unknown usage source: ${message.sourceId}`);
if (message.type === "usage.discover") return jsonTransportValue(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;
}
@@ -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 () => {};
@@ -395,17 +395,6 @@ export class PluginRuntime {
return this.plugins.get(pluginId)?.usageSources ?? [];
}
identifyUsage(pluginId: string, sourceId: string, input: unknown): Promise<unknown> {
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<unknown> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
@@ -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 {
@@ -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 {
@@ -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("<svg");
expect(first.reports[0]?.fetchedAt).toMatch(/^\d{4}-/);
expect(
@@ -41,12 +41,24 @@ test("lists built-in and subprocess usage; validates input and isolates fetch er
expect(
(await client.listUsageReports({ reportIds: ["fixture:throws"] })).reports[0]?.report.status,
).toBe("error");
const expired = first.reports.find((entry) => 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);
@@ -3,17 +3,13 @@ import { UsageSourceRegistry } from "./index.js";
function source(input: {
id: string;
discover?: () => Promise<unknown[]>;
identify?: (value: unknown) => Promise<{ key: string; label?: string } | null>;
discover?: () => Promise<Array<{ key: string; label?: string; input: unknown }>>;
fetch?: (value: unknown) => Promise<unknown>;
}) {
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<string, unknown>;
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<void>((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 },
});
});
@@ -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<unknown[]>;
identify(input: unknown): Promise<{ key: string; label?: string } | null>;
discover(): Promise<unknown>;
fetch(input: unknown): Promise<unknown>;
}
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<Logger, "warn"> = { 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<UsageReportEntry[]> {
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<string, KnownReport>();
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<UsageReportEntry> {
const known = this.known.get(id);
private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise<UsageReportEntry> {
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<UsageReport> {
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<UsageReport> {
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;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"private": true,
"type": "module",
"scripts": {
+1 -2
View File
@@ -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 () => {};
@@ -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 });
@@ -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<typeof fetchUsage>[0], env: NodeJS.ProcessEnv = {}) {
return identify(input, profile, () => 1000, lookup(env));
async function accountIdentity(
input: Parameters<typeof fetchUsage>[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<typeof discover>[0]) {
return (await discover(options, profile)).map(
(account) => account.input as Parameters<typeof fetchUsage>[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([
@@ -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 [];
+151 -34
View File
@@ -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<typeof discover>[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");
});
+72 -36
View File
@@ -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<UsageInput[]> {
export async function discover(
lookup: ClaudeCredentialLookup = {},
fetchApi: typeof fetch = fetch,
): Promise<UsageAccount[]> {
// 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<ClaudeCredentialRecord
function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRecord | null {
const oauth = credentials.claudeAiOauth;
return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null;
return oauth?.accessToken
? { oauth: { ...oauth, accessToken: oauth.accessToken }, expires: oauth.expiresAt }
: null;
}
export async function fetchUsage(
@@ -433,7 +458,7 @@ export async function fetchUsage(
return parsed;
}
async function callClaudeApi(token: string): Promise<ClaudeUsageResponse | "NEEDS_AUTH"> {
async function callClaudeApi(token: string): Promise<ClaudeUsageResponse | number> {
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<string, { at: number; result: Promise<ClaudeIdentity> }>();
function cachedProfile(token: string, now: number): Promise<ClaudeIdentity> | 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<ClaudeIdentity> {
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({
+1 -2
View File
@@ -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 () => {};
@@ -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<typeof fetchUsage>[0], env: NodeJS.ProcessEnv = {}) {
return identify(input, lookup(env));
async function accountIdentity(
input: Parameters<typeof fetchUsage>[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<typeof discover>[0]) {
return (await discover(options)).map(
(account) => account.input as Parameters<typeof fetchUsage>[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");
});
@@ -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 [];
+52 -16
View File
@@ -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("<html>Login</html>", { status: 200 }),
);
expect(report.status).toBe("unavailable");
test("HTML usage body is an error", async () => {
await expect(
fetchUsage(authInput(fixtureHome), async () => new Response("<html>Login</html>")),
).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" } });
});
+22 -12
View File
@@ -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<CodexUsageInput[]> {
export async function discover(lookup: StoreLookup = {}): Promise<UsageAccount[]> {
const env = lookup.env ?? process.env;
const home = lookup.home ?? homedir();
const paths = [
@@ -59,8 +62,11 @@ export async function discover(lookup: StoreLookup = {}): Promise<CodexUsageInpu
{ route: { store: "pi", path: piAuthPath(lookup) } },
...discoverOmp(lookup).map((route) => ({ 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<UsageReport> {
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<string, string> = {
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 } : {}) };
}
+2 -3
View File
@@ -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 () => {};
@@ -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<string, () => 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<typeof fetchUsage>[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;
+28 -39
View File
@@ -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<string | null> {
const candidates: string[] = [];
if (process.env["APPDATA"]) {
candidates.push(join(process.env["APPDATA"], "GitHub CLI", "hosts.yml"));
async function readToken(input: UsageInput): Promise<string | undefined> {
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<UsageAccount[]> {
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<UsageReport> {
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;
}
+6 -2
View File
@@ -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<typeof inputSchema>;
+2 -3
View File
@@ -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 () => {};
@@ -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<typeof fetchUsage>[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;
+53 -54
View File
@@ -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<string | null> {
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<string | null> {
// 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<string | null
return null; // runtime without node:sqlite
}
for (const path of dbPaths) {
if (!existsSync(path)) continue;
let db: CursorStateDatabase | undefined;
try {
db = new sqlite.DatabaseSync(path, { readOnly: true });
const token = cursorTokenFromDb(db);
if (token) return token;
} catch {
// Locked, permission, corrupt, or schema failures try the next candidate.
} finally {
db?.close();
}
if (!existsSync(path)) return null;
let db: CursorStateDatabase | undefined;
try {
db = new sqlite.DatabaseSync(path, { readOnly: true });
return cursorTokenFromDb(db);
} catch {
return null;
} finally {
db?.close();
}
return null;
}
async function readCursorTokenFromAuthJson(homeDir: string): Promise<string | null> {
const path = join(homeDir, ".config", "cursor", "auth.json");
async function readCursorTokenFromAuthJson(path: string): Promise<string | null> {
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<UsageReport> {
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<string | undefined> {
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<UsageAccount[]> {
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 [];
}
+6 -2
View File
@@ -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<typeof inputSchema>;
+2 -3
View File
@@ -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 () => {};
+50 -7
View File
@@ -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<string, unknown>): 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<typeof fetchUsage>[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;
+23 -27
View File
@@ -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<UsageReport> {
void input;
const homeDir = homedir();
async function readGrokToken(): Promise<string | null> {
// 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<string | undefined> {
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<UsageAccount[]> {
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 [];
}
+6 -2
View File
@@ -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<typeof inputSchema>;
+2 -3
View File
@@ -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 () => {};
+54 -11
View File
@@ -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<typeof fetchUsage>[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;
+44 -67
View File
@@ -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<UsageReport> {
void input;
const homeDir = homedir();
async function callUsageApi(token: string): Promise<Response> {
return fetchApi(KIMI_USAGE_URL, {
signal: AbortSignal.timeout(15_000),
@@ -264,52 +262,19 @@ export async function fetchUsage(
});
}
async function readCredentials(): Promise<KimiCredentials | null> {
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<KimiAuth | null> {
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<KimiCredentials | undefined> {
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<UsageAccount[]> {
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 [];
}
+6 -2
View File
@@ -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<typeof inputSchema>;
+2 -3
View File
@@ -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 () => {};
@@ -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<string, unknown>): 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<typeof fetchUsage>[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;
+64 -100
View File
@@ -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<UsageReport> {
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<MiniMaxResolvedAuth | null> {
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<z.infer<typeof MiniMaxCredentialsSchema> | 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<z.infer<typeof MiniMaxConfigSchema> | 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<UsageAccount[]> {
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;
}
+6 -2
View File
@@ -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<typeof inputSchema>;
+9 -9
View File
@@ -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),
};
}
+16 -8
View File
@@ -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([]);
});
@@ -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 () => {};
@@ -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([]);
});
@@ -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<string | null>
}
}
export async function discover(path = authPath()): Promise<Array<{}>> {
return (await readDefaultKey(path)) ? [{}] : [];
export async function discover(path = authPath()): Promise<UsageAccount[]> {
return (await readDefaultKey(path)) ? [{ key: hashAccountKey(path), input: { path } }] : [];
}
export async function fetchUsage(
_input: Input,
input: Input,
fetchApi: typeof fetch = fetch,
path = authPath(),
): Promise<UsageReport> {
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;
}
@@ -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<typeof inputSchema>;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"private": true,
"type": "module",
"scripts": {
+2 -3
View File
@@ -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 () => {};
+50 -7
View File
@@ -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<string, () => 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<typeof fetchUsage>[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;
+13 -11
View File
@@ -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<UsageReport> {
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<UsageAccount[]> {
for (const locator of ["ZAI_API_KEY", "GLM_API_KEY"]) {
if (process.env[locator]) return [{ key: "default", input: { store: "env", locator } }];
}
return [];
}
+6 -2
View File
@@ -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<typeof inputSchema>;
+95 -19
View File
@@ -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<UsageAccount[]>;
fetch(input: unknown): Promise<UsageReport>;
}
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 `<sourceId>:<accountKey>`. 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