mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6166a7aca5 | ||
|
|
31cd18e748 | ||
|
|
05e94ab52e | ||
|
|
b5b43edd65 |
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
sha256-wS6J8HSLqxaFmdX849bowzUkTnPueRM61rcdcvFEUuQ=
|
||||
sha256-UZ9fPdCIJzghuxPXK9Kzt5fJmjyVwfaWsGiwXAh/zZI=
|
||||
|
||||
Generated
+28
-28
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.2",
|
||||
"version": "0.11.0-beta.3",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.2",
|
||||
"version": "0.11.0-beta.3",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
+11
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.11.0-beta.2",
|
||||
"version": "0.11.0-beta.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,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 [];
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
|
||||
@@ -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 } : {}) };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 () => {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 () => {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0-beta.2",
|
||||
"version": "0.11.0-beta.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 () => {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user