Keep usage logins visible and explain unavailable quota (#5876)

* fix(usage): keep existing logins visible and explain unavailable quota

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

* test(usage): align credential route tests with account discovery
This commit is contained in:
Mohamed Boudra
2026-10-02 00:30:11 +01:00
committed by GitHub
parent b5b43edd65
commit 05e94ab52e
70 changed files with 1919 additions and 1110 deletions
+1 -2
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { discover, fetchUsage, identify } from "./server/usage.js";
import { discover, fetchUsage } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -9,7 +9,6 @@ export default function contribute(server: PluginServerContext) {
icon: "icon.svg",
input: inputSchema,
discover: () => discover(),
identify,
fetch: fetchUsage,
});
return () => {};
@@ -90,11 +90,19 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { discover, fetchUsage } from "./usage.js";
import { inputSchema } from "../shared/input.js";
function fixtureFetch(expectedToken: string): typeof fetch {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
expect(init?.headers).toMatchObject({ Authorization: `Bearer ${expectedToken}` });
return new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { status: 200 });
return new Response(
JSON.stringify({
account: { uuid: "fixture-account" },
organization: { uuid: "fixture-org" },
five_hour: { utilization: 12 },
}),
{ status: 200 },
);
}) as unknown as typeof fetch;
}
@@ -125,30 +133,33 @@ describe("Claude credential routes", () => {
join(dir, ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }),
);
const [input] = await discover({
home: dir,
env: { CLAUDE_CONFIG_DIR: dir },
platform: "linux",
});
expect(input).toEqual({ route: { store: "claude", path: join(dir, ".credentials.json") } });
expect((await fetchUsage(input!, fixtureFetch("fixture-default"))).status).toBe("available");
const accounts = await discover(
{ home: dir, env: { CLAUDE_CONFIG_DIR: dir }, platform: "linux" },
fixtureFetch("fixture-default"),
);
expect(accounts).toEqual([
{
key: "fixture-account.fixture-org",
input: { route: { store: "claude", path: join(dir, ".credentials.json") } },
},
]);
expect(
(await fetchUsage(inputSchema.parse(accounts[0]!.input), fixtureFetch("fixture-default")))
.status,
).toBe("available");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("stays unavailable when the routed file has no credentials", async () => {
it("throws without fetching when the routed file has no credentials", async () => {
const dir = mkdtempSync(join(tmpdir(), "claude-empty-config-"));
try {
const fetchApi = vi.fn() as unknown as typeof fetch;
expect(
(
await fetchUsage(
{ route: { store: "claude", path: join(dir, ".credentials.json") } },
fetchApi,
{ platform: "linux" },
)
).status,
).toBe("unavailable");
await expect(
fetchUsage({ route: { store: "claude", path: join(dir, ".credentials.json") } }, fetchApi, {
platform: "linux",
}),
).rejects.toThrow("Claude login store no longer exists");
expect(fetchApi).not.toHaveBeenCalled();
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { discover, fetchUsage, identify } from "./usage.js";
import { discover, fetchUsage } from "./usage.js";
let home: string;
beforeEach(async () => {
@@ -25,8 +25,21 @@ async function profile(_url: RequestInfo | URL, init?: RequestInit) {
JSON.stringify({ account: { uuid: "pi-account" }, organization: { uuid: "pi-org" } }),
);
}
async function account(input: Parameters<typeof fetchUsage>[0], env: NodeJS.ProcessEnv = {}) {
return identify(input, profile, () => 1000, lookup(env));
async function accountIdentity(
input: Parameters<typeof fetchUsage>[0],
env: NodeJS.ProcessEnv = {},
) {
const accounts = await discover(lookup(env), profile);
const account = accounts.find(
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
);
if (!account) throw new Error("Expected discovered login");
return { key: account.key, ...(account.label ? { label: account.label } : {}) };
}
async function logins(options: Parameters<typeof discover>[0]) {
return (await discover(options, profile)).map(
(account) => account.input as Parameters<typeof fetchUsage>[0],
);
}
async function database(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
@@ -76,9 +89,9 @@ test("Pi uses its own OAuth identity and re-reads rotated tokens without writing
await json(join(home, ".claude.json"), {
oauthAccount: { accountUuid: "other", organizationUuid: "other-org" },
});
const inputs = await discover(lookup());
const inputs = await logins(lookup());
expect(inputs).toHaveLength(1);
expect(await account(inputs[0]!)).toEqual({ key: "pi-account.pi-org" });
expect(await accountIdentity(inputs[0]!)).toEqual({ key: "pi-account.pi-org" });
await json(path, {
anthropic: { type: "oauth", access: "fixture-rotated", accountId: "pi-account" },
});
@@ -103,18 +116,18 @@ test("Pi honors directory override and ignores API keys and malformed files", as
const env = { PI_CODING_AGENT_DIR: directory };
const path = join(directory, "auth.json");
await json(path, { anthropic: { type: "oauth", access: "fixture-pi", accountId: "pi-account" } });
expect(await discover(lookup(env))).toEqual([{ route: { store: "pi", path } }]);
expect(await logins(lookup(env))).toEqual([{ route: { store: "pi", path } }]);
await json(path, { anthropic: { type: "api_key", key: "fixture-key" } });
expect(await discover(lookup(env))).toEqual([]);
expect(await logins(lookup(env))).toEqual([]);
await writeFile(path, "broken");
expect(await discover(lookup(env))).toEqual([]);
expect(await logins(lookup(env))).toEqual([]);
});
test("expired Pi tokens are unavailable without calling usage or refreshing", async () => {
await json(join(home, ".pi", "agent", "auth.json"), {
anthropic: { type: "oauth", access: "fixture-expired", accountId: "pi-account", expires: 500 },
});
const [input] = await discover(lookup());
const [input] = await logins(lookup());
expect(
(
await fetchUsage(
@@ -129,7 +142,7 @@ test("expired Pi tokens are unavailable without calling usage or refreshing", as
});
for (const location of ["default", "profile", "xdg", "override", "config"]) {
test(`OMP discovers enabled live OAuth rows in ${location} store read-only`, async () => {
test(`OMP discovers enabled OAuth rows including expired logins in ${location} store read-only`, async () => {
let path = join(home, ".omp", "agent", "agent.db");
let env: NodeJS.ProcessEnv = { OMP_AUTH_BROKER_URL: "https://broker.test" };
if (location === "profile") {
@@ -151,9 +164,12 @@ for (const location of ["default", "profile", "xdg", "override", "config"]) {
}
await database(path);
const before = await readFile(path);
const inputs = await discover(lookup(env));
expect(inputs).toEqual([{ route: { store: "omp", path, credentialId: 1 } }]);
expect(await account(inputs[0]!, env)).toEqual({ key: "pi-account.pi-org" });
const inputs = await logins(lookup(env));
expect(inputs).toEqual([
{ route: { store: "omp", path, credentialId: 1 } },
{ route: { store: "omp", path, credentialId: 2 } },
]);
expect(await accountIdentity(inputs[0]!, env)).toEqual({ key: "pi-account.pi-org" });
expect(
(
await fetchUsage(
@@ -175,56 +191,52 @@ test("missing SQLite skips only OMP", async () => {
await json(join(home, ".pi", "agent", "auth.json"), {
anthropic: { type: "oauth", access: "fixture-pi" },
});
const inputs = await discover({ ...lookup(), sqlite: () => undefined });
const inputs = await logins({ ...lookup(), sqlite: () => undefined });
expect(inputs).toEqual([
{ route: { store: "pi", path: join(home, ".pi", "agent", "auth.json") } },
]);
});
test("Claude file and keychain are independent discovery candidates", async () => {
const path = join(home, ".claude", ".credentials.json");
await json(path, { claudeAiOauth: { accessToken: "fixture-file" } });
const inputs = await discover({
test("Claude Keychain login replaces the fallback file", async () => {
await json(join(home, ".claude", ".credentials.json"), {
claudeAiOauth: { accessToken: "fixture-file", expiresAt: 500 },
});
const inputs = await logins({
...lookup(),
platform: "darwin",
readKeychainCredentials: async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } }),
});
expect(inputs).toEqual([{ route: { store: "claude", path } }, { route: { store: "keychain" } }]);
expect(inputs).toEqual([{ route: { store: "keychain" } }]);
});
test("Claude account metadata alone does not discover a login", async () => {
await json(join(home, ".claude.json"), {
oauthAccount: { accountUuid: "old", organizationUuid: "old-org" },
});
expect(await discover(lookup())).toEqual([]);
expect(
await identify(
{ route: { store: "claude", path: join(home, ".claude", ".credentials.json") } },
profile,
Date.now,
lookup(),
),
).toBeNull();
expect(await logins(lookup())).toEqual([]);
});
test("keychain identity belongs to its token even when Claude Code metadata names another account", async () => {
await json(join(home, ".claude.json"), {
oauthAccount: { accountUuid: "stale", organizationUuid: "stale-org" },
});
const identity = await identify({ route: { store: "keychain" } }, profile, () => 1000, {
...lookup(),
platform: "darwin",
readKeychainCredentials: async () => ({
claudeAiOauth: { accessToken: "fixture-keychain-other" },
}),
});
expect(identity).toEqual({ key: "pi-account.pi-org" });
const [identity] = await discover(
{
...lookup(),
platform: "darwin",
readKeychainCredentials: async () => ({
claudeAiOauth: { accessToken: "fixture-keychain-other" },
}),
},
profile,
);
expect(identity).toEqual({ key: "pi-account.pi-org", input: { route: { store: "keychain" } } });
});
test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => {
const path = join(home, ".omp", "agent", "agent.db");
await database(path);
const [input] = await discover(lookup());
const [input] = await logins(lookup());
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run(
@@ -246,17 +258,15 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login
).toBe("unavailable");
db.prepare("UPDATE auth_credentials SET disabled_cause = 'revoked' WHERE id = 1").run();
db.close();
expect(
(
await fetchUsage(
input!,
async () => {
throw new Error("disabled login must not fetch");
},
lookup(),
)
).status,
).toBe("unavailable");
await expect(
fetchUsage(
input!,
async () => {
throw new Error("must not fetch");
},
lookup(),
),
).rejects.toThrow("login store no longer exists");
});
test("Claude discovery honors CLAUDE_CONFIG_DIR", async () => {
@@ -268,7 +278,7 @@ test("Claude discovery honors CLAUDE_CONFIG_DIR", async () => {
await json(join(ignoredDirectory, ".credentials.json"), {
claudeAiOauth: { accessToken: "fixture-other" },
});
const inputs = await discover(
const inputs = await logins(
lookup({ CLAUDE_CONFIG_DIR: directory, CLAUDE_HOME: ignoredDirectory }),
);
expect(inputs).toEqual([
@@ -97,7 +97,6 @@ function ompRows(path: string, lookup: StoreLookup) {
if (!row.success) return [];
try {
const oauth = oauthSchema.parse(JSON.parse(row.data.data));
if (oauth.expires === undefined || oauth.expires <= (lookup.now ?? Date.now)()) return [];
return [{ id: row.data.id, oauth }];
} catch {
return [];
+151 -34
View File
@@ -2,9 +2,9 @@ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchUsage } from "./usage.js";
import { fetchUsage, discover } from "./usage.js";
import { inputSchema, type UsageInput } from "../shared/input.js";
import type { UsageReport } from "@getpaseo/plugin/server/usage";
import { hashAccountKey, type UsageReport } from "@getpaseo/plugin/server/usage";
function credentialInput(directory: string): UsageInput {
return { route: { store: "claude", path: join(directory, ".credentials.json") } };
@@ -189,15 +189,13 @@ describe("claude usage source", () => {
});
});
it("returns unavailable Claude usage when credentials are missing", async () => {
fetchApi = vi.fn() as never;
const result = await service().listUsage();
const claude = findProvider(result, "claude");
expect(claude.status).toBe("unavailable");
expect(fetchApi).not.toHaveBeenCalled();
});
it.each(["empty home", "unrelated files"])(
"discovers no Claude logins in %s",
async (scenario) => {
if (scenario === "unrelated files") writeFileSync(join(homeDir, "unrelated.json"), "{}");
expect(await discover({ home: homeDir, env: {}, platform: "linux" })).toEqual([]);
},
);
it("returns unavailable on 401 without refreshing or rewriting credentials", async () => {
writeClaudeCredentials(claudeHome, "at_expired", "rt_valid");
@@ -639,9 +637,8 @@ describe("Claude usage source scoped limit reconciliation", () => {
});
});
it("identify uses the credential token profile instead of stale Claude config", async () => {
it("discovery uses the credential token profile instead of stale Claude config", async () => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const { identify } = await import("./usage.js");
const directory = await mkdtemp(join(tmpdir(), "claude-identity-"));
try {
await writeFile(
@@ -656,7 +653,7 @@ it("identify uses the credential token profile instead of stale Claude config",
);
writeClaudeCredentials(directory, "fixture-local-account");
expect(
await identify(
await accountFor(
credentialInput(directory),
async () =>
jsonResponse({
@@ -676,7 +673,6 @@ it("identify uses the credential token profile instead of stale Claude config",
});
it("Claude credentials without account metadata use a cached OAuth profile identity", async () => {
const { identify } = await import("./usage.js");
let calls = 0;
const fetchProfile: typeof fetch = async () => {
calls++;
@@ -690,13 +686,13 @@ it("Claude credentials without account metadata use a cached OAuth profile ident
};
const lookup = tokenLookup("fixture-claude-token");
expect(
await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup),
await accountFor(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup),
).toEqual({
key: "account-uuid.org-uuid",
label: "test@example.com",
});
expect(
await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup),
await accountFor(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup),
).toEqual({
key: "account-uuid.org-uuid",
label: "test@example.com",
@@ -705,7 +701,6 @@ it("Claude credentials without account metadata use a cached OAuth profile ident
});
it("identifies the same credential route that fetch uses when oauthAccount is absent", async () => {
const { identify } = await import("./usage.js");
const directory = mkdtempSync(join(tmpdir(), "claude-credential-identity-"));
try {
writeClaudeCredentials(directory, "fixture-credential-token");
@@ -721,7 +716,7 @@ it("identifies the same credential route that fetch uses when oauthAccount is ab
);
expect(usageRequested).toBe(true);
let profileRequested = false;
const account = await identify(
const account = await accountFor(
credentialInput(directory),
async () => {
profileRequested = true;
@@ -741,7 +736,6 @@ it("identifies the same credential route that fetch uses when oauthAccount is ab
});
it("expires cached token profiles after five minutes", async () => {
const { identify } = await import("./usage.js");
let now = 0;
let calls = 0;
const fetchProfile: typeof fetch = async () => {
@@ -750,16 +744,15 @@ it("expires cached token profiles after five minutes", async () => {
};
const lookup = tokenLookup("ttl-fixture-token");
const input = credentialInput(lookup.claudeHome);
await identify(input, fetchProfile, () => now, lookup);
await identify(input, fetchProfile, () => now, lookup);
await accountFor(input, fetchProfile, () => now, lookup);
await accountFor(input, fetchProfile, () => now, lookup);
expect(calls).toBe(1);
now = 300_001;
await identify(input, fetchProfile, () => now, lookup);
await accountFor(input, fetchProfile, () => now, lookup);
expect(calls).toBe(2);
});
it("identify and fetch share the explicit macOS keychain route", async () => {
const { identify } = await import("./usage.js");
it("discovery and fetch share the explicit macOS keychain route", async () => {
const directory = mkdtempSync(join(tmpdir(), "claude-keychain-identity-"));
try {
const lookup = {
@@ -780,7 +773,7 @@ it("identify and fetch share the explicit macOS keychain route", async () => {
lookup,
);
expect(usageRequested).toBe(true);
const account = await identify(
const account = await accountFor(
{ route: { store: "keychain" } },
async () =>
jsonResponse({
@@ -796,8 +789,7 @@ it("identify and fetch share the explicit macOS keychain route", async () => {
}
});
it("identify and fetch share the explicit Claude credential route", async () => {
const { identify } = await import("./usage.js");
it("discovery and fetch share the explicit Claude credential route", async () => {
const directory = mkdtempSync(join(tmpdir(), "claude-home-identity-"));
try {
writeClaudeCredentials(directory, "home-fixture-token");
@@ -812,7 +804,7 @@ it("identify and fetch share the explicit Claude credential route", async () =>
lookup,
);
expect(usageRequested).toBe(true);
const identity = await identify(
const identity = await accountFor(
credentialInput(directory),
async () =>
jsonResponse({
@@ -829,7 +821,6 @@ it("identify and fetch share the explicit Claude credential route", async () =>
});
it("bounds cached profiles across many token rotations", async () => {
const { identify } = await import("./usage.js");
let calls = 0;
const fetchProfile: typeof fetch = async () => {
calls++;
@@ -837,10 +828,10 @@ it("bounds cached profiles across many token rotations", async () => {
};
for (let index = 0; index < 129; index++) {
const lookup = tokenLookup(`rotation-fixture-${index}`);
await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup);
await accountFor(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup);
}
const lookup = tokenLookup("rotation-fixture-0");
await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup);
await accountFor(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup);
expect(calls).toBe(130);
});
@@ -863,7 +854,6 @@ function tokenLookup(accessToken: string) {
}
it("discovers a Pi OAuth login independently of the CLI login", async () => {
const { discover } = await import("./usage.js");
const { mkdir, mkdtemp, writeFile, rm } = await import("node:fs/promises");
const directory = await mkdtemp(join(tmpdir(), "usage-pi-discovery-"));
try {
@@ -875,10 +865,137 @@ it("discovers a Pi OAuth login independently of the CLI login", async () => {
}),
);
const inputs = await discover({ home: directory, env: {}, platform: "linux" });
expect(inputs).toContainEqual({
expect(inputs.map((account) => account.input)).toContainEqual({
route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") },
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("keeps an expired Keychain login without calling the network and prefers Keychain over the fallback file", async () => {
const directory = mkdtempSync(join(tmpdir(), "claude-expired-"));
try {
writeClaudeCredentials(directory, "stale-file");
const lookup = {
home: directory,
claudeHome: directory,
env: {},
platform: "darwin" as const,
now: () => 10_800_000,
readKeychainCredentials: async () => ({
claudeAiOauth: { accessToken: "expired-keychain", expiresAt: 0 },
}),
};
let calls = 0;
const network: typeof fetch = async () => {
calls++;
throw new Error("must not call network");
};
const accounts = await discover(lookup, network);
expect(accounts).toEqual([
{ key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { route: { store: "keychain" } } },
]);
expect(await fetchUsage(accounts[0]!.input as UsageInput, network, lookup)).toEqual({
status: "unavailable",
problem: { kind: "expired", expiresAt: "1970-01-01T00:00:00.000Z", refreshedBy: "claude" },
});
expect(calls).toBe(0);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
async function accountFor(
input: UsageInput,
fetchApi: typeof fetch,
now: () => number,
lookup: Parameters<typeof discover>[0] = {},
) {
const accounts = await discover({ ...lookup, now }, fetchApi);
const account = accounts.find(
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
);
if (!account) throw new Error("Expected discovered login");
return { key: account.key, ...(account.label ? { label: account.label } : {}) };
}
it.each([401, 403])("fetch reports rejected usage HTTP %i", async (status) => {
const lookup = tokenLookup(`usage-rejection-${status}`);
expect(
await fetchUsage(
credentialInput(lookup.claudeHome),
async () => new Response(null, { status }),
lookup,
),
).toEqual({
status: "unavailable",
problem: { kind: "rejected", status, refreshedBy: "claude" },
});
});
it.each([401, 403])("a rejected profile HTTP %i keeps a locator-keyed account", async (status) => {
const lookup = tokenLookup(`profile-rejection-${status}`);
const api: typeof fetch = async () => new Response(null, { status });
const accounts = await discover(lookup, api);
expect(accounts).toEqual([
{ key: expect.stringMatching(/^[a-f0-9]{64}$/), input: credentialInput(lookup.claudeHome) },
]);
let fetches = 0;
expect(
await fetchUsage(
credentialInput(lookup.claudeHome),
async () => {
fetches++;
throw new Error("profile already rejected");
},
lookup,
),
).toEqual({
status: "unavailable",
problem: { kind: "rejected", status, refreshedBy: "claude" },
});
expect(fetches).toBe(0);
});
it.each([
{ failure: "HTTP 500", networkError: false, error: "Claude usage API returned 500" },
{ failure: "a network error", networkError: true, error: "Network unavailable" },
])(
"keeps a locator-keyed login when the profile fails with $failure",
async ({ networkError, error }) => {
const lookup = tokenLookup(`profile-failure-${networkError}`);
const input = credentialInput(lookup.claudeHome);
const api: typeof fetch = async () => {
if (networkError) throw new Error(error);
return new Response(null, { status: 500 });
};
const accounts = await discover(lookup, api);
expect(accounts).toEqual([{ key: hashAccountKey(JSON.stringify(input.route)), input }]);
await expect(fetchUsage(accounts[0]!.input as UsageInput, api, lookup)).rejects.toThrow(error);
},
);
it("re-reads a refreshed login at the original locator", async () => {
const lookup = tokenLookup("expiring-token");
writeFileSync(
join(lookup.claudeHome, ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "expiring-token", expiresAt: 0 } }),
);
const [account] = await discover(lookup, async () => {
throw new Error("expired token must not fetch");
});
expect(await fetchUsage(account!.input as UsageInput, fetch, lookup)).toEqual({
status: "unavailable",
problem: { kind: "expired", expiresAt: "1970-01-01T00:00:00.000Z", refreshedBy: "claude" },
});
writeClaudeCredentials(lookup.claudeHome, "rotated-token");
const report = await fetchUsage(
account!.input as UsageInput,
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer rotated-token");
return jsonResponse(makeClaudeResponse());
},
lookup,
);
expect(report.status).toBe("available");
});
+72 -36
View File
@@ -9,7 +9,8 @@ import { z } from "zod";
import {
hashAccountKey,
toneFromUsedPct,
unavailableUsage,
unavailable,
type UsageAccount,
windowFromUsedPct,
type UsageReport,
type UsageWindow,
@@ -27,6 +28,7 @@ const ClaudeCredentialsSchema = z.object({
claudeAiOauth: z
.object({
accessToken: z.string().optional(),
expiresAt: z.number().optional(),
refreshToken: z.string().optional(),
subscriptionType: z.string().optional(),
rateLimitTier: z.string().optional(),
@@ -356,20 +358,41 @@ async function keychainCredentialRecord(
return parsed.success ? toCredentialRecord(parsed.data) : null;
}
export async function discover(lookup: ClaudeCredentialLookup = {}): Promise<UsageInput[]> {
export async function discover(
lookup: ClaudeCredentialLookup = {},
fetchApi: typeof fetch = fetch,
): Promise<UsageAccount[]> {
// Claude Code only reads the file when its Keychain login is empty.
const primary: UsageInput = (await keychainCredentialRecord(lookup))
? { route: { store: "keychain" } }
: { route: { store: "claude", path: claudeCredentialPath(lookup) } };
const candidates: UsageInput[] = [
{ route: { store: "claude", path: claudeCredentialPath(lookup) } },
];
if ((lookup.platform ?? process.platform) === "darwin")
candidates.push({ route: { store: "keychain" } });
candidates.push(
primary,
{ route: { store: "pi", path: piAuthPath(lookup) } },
...discoverOmp(lookup).map((route) => ({ route })),
);
const present: UsageInput[] = [];
for (const input of candidates)
if (await resolveClaudeCredentials(input, lookup)) present.push(input);
return present;
];
const accounts: UsageAccount[] = [];
for (const input of candidates) {
const credentials = await resolveClaudeCredentials(input, lookup);
if (!credentials) continue;
const fallback = { key: hashAccountKey(JSON.stringify(input.route)), input };
if (credentials.expires !== undefined && credentials.expires <= (lookup.now ?? Date.now)()) {
accounts.push(fallback);
continue;
}
try {
const profile = await readProfile(
credentials.oauth.accessToken,
fetchApi,
lookup.now ?? Date.now,
);
accounts.push("status" in profile ? fallback : { ...profile, input });
} catch {
// Keep the login visible; fetching usage reports the vendor failure.
accounts.push(fallback);
}
}
return accounts;
}
/** Re-read the selected login; the harness owns token refresh. */
@@ -397,7 +420,9 @@ async function readCredentialFile(path: string): Promise<ClaudeCredentialRecord
function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRecord | null {
const oauth = credentials.claudeAiOauth;
return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null;
return oauth?.accessToken
? { oauth: { ...oauth, accessToken: oauth.accessToken }, expires: oauth.expiresAt }
: null;
}
export async function fetchUsage(
@@ -433,7 +458,7 @@ export async function fetchUsage(
return parsed;
}
async function callClaudeApi(token: string): Promise<ClaudeUsageResponse | "NEEDS_AUTH"> {
async function callClaudeApi(token: string): Promise<ClaudeUsageResponse | number> {
const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", {
signal: AbortSignal.timeout(15_000),
headers: {
@@ -442,28 +467,38 @@ export async function fetchUsage(
"anthropic-beta": CLAUDE_OAUTH_BETA,
},
});
if (res.status === 401 || res.status === 403) return "NEEDS_AUTH";
if (res.status === 401 || res.status === 403) return res.status;
if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`);
return ClaudeUsageResponseSchema.parse(await res.json());
}
const credentials = await resolveClaudeCredentials(input, credentialLookup);
if (!credentials) throw new Error("Claude login store no longer exists");
const refreshedBy =
input.route.store === "claude" || input.route.store === "keychain"
? "claude"
: input.route.store;
if (
!credentials ||
(credentials.expires !== undefined &&
credentials.expires <= (credentialLookup.now ?? Date.now)())
) {
return unavailableUsage();
}
credentials.expires !== undefined &&
credentials.expires <= (credentialLookup.now ?? Date.now)()
)
return unavailable({
kind: "expired",
expiresAt: new Date(credentials.expires).toISOString(),
refreshedBy,
});
const profile = await cachedProfile(
credentials.oauth.accessToken,
(credentialLookup.now ?? Date.now)(),
);
if (profile && "status" in profile)
return unavailable({ kind: "rejected", status: profile.status, refreshedBy });
const { oauth } = credentials;
const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier);
const resp = await callClaudeApi(oauth.accessToken);
if (resp === "NEEDS_AUTH") {
// Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md.
return unavailableUsage();
}
if (typeof resp === "number") return unavailable({ kind: "rejected", status: resp, refreshedBy });
const scoped = reconcileScopedLimits(
legacyScopedLimits(resp),
@@ -498,26 +533,27 @@ export async function fetchUsage(
}
// The OAuth usage endpoint meters the active organization selected by the token.
type ClaudeIdentity = { key: string; label?: string } | null;
type ClaudeIdentity = { key: string; label?: string } | { status: number };
const PROFILE_TTL_MS = 300_000;
const PROFILE_CACHE_LIMIT = 128;
const profileCache = new Map<string, { at: number; result: Promise<ClaudeIdentity> }>();
function cachedProfile(token: string, now: number): Promise<ClaudeIdentity> | undefined {
pruneProfileCache(now);
return profileCache.get(hashAccountKey(token))?.result;
}
function pruneProfileCache(now: number): void {
for (const [key, entry] of profileCache) {
if (now - entry.at >= PROFILE_TTL_MS) profileCache.delete(key);
}
}
export async function identify(
input: UsageInput,
fetchApi: typeof fetch = fetch,
now: () => number = Date.now,
credentialLookup: ClaudeCredentialLookup = {},
) {
const credentials = await resolveClaudeCredentials(input, credentialLookup);
if (!credentials) return null;
const token = credentials.oauth.accessToken;
async function readProfile(
token: string,
fetchApi: typeof fetch,
now: () => number,
): Promise<ClaudeIdentity> {
const tokenHash = hashAccountKey(token);
pruneProfileCache(now());
const pending = profileCache.get(tokenHash);
@@ -536,7 +572,7 @@ export async function identify(
"anthropic-beta": CLAUDE_OAUTH_BETA,
},
});
if (response.status === 401 || response.status === 403) return null;
if (response.status === 401 || response.status === 403) return { status: response.status };
if (!response.ok) throw new Error(`Claude profile API returned ${response.status}`);
const profile = z
.object({
+1 -2
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { discover, fetchUsage, identify } from "./server/usage.js";
import { discover, fetchUsage } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -9,7 +9,6 @@ export default function contribute(server: PluginServerContext) {
icon: "icon.svg",
input: inputSchema,
discover: () => discover(),
identify,
fetch: fetchUsage,
});
return () => {};
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { discover, fetchUsage, identify } from "./usage.js";
import { discover, fetchUsage } from "./usage.js";
let home: string;
beforeEach(async () => {
@@ -19,8 +19,21 @@ async function json(path: string, value: unknown) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(value));
}
async function account(input: Parameters<typeof fetchUsage>[0], env: NodeJS.ProcessEnv = {}) {
return identify(input, lookup(env));
async function accountIdentity(
input: Parameters<typeof fetchUsage>[0],
env: NodeJS.ProcessEnv = {},
) {
const accounts = await discover(lookup(env));
const account = accounts.find(
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
);
if (!account) throw new Error("Expected discovered login");
return { key: account.key, ...(account.label ? { label: account.label } : {}) };
}
async function logins(options: Parameters<typeof discover>[0]) {
return (await discover(options)).map(
(account) => account.input as Parameters<typeof fetchUsage>[0],
);
}
async function database(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
@@ -70,9 +83,9 @@ test("Pi uses its own OAuth identity and re-reads rotated tokens without writing
await json(join(home, ".claude.json"), {
oauthAccount: { accountUuid: "other", organizationUuid: "other-org" },
});
const inputs = await discover(lookup());
const inputs = await logins(lookup());
expect(inputs).toHaveLength(1);
expect(await account(inputs[0]!)).toEqual({ key: "pi-account" });
expect(await accountIdentity(inputs[0]!)).toEqual({ key: "pi-account" });
await json(path, {
"openai-codex": { type: "oauth", access: "fixture-rotated", accountId: "pi-account" },
});
@@ -99,11 +112,11 @@ test("Pi honors directory override and ignores API keys and malformed files", as
await json(path, {
"openai-codex": { type: "oauth", access: "fixture-pi", accountId: "pi-account" },
});
expect(await discover(lookup(env))).toEqual([{ route: { store: "pi", path } }]);
expect(await logins(lookup(env))).toEqual([{ route: { store: "pi", path } }]);
await json(path, { "openai-codex": { type: "api_key", key: "fixture-key" } });
expect(await discover(lookup(env))).toEqual([]);
expect(await logins(lookup(env))).toEqual([]);
await writeFile(path, "broken");
expect(await discover(lookup(env))).toEqual([]);
expect(await logins(lookup(env))).toEqual([]);
});
test("expired Pi tokens are unavailable without calling usage or refreshing", async () => {
@@ -115,7 +128,7 @@ test("expired Pi tokens are unavailable without calling usage or refreshing", as
expires: 500,
},
});
const [input] = await discover(lookup());
const [input] = await logins(lookup());
expect(
(
await fetchUsage(
@@ -152,9 +165,12 @@ for (const location of ["default", "profile", "xdg", "override", "config"]) {
}
await database(path);
const before = await readFile(path);
const inputs = await discover(lookup(env));
expect(inputs).toEqual([{ route: { store: "omp", path, credentialId: 1 } }]);
expect(await account(inputs[0]!, env)).toEqual({ key: "omp-account" });
const inputs = await logins(lookup(env));
expect(inputs).toEqual([
{ route: { store: "omp", path, credentialId: 1 } },
{ route: { store: "omp", path, credentialId: 2 } },
]);
expect(await accountIdentity(inputs[0]!, env)).toEqual({ key: "omp-account" });
expect(
(
await fetchUsage(
@@ -176,7 +192,7 @@ test("missing SQLite skips only OMP", async () => {
await json(join(home, ".pi", "agent", "auth.json"), {
"openai-codex": { type: "oauth", access: "fixture-pi" },
});
const inputs = await discover({ ...lookup(), sqlite: () => undefined });
const inputs = await logins({ ...lookup(), sqlite: () => undefined });
expect(inputs).toEqual([
{ route: { store: "pi", path: join(home, ".pi", "agent", "auth.json") } },
]);
@@ -199,17 +215,17 @@ test("Codex and OpenCode discover accounts in preference order with XDG and CODE
await json(join(data, "opencode", "auth.json"), {
openai: { type: "oauth", access: "fixture-open", accountId: "same" },
});
const inputs = await discover(lookup(env));
const inputs = await logins(lookup(env));
expect(inputs.map((input) => input.route.store)).toEqual(["codex", "codex", "opencode"]);
for (const input of inputs) expect(await account(input, env)).toEqual({ key: "same" });
for (const input of inputs) expect(await accountIdentity(input, env)).toEqual({ key: "same" });
await json(join(data, "opencode", "auth.json"), { openai: { type: "api", key: "fixture-key" } });
expect(await discover(lookup(env))).toHaveLength(2);
expect(await logins(lookup(env))).toHaveLength(2);
});
test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => {
const path = join(home, ".omp", "agent", "agent.db");
await database(path);
const [input] = await discover(lookup());
const [input] = await logins(lookup());
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run(
@@ -231,15 +247,13 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login
).toBe("unavailable");
db.prepare("UPDATE auth_credentials SET disabled_cause = 'revoked' WHERE id = 1").run();
db.close();
expect(
(
await fetchUsage(
input!,
async () => {
throw new Error("disabled login must not fetch");
},
lookup(),
)
).status,
).toBe("unavailable");
await expect(
fetchUsage(
input!,
async () => {
throw new Error("must not fetch");
},
lookup(),
),
).rejects.toThrow("login store no longer exists");
});
@@ -97,7 +97,6 @@ function ompRows(path: string, lookup: StoreLookup) {
if (!row.success) return [];
try {
const oauth = oauthSchema.parse(JSON.parse(row.data.data));
if (oauth.expires === undefined || oauth.expires <= (lookup.now ?? Date.now)()) return [];
return [{ id: row.data.id, oauth }];
} catch {
return [];
+52 -16
View File
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { fetchUsage, identify } from "./usage.js";
import { fetchUsage, discover } from "./usage.js";
import { inputSchema, type CodexUsageInput } from "../shared/input.js";
@@ -80,12 +80,12 @@ test("discovery input requires a route and rejects raw credentials", () => {
expect(() => inputSchema.parse({})).toThrow();
});
test("identify returns a key when fetch finds Codex token credentials", async () => {
test("discovery returns a key when fetch finds Codex token credentials", async () => {
await writeAuth("fixture-supplied", "account-supplied");
await fetchUsage(authInput(fixtureHome), (_url, init) =>
response(init?.headers ?? {}, "account-supplied"),
);
expect(await identify(authInput(fixtureHome))).toEqual({ key: "account-supplied" });
expect(await accountFor(authInput(fixtureHome))).toEqual({ key: "account-supplied" });
});
test("coerces credit balance and marks a 96 percent window dangerous", async () => {
@@ -136,12 +136,10 @@ test("summarizes the session and weekly windows by default, not code review", as
]);
});
test("HTML usage body is unavailable", async () => {
const report = await fetchUsage(
authInput(fixtureHome),
async () => new Response("<html>Login</html>", { status: 200 }),
);
expect(report.status).toBe("unavailable");
test("HTML usage body is an error", async () => {
await expect(
fetchUsage(authInput(fixtureHome), async () => new Response("<html>Login</html>")),
).rejects.toThrow("Codex usage API returned HTML");
});
test("401 leaves auth.json byte for byte unchanged and makes no refresh request", async () => {
@@ -178,14 +176,16 @@ test("auth account claim survives token rotation", async () => {
const token = (suffix: string) =>
`header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "work-id" }, suffix })).toString("base64url")}.signature`;
await writeAuth(token("first"));
expect(await identify(authInput(fixtureHome))).toEqual({ key: "work-id" });
expect(await accountFor(authInput(fixtureHome))).toEqual({ key: "work-id" });
await writeAuth(token("second"));
expect(await identify(authInput(fixtureHome))).toEqual({ key: "work-id" });
expect(await accountFor(authInput(fixtureHome))).toEqual({ key: "work-id" });
await writeAuth("opaque-token");
expect(await identify(authInput(fixtureHome))).toBeNull();
expect(await accountFor(authInput(fixtureHome))).toEqual({
key: expect.stringMatching(/^[a-f0-9]{64}$/),
});
});
test("identify reads an email label from auth id_token when access token has no profile", async () => {
test("discovery reads an email label from auth id_token when access token has no profile", async () => {
const home = await mkdtemp(join(tmpdir(), "usage-codex-label-"));
try {
process.env["CODEX_HOME"] = home;
@@ -196,7 +196,7 @@ test("identify reads an email label from auth id_token when access token has no
tokens: { account_id: "account-id", access_token: "opaque-token", id_token: idToken },
}),
);
expect(await identify(authInput(home))).toEqual({
expect(await accountFor(authInput(home))).toEqual({
key: "account-id",
label: "id-owner@example.test",
});
@@ -206,7 +206,6 @@ test("identify reads an email label from auth id_token when access token has no
});
test("discovers a Pi OAuth login independently of the CLI login", async () => {
const { discover } = await import("./usage.js");
const directory = await mkdtemp(join(tmpdir(), "usage-pi-discovery-"));
try {
await mkdir(join(directory, ".pi", "agent"), { recursive: true });
@@ -217,10 +216,47 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => {
}),
);
const inputs = await discover({ home: directory, env: {}, platform: "linux" });
expect(inputs).toContainEqual({
expect(inputs.map((account) => account.input)).toContainEqual({
route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") },
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
async function accountFor(input: CodexUsageInput) {
const accounts = await discover({
home: fixtureHome,
env: {
CODEX_HOME:
input.route.store === "codex"
? (await import("node:path")).dirname(input.route.path)
: fixtureHome,
},
platform: "linux",
});
const account = accounts.find(
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
);
if (!account) throw new Error("Expected discovered login");
return { key: account.key, ...(account.label ? { label: account.label } : {}) };
}
test.each(["empty home", "unrelated files"])(
"discovers no Codex accounts in %s",
async (scenario) => {
const home = await mkdtemp(join(tmpdir(), "codex-no-login-"));
try {
if (scenario === "unrelated files") await writeFile(join(home, "unrelated.json"), "{}");
expect(await discover({ home, env: {}, platform: "linux" })).toEqual([]);
} finally {
await rm(home, { recursive: true, force: true });
}
},
);
test.each([401, 403])("reports a Codex login rejected with HTTP %i", async (status) => {
expect(
await fetchUsage(authInput(fixtureHome), async () => new Response(null, { status })),
).toEqual({ status: "unavailable", problem: { kind: "rejected", status, refreshedBy: "codex" } });
});
+22 -12
View File
@@ -3,6 +3,9 @@ import { homedir } from "node:os";
import { join } from "node:path";
import {
balanceToneFromRemaining,
hashAccountKey,
unavailable,
type UsageAccount,
toneFromUsedPct,
windowFromUsedPct,
type UsageReport,
@@ -39,7 +42,7 @@ interface Auth {
expires?: number;
}
export async function discover(lookup: StoreLookup = {}): Promise<CodexUsageInput[]> {
export async function discover(lookup: StoreLookup = {}): Promise<UsageAccount[]> {
const env = lookup.env ?? process.env;
const home = lookup.home ?? homedir();
const paths = [
@@ -59,8 +62,11 @@ export async function discover(lookup: StoreLookup = {}): Promise<CodexUsageInpu
{ route: { store: "pi", path: piAuthPath(lookup) } },
...discoverOmp(lookup).map((route) => ({ route })),
);
const present: CodexUsageInput[] = [];
for (const input of candidates) if (await readAuth(input, lookup)) present.push(input);
const present: UsageAccount[] = [];
for (const input of candidates) {
const auth = await readAuth(input, lookup);
if (auth) present.push({ ...accountIdentity(auth, input), input });
}
return present;
}
@@ -104,8 +110,14 @@ export async function fetchUsage(
lookup: StoreLookup = {},
): Promise<UsageReport> {
const auth = await readAuth(input, lookup);
if (!auth || (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)()))
return { status: "unavailable", windows: [] };
if (!auth) throw new Error("Codex login store no longer exists");
const refreshedBy = input.route.store;
if (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)())
return unavailable({
kind: "expired",
expiresAt: new Date(auth.expires).toISOString(),
refreshedBy,
});
const headers: Record<string, string> = {
Authorization: `Bearer ${auth.token}`,
Accept: "application/json",
@@ -117,10 +129,10 @@ export async function fetchUsage(
signal: AbortSignal.timeout(15_000),
});
if (response.status === 401 || response.status === 403)
return { status: "unavailable", windows: [] };
return unavailable({ kind: "rejected", status: response.status, refreshedBy });
if (!response.ok) throw new Error(`Codex usage API returned ${response.status}`);
const text = await response.text();
if (text.trim().startsWith("<")) return { status: "unavailable", windows: [] };
if (text.trim().startsWith("<")) throw new Error("Codex usage API returned HTML");
const usage = responseSchema.parse(JSON.parse(text));
const windows = [
usageWindow(
@@ -181,9 +193,7 @@ function claimString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
export async function identify(input: CodexUsageInput, lookup: StoreLookup = {}) {
const auth = await readAuth(input, lookup);
if (!auth) return null;
function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; label?: string } {
const access = jwtClaims(auth.token);
const id = jwtClaims(auth.idToken);
const accessAuth = claimObject(access, "https://api.openai.com/auth");
@@ -193,11 +203,11 @@ export async function identify(input: CodexUsageInput, lookup: StoreLookup = {})
claimString(accessAuth?.["chatgpt_account_id"]) ??
claimString(access?.["chatgpt_account_id"]) ??
claimString(idAuth?.["chatgpt_account_id"]);
if (!key) return null;
const label =
claimString(claimObject(access, "https://api.openai.com/profile")?.["email"]) ??
claimString(access?.["email"]) ??
claimString(claimObject(id, "https://api.openai.com/profile")?.["email"]) ??
claimString(id?.["email"]);
return { key, ...(label ? { label } : {}) };
return { key: key ?? hashAccountKey(JSON.stringify(input.route)), ...(label ? { label } : {}) };
}
+2 -3
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { fetchUsage, identify } from "./server/usage.js";
import { fetchUsage, discover } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) {
label: "GitHub Copilot",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
identify,
discover: () => discover(),
fetch: fetchUsage,
});
return () => {};
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { identify, fetchUsage } from "./usage.js";
import { discover, fetchUsage } from "./usage.js";
import type { UsageReport } from "@getpaseo/plugin/server/usage";
function mockFetch(handlers: Map<string, () => Response>): typeof fetch {
@@ -65,14 +65,14 @@ describe("copilot usage source", () => {
) {
return {
listUsage: async () => {
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
const report = await fetchFirst((url, init) => fetchApi(url, init));
return {
providers: [
{
providerId: "copilot",
...report,
error: report.error ?? null,
planLabel: report.planLabel ?? null,
error: report.status === "error" ? report.error : null,
planLabel: report.status === "available" ? (report.planLabel ?? null) : null,
},
],
};
@@ -112,17 +112,60 @@ describe("copilot usage source", () => {
});
});
it("identify returns a key when fetch finds copilot credentials", async () => {
it("discovery returns a locator when fetch finds copilot credentials", async () => {
const previous = process.env["COPILOT_TOKEN"];
try {
process.env["COPILOT_TOKEN"] = "fixture-token";
let requested = false;
await fetchUsage({}, async () => {
await fetchFirst(async () => {
requested = true;
return new Response(null, { status: 401 });
});
expect(requested).toBe(true);
expect(await identify()).toEqual({ key: "default" });
expect(await discover()).toEqual([
{ key: "default", input: { store: "env", locator: "COPILOT_TOKEN" } },
]);
} finally {
if (previous === undefined) delete process.env["COPILOT_TOKEN"];
else process.env["COPILOT_TOKEN"] = previous;
}
});
describe("account discovery", () => {
it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const directory = await mkdtemp(join(tmpdir(), "usage-empty-"));
const original = { ...process.env };
try {
for (const key of Object.keys(process.env)) delete process.env[key];
process.env.HOME = directory;
process.env.USERPROFILE = directory;
if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}");
expect(await discover()).toEqual([]);
} finally {
for (const key of Object.keys(process.env)) delete process.env[key];
Object.assign(process.env, original);
await rm(directory, { recursive: true, force: true });
}
});
});
async function fetchFirst(fetchApi: typeof fetch) {
const accounts = await discover();
const account = accounts[0];
if (!account) throw new Error("No configured account");
return fetchUsage(account.input as Parameters<typeof fetchUsage>[0], fetchApi);
}
it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => {
const previous = process.env["COPILOT_TOKEN"];
try {
process.env["COPILOT_TOKEN"] = "fixture-rejected-login";
const report = await fetchUsage(
{ store: "env", locator: "COPILOT_TOKEN" },
async () => new Response(null, { status }),
);
expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } });
} finally {
if (previous === undefined) delete process.env["COPILOT_TOKEN"];
else process.env["COPILOT_TOKEN"] = previous;
+28 -39
View File
@@ -1,10 +1,11 @@
import type { UsageInput } from "../shared/input.js";
import { existsSync, promises as fs } from "node:fs";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import {
unavailableUsage,
unavailable,
type UsageAccount,
type UsageReport,
type UsageDetail,
} from "@getpaseo/plugin/server/usage";
@@ -19,39 +20,36 @@ const CopilotUsageResponseSchema = z.object({
quota_reset_date: ApiOptionalStringSchema,
});
async function readGithubCliToken(): Promise<string | null> {
const candidates: string[] = [];
if (process.env["APPDATA"]) {
candidates.push(join(process.env["APPDATA"], "GitHub CLI", "hosts.yml"));
async function readToken(input: UsageInput): Promise<string | undefined> {
if (input.store === "env") return process.env[input.locator];
try {
const raw = await fs.readFile(input.locator, "utf8");
return raw.match(/oauth_token:\s*["']?([a-zA-Z0-9_-]+)["']?/)?.[1];
} catch {
return undefined;
}
candidates.push(join(homedir(), ".config", "gh", "hosts.yml"));
}
for (const path of candidates) {
if (!existsSync(path)) continue;
try {
const raw = await fs.readFile(path, "utf8");
const match = raw.match(/oauth_token:\s*["']?([a-zA-Z0-9_-]+)["']?/);
if (match?.[1]) return match[1];
} catch {
continue;
}
}
return null;
export async function discover(): Promise<UsageAccount[]> {
const candidates: UsageInput[] = ["COPILOT_TOKEN", "GITHUB_TOKEN", "GITHUB_PAT"].map(
(locator) => ({ store: "env", locator }),
);
if (process.env.APPDATA)
candidates.push({
store: "file",
locator: join(process.env.APPDATA, "GitHub CLI", "hosts.yml"),
});
candidates.push({ store: "file", locator: join(homedir(), ".config", "gh", "hosts.yml") });
for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }];
return [];
}
export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
): Promise<UsageReport> {
void input;
const token =
process.env["COPILOT_TOKEN"] ||
process.env["GITHUB_TOKEN"] ||
process.env["GITHUB_PAT"] ||
(await readGithubCliToken());
if (!token) return unavailableUsage();
const token = await readToken(input);
if (!token) throw new Error("Copilot login store no longer exists");
const res = await fetchApi("https://api.github.com/copilot_internal/user", {
signal: AbortSignal.timeout(15_000),
@@ -65,9 +63,9 @@ export async function fetchUsage(
},
});
if (!res.ok) {
return unavailableUsage();
}
if (res.status === 401 || res.status === 403)
return unavailable({ kind: "rejected", status: res.status });
if (!res.ok) throw new Error(`Copilot usage API returned ${res.status}`);
const resp = CopilotUsageResponseSchema.parse(await res.json());
const details: UsageDetail[] = resp.quota_reset_date
@@ -82,12 +80,3 @@ export async function fetchUsage(
details,
};
}
export async function identify() {
const token =
process.env["COPILOT_TOKEN"] ||
process.env["GITHUB_TOKEN"] ||
process.env["GITHUB_PAT"] ||
(await readGithubCliToken());
return token ? { key: "default" } : null;
}
+6 -2
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
export const inputSchema = z
.object({
store: z.enum(["env", "file"]),
locator: z.string().min(1),
})
.strict();
export type UsageInput = z.infer<typeof inputSchema>;
+2 -3
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { fetchUsage, identify } from "./server/usage.js";
import { fetchUsage, discover } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) {
label: "Cursor",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
identify,
discover: () => discover(),
fetch: fetchUsage,
});
return () => {};
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { identify, fetchUsage } from "./usage.js";
import { discover, fetchUsage } from "./usage.js";
import type { UsageReport } from "@getpaseo/plugin/server/usage";
// node:sqlite has no @types/node@20 typings; require it with a narrow local type.
@@ -97,14 +97,14 @@ describe("cursor usage source", () => {
) {
return {
listUsage: async () => {
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
const report = await fetchFirst((url, init) => fetchApi(url, init));
return {
providers: [
{
providerId: "cursor",
...report,
error: report.error ?? null,
planLabel: report.planLabel ?? null,
error: report.status === "error" ? report.error : null,
planLabel: report.status === "available" ? (report.planLabel ?? null) : null,
},
],
};
@@ -250,26 +250,68 @@ describe("cursor usage source", () => {
expect(cursor.status).toBe("available");
});
it("stays unavailable when state.vscdb is unreadable", async () => {
const path = join(homeDir, ".config", "Cursor", "User", "globalStorage");
mkdirSync(path, { recursive: true });
writeFileSync(join(path, "state.vscdb"), "not a database");
const cursor = findProvider(await service().listUsage(), "cursor");
expect(cursor.status).toBe("unavailable");
it("omits unreadable state.vscdb", async () => {
const directory = join(homeDir, ".config", "Cursor", "User", "globalStorage");
mkdirSync(directory, { recursive: true });
writeFileSync(join(directory, "state.vscdb"), "invalid database");
expect(await discover()).toEqual([]);
});
});
it("identify returns a key when fetch finds cursor credentials", async () => {
it("discovery returns a locator when fetch finds cursor credentials", async () => {
const previous = process.env["CURSOR_ACCESS_TOKEN"];
try {
process.env["CURSOR_ACCESS_TOKEN"] = "fixture-token";
let requested = false;
await fetchUsage({}, async () => {
await fetchFirst(async () => {
requested = true;
return new Response(null, { status: 401 });
});
expect(requested).toBe(true);
expect(await identify()).toEqual({ key: "default" });
expect(await discover()).toEqual([
{ key: "default", input: { store: "env", locator: "CURSOR_ACCESS_TOKEN" } },
]);
} finally {
if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"];
else process.env["CURSOR_ACCESS_TOKEN"] = previous;
}
});
describe("account discovery", () => {
it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const directory = await mkdtemp(join(tmpdir(), "usage-empty-"));
const original = { ...process.env };
try {
for (const key of Object.keys(process.env)) delete process.env[key];
process.env.HOME = directory;
process.env.USERPROFILE = directory;
if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}");
expect(await discover()).toEqual([]);
} finally {
for (const key of Object.keys(process.env)) delete process.env[key];
Object.assign(process.env, original);
await rm(directory, { recursive: true, force: true });
}
});
});
async function fetchFirst(fetchApi: typeof fetch) {
const accounts = await discover();
const account = accounts[0];
if (!account) throw new Error("No configured account");
return fetchUsage(account.input as Parameters<typeof fetchUsage>[0], fetchApi);
}
it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => {
const previous = process.env["CURSOR_ACCESS_TOKEN"];
try {
process.env["CURSOR_ACCESS_TOKEN"] = "fixture-rejected-login";
const report = await fetchUsage(
{ store: "env", locator: "CURSOR_ACCESS_TOKEN" },
async () => new Response(null, { status }),
);
expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } });
} finally {
if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"];
else process.env["CURSOR_ACCESS_TOKEN"] = previous;
+53 -54
View File
@@ -7,7 +7,8 @@ import { z } from "zod";
import {
toneFromUsedPct,
usedPctOf,
unavailableUsage,
unavailable,
type UsageAccount,
type UsageReport,
type UsageBalance,
} from "@getpaseo/plugin/server/usage";
@@ -114,24 +115,7 @@ function cursorTokenFromDb(db: CursorStateDatabase): string | null {
return null;
}
async function readCursorTokenFromSqlite(homeDir: string): Promise<string | null> {
const dbPaths: string[] = [];
if (process.env["APPDATA"]) {
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
}
dbPaths.push(
join(
homeDir,
"Library",
"Application Support",
"Cursor",
"User",
"globalStorage",
"state.vscdb",
),
);
dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
async function readCursorTokenFromSqlite(path: string): Promise<string | null> {
// Held in a variable so TypeScript skips module resolution: @types/node@20 has no
// node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it.
const sqliteSpecifier: string = "node:sqlite";
@@ -142,24 +126,19 @@ async function readCursorTokenFromSqlite(homeDir: string): Promise<string | null
return null; // runtime without node:sqlite
}
for (const path of dbPaths) {
if (!existsSync(path)) continue;
let db: CursorStateDatabase | undefined;
try {
db = new sqlite.DatabaseSync(path, { readOnly: true });
const token = cursorTokenFromDb(db);
if (token) return token;
} catch {
// Locked, permission, corrupt, or schema failures try the next candidate.
} finally {
db?.close();
}
if (!existsSync(path)) return null;
let db: CursorStateDatabase | undefined;
try {
db = new sqlite.DatabaseSync(path, { readOnly: true });
return cursorTokenFromDb(db);
} catch {
return null;
} finally {
db?.close();
}
return null;
}
async function readCursorTokenFromAuthJson(homeDir: string): Promise<string | null> {
const path = join(homeDir, ".config", "cursor", "auth.json");
async function readCursorTokenFromAuthJson(path: string): Promise<string | null> {
if (!existsSync(path)) return null;
try {
const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8")));
@@ -173,16 +152,8 @@ export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
): Promise<UsageReport> {
void input;
const homeDir = homedir();
const token =
process.env["CURSOR_ACCESS_TOKEN"] ||
process.env["CURSOR_TOKEN"] ||
(await readCursorTokenFromSqlite(homeDir)) ||
(await readCursorTokenFromAuthJson(homeDir));
if (!token) return unavailableUsage();
const token = await readToken(input);
if (!token) throw new Error("Cursor login store no longer exists");
const res = await fetchApi(
"https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
@@ -198,9 +169,9 @@ export async function fetchUsage(
},
);
if (!res.ok) {
return unavailableUsage();
}
if (res.status === 401 || res.status === 403)
return unavailable({ kind: "rejected", status: res.status });
if (!res.ok) throw new Error(`Cursor usage API returned ${res.status}`);
const resp = CursorUsageResponseSchema.parse(await res.json());
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
@@ -230,12 +201,40 @@ export async function fetchUsage(
};
}
export async function identify() {
const home = homedir();
async function readToken(input: UsageInput): Promise<string | undefined> {
if (input.store === "env") return process.env[input.locator];
const token =
process.env["CURSOR_ACCESS_TOKEN"] ||
process.env["CURSOR_TOKEN"] ||
(await readCursorTokenFromSqlite(home)) ||
(await readCursorTokenFromAuthJson(home));
return token ? { key: "default" } : null;
input.store === "sqlite"
? await readCursorTokenFromSqlite(input.locator)
: await readCursorTokenFromAuthJson(input.locator);
return token ?? undefined;
}
export async function discover(): Promise<UsageAccount[]> {
const home = homedir();
const candidates: UsageInput[] = ["CURSOR_ACCESS_TOKEN", "CURSOR_TOKEN"].map((locator) => ({
store: "env",
locator,
}));
if (process.env.APPDATA)
candidates.push({
store: "sqlite",
locator: join(process.env.APPDATA, "Cursor", "User", "globalStorage", "state.vscdb"),
});
candidates.push(
...[
join(
home,
"Library",
"Application Support",
"Cursor",
"User",
"globalStorage",
"state.vscdb",
),
join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb"),
].map((locator) => ({ store: "sqlite" as const, locator })),
{ store: "file", locator: join(home, ".config", "cursor", "auth.json") },
);
for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }];
return [];
}
+6 -2
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
export const inputSchema = z
.object({
store: z.enum(["env", "file", "sqlite"]),
locator: z.string().min(1),
})
.strict();
export type UsageInput = z.infer<typeof inputSchema>;
+2 -3
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { fetchUsage, identify } from "./server/usage.js";
import { fetchUsage, discover } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) {
label: "Grok",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
identify,
discover: () => discover(),
fetch: fetchUsage,
});
return () => {};
+50 -7
View File
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { identify, fetchUsage } from "./usage.js";
import { discover, fetchUsage } from "./usage.js";
import type { UsageReport } from "@getpaseo/plugin/server/usage";
function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
@@ -70,14 +70,14 @@ describe("grok usage source", () => {
) {
return {
listUsage: async () => {
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
const report = await fetchFirst((url, init) => fetchApi(url, init));
return {
providers: [
{
providerId: "grok",
...report,
error: report.error ?? null,
planLabel: report.planLabel ?? null,
error: report.status === "error" ? report.error : null,
planLabel: report.status === "available" ? (report.planLabel ?? null) : null,
},
],
};
@@ -267,17 +267,60 @@ describe("grok usage source", () => {
});
});
it("identify returns a key when fetch finds grok credentials", async () => {
it("discovery returns a locator when fetch finds grok credentials", async () => {
const previous = process.env["GROK_TOKEN"];
try {
process.env["GROK_TOKEN"] = "fixture-token";
let requested = false;
await fetchUsage({}, async () => {
await fetchFirst(async () => {
requested = true;
return new Response(null, { status: 401 });
});
expect(requested).toBe(true);
expect(await identify()).toEqual({ key: "default" });
expect(await discover()).toEqual([
{ key: "default", input: { store: "env", locator: "GROK_TOKEN" } },
]);
} finally {
if (previous === undefined) delete process.env["GROK_TOKEN"];
else process.env["GROK_TOKEN"] = previous;
}
});
describe("account discovery", () => {
it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const directory = await mkdtemp(join(tmpdir(), "usage-empty-"));
const original = { ...process.env };
try {
for (const key of Object.keys(process.env)) delete process.env[key];
process.env.HOME = directory;
process.env.USERPROFILE = directory;
if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}");
expect(await discover()).toEqual([]);
} finally {
for (const key of Object.keys(process.env)) delete process.env[key];
Object.assign(process.env, original);
await rm(directory, { recursive: true, force: true });
}
});
});
async function fetchFirst(fetchApi: typeof fetch) {
const accounts = await discover();
const account = accounts[0];
if (!account) throw new Error("No configured account");
return fetchUsage(account.input as Parameters<typeof fetchUsage>[0], fetchApi);
}
it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => {
const previous = process.env["GROK_TOKEN"];
try {
process.env["GROK_TOKEN"] = "fixture-rejected-login";
const report = await fetchUsage(
{ store: "env", locator: "GROK_TOKEN" },
async () => new Response(null, { status }),
);
expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } });
} finally {
if (previous === undefined) delete process.env["GROK_TOKEN"];
else process.env["GROK_TOKEN"] = previous;
+23 -27
View File
@@ -1,12 +1,13 @@
import type { UsageInput } from "../shared/input.js";
import { existsSync, promises as fs } from "node:fs";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import {
toneFromUsedPct,
usedPctOf,
unavailableUsage,
unavailable,
type UsageAccount,
windowFromUsedPct,
type UsageReport,
type UsageWindow,
@@ -109,23 +110,8 @@ export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
): Promise<UsageReport> {
void input;
const homeDir = homedir();
async function readGrokToken(): Promise<string | null> {
// homeDir override is for tests: Windows os.homedir() ignores $HOME (uses USERPROFILE).
const path = join(homeDir ?? homedir(), ".grok", "auth.json");
if (!existsSync(path)) return null;
try {
return extractGrokTokenFromAuth(JSON.parse(await fs.readFile(path, "utf8")));
} catch {
return null;
}
}
const token = process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await readGrokToken());
if (!token) return unavailableUsage();
const token = await readToken(input);
if (!token) throw new Error("Grok login store no longer exists");
// The Grok CLI's /usage uses ?format=credits; without it, unified-billing accounts
// get a zeroed legacy monthly shape (monthlyLimit.val 0) instead of real usage.
@@ -138,9 +124,9 @@ export async function fetchUsage(
},
});
if (!res.ok) {
return unavailableUsage();
}
if (res.status === 401 || res.status === 403)
return unavailable({ kind: "rejected", status: res.status });
if (!res.ok) throw new Error(`Grok usage API returned ${res.status}`);
const resp = GrokUsageResponseSchema.parse(await res.json());
const balance = grokMonthlyCreditBalance(resp);
@@ -155,12 +141,22 @@ export async function fetchUsage(
};
}
export async function identify() {
if (process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"]) return { key: "default" };
async function readToken(input: UsageInput): Promise<string | undefined> {
if (input.store === "env") return process.env[input.locator];
try {
const auth = JSON.parse(await fs.readFile(join(homedir(), ".grok", "auth.json"), "utf8"));
return extractGrokTokenFromAuth(auth) ? { key: "default" } : null;
return (
extractGrokTokenFromAuth(JSON.parse(await fs.readFile(input.locator, "utf8"))) ?? undefined
);
} catch {
return null;
return undefined;
}
}
export async function discover(): Promise<UsageAccount[]> {
const candidates: UsageInput[] = ["GROK_API_KEY", "GROK_TOKEN"].map((locator) => ({
store: "env",
locator,
}));
candidates.push({ store: "file", locator: join(homedir(), ".grok", "auth.json") });
for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }];
return [];
}
+6 -2
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
export const inputSchema = z
.object({
store: z.enum(["env", "file"]),
locator: z.string().min(1),
})
.strict();
export type UsageInput = z.infer<typeof inputSchema>;
+2 -3
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { fetchUsage, identify } from "./server/usage.js";
import { fetchUsage, discover } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) {
label: "Kimi",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
identify,
discover: () => discover(),
fetch: fetchUsage,
});
return () => {};
+54 -11
View File
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "nod
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { identify, fetchUsage } from "./usage.js";
import { discover, fetchUsage } from "./usage.js";
import type { UsageReport } from "@getpaseo/plugin/server/usage";
function kimiCredentialPath(dir: string): string {
@@ -85,14 +85,14 @@ describe("kimi usage source", () => {
) {
return {
listUsage: async () => {
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
const report = await fetchFirst((url, init) => fetchApi(url, init));
return {
providers: [
{
providerId: "kimi",
...report,
error: report.error ?? null,
planLabel: report.planLabel ?? null,
error: report.status === "error" ? report.error : null,
planLabel: report.status === "available" ? (report.planLabel ?? null) : null,
},
],
};
@@ -272,7 +272,7 @@ describe("Kimi usage source usage windows", () => {
],
}),
);
const usage = await fetchUsage({}, fetchApi);
const usage = await fetchFirst(fetchApi);
expect(usage).toMatchObject({
status: "available",
@@ -315,7 +315,7 @@ describe("Kimi usage source usage windows", () => {
],
}),
);
const usage = await fetchUsage({}, fetchApi);
const usage = await fetchFirst(fetchApi);
expect(usage.windows).toHaveLength(2);
expect(usage.windows[1]).toMatchObject({
@@ -340,7 +340,7 @@ describe("Kimi usage source usage windows", () => {
],
}),
);
const usage = await fetchUsage({}, fetchApi);
const usage = await fetchFirst(fetchApi);
expect(usage.windows).toEqual([
expect.objectContaining({
@@ -360,7 +360,7 @@ describe("Kimi usage source usage windows", () => {
detail: { limit: "100", used: "10" },
};
const fetchApi = vi.fn(async () => jsonResponse({ limits: [duplicate, duplicate] }));
const usage = await fetchUsage({}, fetchApi);
const usage = await fetchFirst(fetchApi);
expect(usage.windows.map((window) => window.id)).toEqual([
"coding_limit_300_time_unit_minute",
@@ -369,17 +369,60 @@ describe("Kimi usage source usage windows", () => {
});
});
it("identify returns a key when fetch finds kimi credentials", async () => {
it("discovery returns a locator when fetch finds kimi credentials", async () => {
const previous = process.env["KIMI_TOKEN"];
try {
process.env["KIMI_TOKEN"] = "fixture-token";
let requested = false;
await fetchUsage({}, async () => {
await fetchFirst(async () => {
requested = true;
return new Response(null, { status: 401 });
});
expect(requested).toBe(true);
expect(await identify()).toEqual({ key: "default" });
expect(await discover()).toEqual([
{ key: "default", input: { store: "env", locator: "KIMI_TOKEN" } },
]);
} finally {
if (previous === undefined) delete process.env["KIMI_TOKEN"];
else process.env["KIMI_TOKEN"] = previous;
}
});
describe("account discovery", () => {
it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const directory = await mkdtemp(join(tmpdir(), "usage-empty-"));
const original = { ...process.env };
try {
for (const key of Object.keys(process.env)) delete process.env[key];
process.env.HOME = directory;
process.env.USERPROFILE = directory;
if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}");
expect(await discover()).toEqual([]);
} finally {
for (const key of Object.keys(process.env)) delete process.env[key];
Object.assign(process.env, original);
await rm(directory, { recursive: true, force: true });
}
});
});
async function fetchFirst(fetchApi: typeof fetch) {
const accounts = await discover();
const account = accounts[0];
if (!account) throw new Error("No configured account");
return fetchUsage(account.input as Parameters<typeof fetchUsage>[0], fetchApi);
}
it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => {
const previous = process.env["KIMI_TOKEN"];
try {
process.env["KIMI_TOKEN"] = "fixture-rejected-login";
const report = await fetchUsage(
{ store: "env", locator: "KIMI_TOKEN" },
async () => new Response(null, { status }),
);
expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } });
} finally {
if (previous === undefined) delete process.env["KIMI_TOKEN"];
else process.env["KIMI_TOKEN"] = previous;
+44 -67
View File
@@ -1,11 +1,12 @@
import type { UsageInput } from "../shared/input.js";
import { existsSync, promises as fs } from "node:fs";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import {
toneFromUsedPct,
unavailableUsage,
unavailable,
type UsageAccount,
windowFromUsedPct,
type UsageReport,
type UsageWindow,
@@ -251,9 +252,6 @@ export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
): Promise<UsageReport> {
void input;
const homeDir = homedir();
async function callUsageApi(token: string): Promise<Response> {
return fetchApi(KIMI_USAGE_URL, {
signal: AbortSignal.timeout(15_000),
@@ -264,52 +262,19 @@ export async function fetchUsage(
});
}
async function readCredentials(): Promise<KimiCredentials | null> {
const environmentToken = process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"];
if (environmentToken) {
return { access_token: environmentToken };
}
for (const path of credentialPaths()) {
const credentials = await readCredentialFile(path);
if (credentials?.access_token) {
return { ...credentials, access_token: credentials.access_token };
}
}
return null;
}
function credentialPaths(): string[] {
const home = homeDir;
return [
join(
process.env["KIMI_CODE_HOME"] || join(home, ".kimi-code"),
"credentials",
"kimi-code.json",
),
join(home, ".kimi", "credentials", "kimi-code.json"),
];
}
async function readCredentialFile(path: string): Promise<KimiAuth | null> {
if (!existsSync(path)) return null;
try {
return KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
} catch {
return null;
}
}
const credentials = await readCredentials();
if (!credentials) return unavailableUsage();
const credentials = await readCredentials(input);
if (!credentials) throw new Error("Kimi login store no longer exists");
if (credentials.expires_at != null && credentials.expires_at * 1000 <= Date.now())
return unavailable({
kind: "expired",
expiresAt: new Date(credentials.expires_at * 1000).toISOString(),
});
const res = await callUsageApi(credentials.access_token);
if (!res.ok) {
// Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md.
return unavailableUsage();
}
if (res.status === 401 || res.status === 403)
return unavailable({ kind: "rejected", status: res.status });
if (!res.ok) throw new Error(`Kimi usage API returned ${res.status}`);
const windows = kimiUsageWindowsFromPayload(await res.json());
@@ -322,24 +287,36 @@ export async function fetchUsage(
};
}
export async function identify() {
if (process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"]) return { key: "default" };
const home = homedir();
const paths = [
join(
process.env["KIMI_CODE_HOME"] || join(home, ".kimi-code"),
"credentials",
"kimi-code.json",
),
join(home, ".kimi", "credentials", "kimi-code.json"),
];
for (const path of paths) {
try {
const auth = KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
if (auth.access_token) return { key: "default" };
} catch {
continue;
}
async function readCredentials(input: UsageInput): Promise<KimiCredentials | undefined> {
if (input.store === "env") {
const token = process.env[input.locator];
return token ? { access_token: token } : undefined;
}
try {
const credentials = KimiAuthSchema.parse(JSON.parse(await fs.readFile(input.locator, "utf8")));
return credentials.access_token
? { ...credentials, access_token: credentials.access_token }
: undefined;
} catch {
return undefined;
}
return null;
}
export async function discover(): Promise<UsageAccount[]> {
const candidates: UsageInput[] = ["KIMI_TOKEN", "KIMI_API_KEY"].map((locator) => ({
store: "env",
locator,
}));
candidates.push(
...[
join(
process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"),
"credentials",
"kimi-code.json",
),
join(homedir(), ".kimi", "credentials", "kimi-code.json"),
].map((locator) => ({ store: "file" as const, locator })),
);
for (const input of candidates)
if (await readCredentials(input)) return [{ key: "default", input }];
return [];
}
+6 -2
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
export const inputSchema = z
.object({
store: z.enum(["env", "file"]),
locator: z.string().min(1),
})
.strict();
export type UsageInput = z.infer<typeof inputSchema>;
+2 -3
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { fetchUsage, identify } from "./server/usage.js";
import { fetchUsage, discover } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) {
label: "MiniMax",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
identify,
discover: () => discover(),
fetch: fetchUsage,
});
return () => {};
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { identify, fetchUsage } from "./usage.js";
import { discover, fetchUsage } from "./usage.js";
import type { UsageReport } from "@getpaseo/plugin/server/usage";
function writeMiniMaxConfig(dir: string, payload: Record<string, unknown>): void {
@@ -83,14 +83,14 @@ describe("minimax usage source", () => {
) {
return {
listUsage: async () => {
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
const report = await fetchFirst((url, init) => fetchApi(url, init));
return {
providers: [
{
providerId: "minimax",
...report,
error: report.error ?? null,
planLabel: report.planLabel ?? null,
error: report.status === "error" ? report.error : null,
planLabel: report.status === "available" ? (report.planLabel ?? null) : null,
},
],
};
@@ -154,13 +154,8 @@ describe("minimax usage source", () => {
});
});
it("returns unavailable MiniMax usage when no credentials are configured", async () => {
fetchApi = vi.fn() as never;
const miniMax = findProvider(await service().listUsage(), "minimax");
expect(miniMax.status).toBe("unavailable");
expect(fetchApi).not.toHaveBeenCalled();
it("omits MiniMax when no credentials are configured", async () => {
expect(await discover()).toEqual([]);
});
it("reads MiniMax OAuth credentials from the CLI credentials file", async () => {
@@ -215,7 +210,11 @@ describe("minimax usage source", () => {
const miniMax = findProvider(await service().listUsage(), "minimax");
expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null });
expect(miniMax).toMatchObject({
status: "unavailable",
problem: { kind: "no_quota" },
error: null,
});
});
it("reports a null MiniMax token plan as unavailable even without base_resp", async () => {
@@ -231,7 +230,11 @@ describe("minimax usage source", () => {
const miniMax = findProvider(await service().listUsage(), "minimax");
expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null });
expect(miniMax).toMatchObject({
status: "unavailable",
problem: { kind: "no_quota" },
error: null,
});
});
it("still reads MiniMax windows when base_resp reports success", async () => {
@@ -314,17 +317,60 @@ describe("minimax usage source", () => {
});
});
it("identify returns a key when fetch finds minimax credentials", async () => {
it("discovery returns a locator when fetch finds minimax credentials", async () => {
const previous = process.env["MINIMAX_API_KEY"];
try {
process.env["MINIMAX_API_KEY"] = "fixture-token";
let requested = false;
await fetchUsage({}, async () => {
await fetchFirst(async () => {
requested = true;
return new Response(null, { status: 401 });
});
expect(requested).toBe(true);
expect(await identify()).toEqual({ key: "default" });
expect(await discover()).toEqual([
{ key: "default", input: { store: "env", locator: "MINIMAX_API_KEY" } },
]);
} finally {
if (previous === undefined) delete process.env["MINIMAX_API_KEY"];
else process.env["MINIMAX_API_KEY"] = previous;
}
});
describe("account discovery", () => {
it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const directory = await mkdtemp(join(tmpdir(), "usage-empty-"));
const original = { ...process.env };
try {
for (const key of Object.keys(process.env)) delete process.env[key];
process.env.HOME = directory;
process.env.USERPROFILE = directory;
if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}");
expect(await discover()).toEqual([]);
} finally {
for (const key of Object.keys(process.env)) delete process.env[key];
Object.assign(process.env, original);
await rm(directory, { recursive: true, force: true });
}
});
});
async function fetchFirst(fetchApi: typeof fetch) {
const accounts = await discover();
const account = accounts[0];
if (!account) throw new Error("No configured account");
return fetchUsage(account.input as Parameters<typeof fetchUsage>[0], fetchApi);
}
it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => {
const previous = process.env["MINIMAX_API_KEY"];
try {
process.env["MINIMAX_API_KEY"] = "fixture-rejected-login";
const report = await fetchUsage(
{ store: "env", locator: "MINIMAX_API_KEY" },
async () => new Response(null, { status }),
);
expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } });
} finally {
if (previous === undefined) delete process.env["MINIMAX_API_KEY"];
else process.env["MINIMAX_API_KEY"] = previous;
+64 -100
View File
@@ -1,10 +1,11 @@
import type { UsageInput } from "../shared/input.js";
import { existsSync, promises as fs } from "node:fs";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import {
unavailableUsage,
unavailable,
type UsageAccount,
windowFromUsedPct,
type UsageReport,
type UsageWindow,
@@ -148,83 +149,10 @@ export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
): Promise<UsageReport> {
void input;
const configPath = join(homedir(), ".mmx", "config.json");
const credentialsPath = join(homedir(), ".mmx", "credentials.json");
const env = process.env;
const now = Date.now;
async function resolveAuth(): Promise<MiniMaxResolvedAuth | null> {
const envToken = env["MINIMAX_API_KEY"];
if (envToken) {
const envBase = env["MINIMAX_BASE_URL"];
return {
token: envToken,
baseUrl: resolveBaseUrl({ baseUrl: envBase }),
};
}
const credentials = await readCredentials();
if (credentials?.access_token && !isExpired(credentials.expires_at)) {
return {
token: credentials.access_token,
baseUrl: resolveBaseUrl({ baseUrl: credentials.resource_url }),
};
}
const config = await readConfig();
if (config?.api_key) {
return {
token: config.api_key,
baseUrl: resolveBaseUrl({
baseUrl: config.base_url,
region: config.region,
}),
};
}
if (config?.oauth?.access_token && !isExpired(config.oauth.expires_at)) {
return {
token: config.oauth.access_token,
baseUrl: resolveBaseUrl({
baseUrl: config.oauth.resource_url ?? config.base_url,
region: config.region,
}),
};
}
return null;
}
function isExpired(expiresAt: string | null | undefined): boolean {
if (!expiresAt) return false;
const parsed = Date.parse(expiresAt);
if (!Number.isFinite(parsed)) return false;
return parsed <= now();
}
async function readCredentials(): Promise<z.infer<typeof MiniMaxCredentialsSchema> | null> {
if (!existsSync(credentialsPath)) return null;
try {
const raw = JSON.parse(await fs.readFile(credentialsPath, "utf8"));
return MiniMaxCredentialsSchema.parse(raw);
} catch {
return null;
}
}
async function readConfig(): Promise<z.infer<typeof MiniMaxConfigSchema> | null> {
if (!existsSync(configPath)) return null;
try {
const raw = JSON.parse(await fs.readFile(configPath, "utf8"));
return MiniMaxConfigSchema.parse(raw);
} catch {
return null;
}
}
const auth = await resolveAuth();
if (!auth) return unavailableUsage();
const auth = await readAuth(input);
if (!auth) throw new Error("MiniMax login store no longer exists");
if (auth.expiresAt && Date.parse(auth.expiresAt) <= Date.now())
return unavailable({ kind: "expired", expiresAt: new Date(auth.expiresAt).toISOString() });
const res = await fetchApi(`${auth.baseUrl}/v1/token_plan/remains`, {
signal: AbortSignal.timeout(15_000),
@@ -234,15 +162,18 @@ export async function fetchUsage(
},
});
if (!res.ok) {
return unavailableUsage();
}
if (res.status === 401 || res.status === 403)
return unavailable({ kind: "rejected", status: res.status });
if (!res.ok) throw new Error(`MiniMax usage API returned ${res.status}`);
const resp = MiniMaxQuotaResponseSchema.parse(await res.json());
const statusCode = resp.base_resp?.status_code;
if (typeof statusCode === "number" && statusCode !== 0) {
return unavailableUsage();
return unavailable({
kind: "no_quota",
detail: resp.base_resp?.status_msg ?? "No active coding plan",
});
}
const models = resp.model_remains ?? [];
@@ -256,8 +187,10 @@ export async function fetchUsage(
if (weeklyWindow) windows.push(weeklyWindow);
}
if (windows.length === 0)
return unavailable({ kind: "no_quota", detail: "No active coding plan" });
return {
status: windows.length > 0 ? "available" : "unavailable",
status: "available",
planLabel: undefined,
windows,
balances: [],
@@ -265,24 +198,55 @@ export async function fetchUsage(
};
}
export async function identify() {
if (process.env["MINIMAX_API_KEY"]) return { key: "default" };
const home = join(homedir(), ".mmx");
try {
const credentials = MiniMaxCredentialsSchema.parse(
JSON.parse(await fs.readFile(join(home, "credentials.json"), "utf8")),
);
if (credentials.access_token) return { key: "default" };
} catch {
/* Check config next. */
async function readAuth(
input: UsageInput,
): Promise<(MiniMaxResolvedAuth & { expiresAt?: string }) | undefined> {
if (input.store === "env") {
const token = process.env[input.locator];
return token
? { token, baseUrl: resolveBaseUrl({ baseUrl: process.env.MINIMAX_BASE_URL }) }
: undefined;
}
try {
const config = MiniMaxConfigSchema.parse(
JSON.parse(await fs.readFile(join(home, "config.json"), "utf8")),
);
if (config.api_key || config.oauth?.access_token) return { key: "default" };
const raw: unknown = JSON.parse(await fs.readFile(input.locator, "utf8"));
if (input.store === "credentials") {
const credentials = MiniMaxCredentialsSchema.parse(raw);
return credentials.access_token
? {
token: credentials.access_token,
baseUrl: resolveBaseUrl({ baseUrl: credentials.resource_url }),
expiresAt: credentials.expires_at ?? undefined,
}
: undefined;
}
const config = MiniMaxConfigSchema.parse(raw);
if (config.api_key)
return {
token: config.api_key,
baseUrl: resolveBaseUrl({ baseUrl: config.base_url, region: config.region }),
};
return config.oauth?.access_token
? {
token: config.oauth.access_token,
baseUrl: resolveBaseUrl({
baseUrl: config.oauth.resource_url ?? config.base_url,
region: config.region,
}),
expiresAt: config.oauth.expires_at ?? undefined,
}
: undefined;
} catch {
/* No configured account. */
return undefined;
}
return null;
}
export async function discover(): Promise<UsageAccount[]> {
const candidates: UsageInput[] = [
{ store: "env", locator: "MINIMAX_API_KEY" },
{ store: "credentials", locator: join(homedir(), ".mmx", "credentials.json") },
{ store: "config", locator: join(homedir(), ".mmx", "config.json") },
];
const accounts: UsageAccount[] = [];
for (const input of candidates)
if (await readAuth(input)) accounts.push({ key: "default", input });
return accounts;
}
+6 -2
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
export const inputSchema = z
.object({
store: z.enum(["env", "credentials", "config"]),
locator: z.string().min(1),
})
.strict();
export type UsageInput = z.infer<typeof inputSchema>;
+9 -9
View File
@@ -5,7 +5,7 @@ import type { UsageSourceRegistration } from "@getpaseo/plugin/server";
import type { ProviderLaunch } from "@getpaseo/plugin/server/provider";
import {
hashAccountKey,
unavailableUsage,
unavailable,
windowFromUsedPct,
toneFromUsedPct,
} from "@getpaseo/plugin/server/usage";
@@ -59,18 +59,19 @@ export class Usage {
label: "Muse Code",
icon: "icon.svg",
input: inputSchema,
discover: async () => [...this.launches.keys()].map((account) => ({ account })),
identify: async (input) => {
const { account } = inputSchema.parse(input);
return this.launches.has(account) ? { key: account, label: "Muse Code" } : null;
},
discover: async () =>
[...this.launches.keys()].map((account) => ({
key: account,
label: "Muse Code",
input: { account },
})),
fetch: async (input) => {
const { account } = inputSchema.parse(input);
const launch = this.launches.get(account);
if (!launch) return unavailableUsage();
if (!launch) throw new Error("Muse login no longer exists");
try {
const { usage } = await this.read(account, launch);
if (!usage) return unavailableUsage();
if (!usage) return unavailable({ kind: "no_quota", detail: "No usage quota reported" });
return {
status: "available",
planLabel: usage.tier,
@@ -105,7 +106,6 @@ export class Usage {
} catch (error) {
return {
status: "error",
windows: [],
error: error instanceof Error ? error.message : String(error),
};
}
+16 -8
View File
@@ -1157,12 +1157,13 @@ for (const usage of [
await h.open();
const inputs = await h.usageSource.discover();
expect(inputs).toHaveLength(1);
const input = inputs[0];
expect(await h.usageSource.identify(input)).toEqual({
key: (input as { account: string }).account,
const account = inputs[0]!;
expect(account).toEqual({
key: expect.stringMatching(/^[a-f0-9]{64}$/),
label: "Muse Code",
input: { account: account.key },
});
const report = await h.usageSource.fetch(input);
const report = await h.usageSource.fetch(account.input);
if ("usage" in usage)
expect(report).toMatchObject({
status: "available",
@@ -1178,7 +1179,11 @@ for (const usage of [
{ id: "weekly", usedPct: 105, remainingPct: 0, tone: "danger" },
],
});
else expect(report).toEqual({ status: "unavailable", windows: [], balances: [], details: [] });
else
expect(report).toEqual({
status: "unavailable",
problem: { kind: "no_quota", detail: "No usage quota reported" },
});
expect((await h.recorded()).filter((f) => f.method === "usage/read")).toHaveLength(1);
});
}
@@ -1303,10 +1308,13 @@ test("usage identity follows the resolved config directory across credential and
await h.open();
const inputs = await h.usageSource.discover();
expect(inputs).toHaveLength(1);
const identity = await h.usageSource.identify(inputs[0]);
expect(identity).not.toBeNull();
identities.push(identity!.key);
identities.push(inputs[0]!.key);
}
expect(identities[0]).toBe(identities[1]);
expect(identities[2]).not.toBe(identities[0]);
});
test.each(["no sessions", "unrelated environment"])("Muse discovery is empty with %s", async () => {
const { Usage } = await import("../server/usage.js");
expect(await new Usage().registration().discover()).toEqual([]);
});
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { discover, fetchUsage, identify } from "./server/usage.js";
import { discover, fetchUsage } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -9,7 +9,6 @@ export default function contribute(server: PluginServerContext) {
icon: "icon.svg",
input: inputSchema,
discover,
identify,
fetch: fetchUsage,
});
return () => {};
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { discover, fetchUsage, identify } from "./usage.js";
import { discover, fetchUsage } from "./usage.js";
import { inputSchema } from "../shared/input.js";
@@ -22,7 +22,7 @@ afterEach(async () => {
test("discovery input rejects an explicit API key", () => {
expect(inputSchema.safeParse({ apiKey: "unused" }).success).toBe(false);
expect(inputSchema.parse({})).toEqual({});
expect(() => inputSchema.parse({})).toThrow();
});
const upstreamResponse = {
@@ -39,7 +39,9 @@ test("discovers and fetches the default key from read-only auth.json", async ()
const content = JSON.stringify({ "opencode-go": { type: "api", key: "fixture-key" } });
try {
await writeFile(path, content);
expect(await discover(path)).toEqual([{}]);
expect(await discover(path)).toEqual([
{ key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { path } },
]);
const requests: Array<{ url: string; authorization: string | null }> = [];
const fetchApi = async (url: string | URL | Request, init?: RequestInit) => {
requests.push({
@@ -48,8 +50,7 @@ test("discovers and fetches the default key from read-only auth.json", async ()
});
return Response.json(upstreamResponse);
};
const report = await fetchUsage({}, fetchApi as typeof fetch, path);
expect((await identify({}, path))?.key).toMatch(/^[a-f0-9]{64}$/);
const report = await fetchUsage({ path }, fetchApi as typeof fetch);
expect(requests).toEqual([
{ url: "https://opencode.ai/zen/go/v1/usage", authorization: "Bearer fixture-key" },
]);
@@ -75,38 +76,29 @@ test("omits default discovery when auth.json lacks an OpenCode Go API key", asyn
try {
await writeFile(path, JSON.stringify({ openai: { type: "oauth", access: "other-token" } }));
expect(await discover(path)).toEqual([]);
const report = await fetchUsage(
{},
async () => {
await expect(
fetchUsage({ path }, async () => {
throw new Error("must not fetch");
},
path,
);
expect(report).toEqual({ status: "unavailable", windows: [] });
}),
).rejects.toThrow("login store no longer exists");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test.each([401, 403])("maps HTTP %i to unavailable", async (status) => {
const report = await fetchUsage({}, async () => new Response(null, { status }), fixturePath);
expect(report.status).toBe("unavailable");
expect(report.windows).toEqual([]);
const report = await fetchUsage(
{ path: fixturePath },
async () => new Response(null, { status }),
);
expect(report).toEqual({
status: "unavailable",
problem: { kind: "rejected", status, refreshedBy: "opencode" },
});
});
test("identify returns a key when fetch finds OpenCode Go credentials", async () => {
let requested = false;
await fetchUsage(
{},
async () => {
requested = true;
return new Response(null, { status: 401 });
},
fixturePath,
);
expect(requested).toBe(true);
const identity = await identify({}, fixturePath);
expect(identity?.key).toMatch(/^[a-f0-9]{64}$/);
expect(identity?.key).not.toBe("fixture-secret");
expect(await identify({}, fixturePath)).toEqual(identity);
test.each(["missing file", "unrelated file"])("discovers no accounts for %s", async (scenario) => {
const path = join(fixtureDirectory, "missing.json");
if (scenario === "unrelated file") await writeFile(path, "{}");
expect(await discover(path)).toEqual([]);
});
@@ -4,6 +4,8 @@ import { join } from "node:path";
import { z } from "zod";
import {
hashAccountKey,
unavailable,
type UsageAccount,
toneFromUsedPct,
windowFromUsedPct,
type UsageReport,
@@ -44,23 +46,22 @@ export async function readDefaultKey(path = authPath()): Promise<string | null>
}
}
export async function discover(path = authPath()): Promise<Array<{}>> {
return (await readDefaultKey(path)) ? [{}] : [];
export async function discover(path = authPath()): Promise<UsageAccount[]> {
return (await readDefaultKey(path)) ? [{ key: hashAccountKey(path), input: { path } }] : [];
}
export async function fetchUsage(
_input: Input,
input: Input,
fetchApi: typeof fetch = fetch,
path = authPath(),
): Promise<UsageReport> {
const apiKey = await readDefaultKey(path);
if (!apiKey) return { status: "unavailable", windows: [] };
const apiKey = await readDefaultKey(input.path);
if (!apiKey) throw new Error("OpenCode Go login store no longer exists");
const response = await fetchApi("https://opencode.ai/zen/go/v1/usage", {
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
signal: AbortSignal.timeout(15_000),
});
if (response.status === 401 || response.status === 403)
return { status: "unavailable", windows: [] };
return unavailable({ kind: "rejected", status: response.status, refreshedBy: "opencode" });
if (!response.ok) throw new Error(`OpenCode Go usage API returned ${response.status}`);
const data = responseSchema.parse(await response.json());
const windows = (
@@ -82,8 +83,3 @@ export async function fetchUsage(
);
return { status: "available", planLabel: "Go", windows };
}
export async function identify(_input: Input, path = authPath()) {
const key = await readDefaultKey(path);
return key ? { key: hashAccountKey(key) } : null;
}
@@ -1,4 +1,3 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
export const inputSchema = z.object({ path: z.string().min(1) }).strict();
export type Input = z.infer<typeof inputSchema>;
+2 -3
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { fetchUsage, identify } from "./server/usage.js";
import { fetchUsage, discover } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -8,8 +8,7 @@ export default function contribute(server: PluginServerContext) {
label: "Z.ai",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
identify,
discover: () => discover(),
fetch: fetchUsage,
});
return () => {};
+50 -7
View File
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { identify, fetchUsage } from "./usage.js";
import { discover, fetchUsage } from "./usage.js";
import type { UsageReport } from "@getpaseo/plugin/server/usage";
function mockFetch(handlers: Map<string, () => Response>): typeof fetch {
@@ -65,14 +65,14 @@ describe("zai usage source", () => {
) {
return {
listUsage: async () => {
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
const report = await fetchFirst((url, init) => fetchApi(url, init));
return {
providers: [
{
providerId: "zai",
...report,
error: report.error ?? null,
planLabel: report.planLabel ?? null,
error: report.status === "error" ? report.error : null,
planLabel: report.status === "available" ? (report.planLabel ?? null) : null,
},
],
};
@@ -118,17 +118,60 @@ describe("zai usage source", () => {
});
});
it("identify returns a key when fetch finds zai credentials", async () => {
it("discovery returns a locator when fetch finds zai credentials", async () => {
const previous = process.env["ZAI_API_KEY"];
try {
process.env["ZAI_API_KEY"] = "fixture-token";
let requested = false;
await fetchUsage({}, async () => {
await fetchFirst(async () => {
requested = true;
return new Response(null, { status: 401 });
});
expect(requested).toBe(true);
expect(await identify()).toEqual({ key: "default" });
expect(await discover()).toEqual([
{ key: "default", input: { store: "env", locator: "ZAI_API_KEY" } },
]);
} finally {
if (previous === undefined) delete process.env["ZAI_API_KEY"];
else process.env["ZAI_API_KEY"] = previous;
}
});
describe("account discovery", () => {
it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const directory = await mkdtemp(join(tmpdir(), "usage-empty-"));
const original = { ...process.env };
try {
for (const key of Object.keys(process.env)) delete process.env[key];
process.env.HOME = directory;
process.env.USERPROFILE = directory;
if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}");
expect(await discover()).toEqual([]);
} finally {
for (const key of Object.keys(process.env)) delete process.env[key];
Object.assign(process.env, original);
await rm(directory, { recursive: true, force: true });
}
});
});
async function fetchFirst(fetchApi: typeof fetch) {
const accounts = await discover();
const account = accounts[0];
if (!account) throw new Error("No configured account");
return fetchUsage(account.input as Parameters<typeof fetchUsage>[0], fetchApi);
}
it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => {
const previous = process.env["ZAI_API_KEY"];
try {
process.env["ZAI_API_KEY"] = "fixture-rejected-login";
const report = await fetchUsage(
{ store: "env", locator: "ZAI_API_KEY" },
async () => new Response(null, { status }),
);
expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } });
} finally {
if (previous === undefined) delete process.env["ZAI_API_KEY"];
else process.env["ZAI_API_KEY"] = previous;
+13 -11
View File
@@ -1,7 +1,8 @@
import type { UsageInput } from "../shared/input.js";
import { z } from "zod";
import {
unavailableUsage,
unavailable,
type UsageAccount,
type UsageReport,
type UsageDetail,
} from "@getpaseo/plugin/server/usage";
@@ -28,10 +29,8 @@ export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
): Promise<UsageReport> {
void input;
const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"];
if (!token) return unavailableUsage();
const token = process.env[input.locator];
if (!token) throw new Error("Z.ai login store no longer exists");
const res = await fetchApi("https://api.z.ai/api/biz/subscription/list", {
signal: AbortSignal.timeout(15_000),
@@ -41,13 +40,13 @@ export async function fetchUsage(
},
});
if (!res.ok) {
return unavailableUsage();
}
if (res.status === 401 || res.status === 403)
return unavailable({ kind: "rejected", status: res.status });
if (!res.ok) throw new Error(`Z.ai usage API returned ${res.status}`);
const resp = ZaiUsageResponseSchema.parse(await res.json());
const sub = resp.data?.[0];
if (!sub) return unavailableUsage();
if (!sub) return unavailable({ kind: "no_quota", detail: "No active coding plan" });
const details: UsageDetail[] = [];
if (sub.status) details.push({ id: "status", label: "Status", value: sub.status });
@@ -65,6 +64,9 @@ export async function fetchUsage(
};
}
export async function identify() {
return process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"] ? { key: "default" } : null;
export async function discover(): Promise<UsageAccount[]> {
for (const locator of ["ZAI_API_KEY", "GLM_API_KEY"]) {
if (process.env[locator]) return [{ key: "default", input: { store: "env", locator } }];
}
return [];
}
+6 -2
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
export const inputSchema = z
.object({
store: z.literal("env"),
locator: z.string().min(1),
})
.strict();
export type UsageInput = z.infer<typeof inputSchema>;