mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
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:
@@ -1,6 +1,6 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { inputSchema } from "./shared/input.js";
|
||||
import { discover, fetchUsage, identify } from "./server/usage.js";
|
||||
import { discover, fetchUsage } from "./server/usage.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerUsageSource({
|
||||
@@ -9,7 +9,6 @@ export default function contribute(server: PluginServerContext) {
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: () => discover(),
|
||||
identify,
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { discover, fetchUsage, identify } from "./usage.js";
|
||||
import { discover, fetchUsage } from "./usage.js";
|
||||
|
||||
let home: string;
|
||||
beforeEach(async () => {
|
||||
@@ -19,8 +19,21 @@ async function json(path: string, value: unknown) {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, JSON.stringify(value));
|
||||
}
|
||||
async function account(input: Parameters<typeof fetchUsage>[0], env: NodeJS.ProcessEnv = {}) {
|
||||
return identify(input, lookup(env));
|
||||
async function accountIdentity(
|
||||
input: Parameters<typeof fetchUsage>[0],
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
) {
|
||||
const accounts = await discover(lookup(env));
|
||||
const account = accounts.find(
|
||||
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
|
||||
);
|
||||
if (!account) throw new Error("Expected discovered login");
|
||||
return { key: account.key, ...(account.label ? { label: account.label } : {}) };
|
||||
}
|
||||
async function logins(options: Parameters<typeof discover>[0]) {
|
||||
return (await discover(options)).map(
|
||||
(account) => account.input as Parameters<typeof fetchUsage>[0],
|
||||
);
|
||||
}
|
||||
async function database(path: string) {
|
||||
await mkdir((await import("node:path")).dirname(path), { recursive: true });
|
||||
@@ -70,9 +83,9 @@ test("Pi uses its own OAuth identity and re-reads rotated tokens without writing
|
||||
await json(join(home, ".claude.json"), {
|
||||
oauthAccount: { accountUuid: "other", organizationUuid: "other-org" },
|
||||
});
|
||||
const inputs = await discover(lookup());
|
||||
const inputs = await logins(lookup());
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(await account(inputs[0]!)).toEqual({ key: "pi-account" });
|
||||
expect(await accountIdentity(inputs[0]!)).toEqual({ key: "pi-account" });
|
||||
await json(path, {
|
||||
"openai-codex": { type: "oauth", access: "fixture-rotated", accountId: "pi-account" },
|
||||
});
|
||||
@@ -99,11 +112,11 @@ test("Pi honors directory override and ignores API keys and malformed files", as
|
||||
await json(path, {
|
||||
"openai-codex": { type: "oauth", access: "fixture-pi", accountId: "pi-account" },
|
||||
});
|
||||
expect(await discover(lookup(env))).toEqual([{ route: { store: "pi", path } }]);
|
||||
expect(await logins(lookup(env))).toEqual([{ route: { store: "pi", path } }]);
|
||||
await json(path, { "openai-codex": { type: "api_key", key: "fixture-key" } });
|
||||
expect(await discover(lookup(env))).toEqual([]);
|
||||
expect(await logins(lookup(env))).toEqual([]);
|
||||
await writeFile(path, "broken");
|
||||
expect(await discover(lookup(env))).toEqual([]);
|
||||
expect(await logins(lookup(env))).toEqual([]);
|
||||
});
|
||||
|
||||
test("expired Pi tokens are unavailable without calling usage or refreshing", async () => {
|
||||
@@ -115,7 +128,7 @@ test("expired Pi tokens are unavailable without calling usage or refreshing", as
|
||||
expires: 500,
|
||||
},
|
||||
});
|
||||
const [input] = await discover(lookup());
|
||||
const [input] = await logins(lookup());
|
||||
expect(
|
||||
(
|
||||
await fetchUsage(
|
||||
@@ -152,9 +165,12 @@ for (const location of ["default", "profile", "xdg", "override", "config"]) {
|
||||
}
|
||||
await database(path);
|
||||
const before = await readFile(path);
|
||||
const inputs = await discover(lookup(env));
|
||||
expect(inputs).toEqual([{ route: { store: "omp", path, credentialId: 1 } }]);
|
||||
expect(await account(inputs[0]!, env)).toEqual({ key: "omp-account" });
|
||||
const inputs = await logins(lookup(env));
|
||||
expect(inputs).toEqual([
|
||||
{ route: { store: "omp", path, credentialId: 1 } },
|
||||
{ route: { store: "omp", path, credentialId: 2 } },
|
||||
]);
|
||||
expect(await accountIdentity(inputs[0]!, env)).toEqual({ key: "omp-account" });
|
||||
expect(
|
||||
(
|
||||
await fetchUsage(
|
||||
@@ -176,7 +192,7 @@ test("missing SQLite skips only OMP", async () => {
|
||||
await json(join(home, ".pi", "agent", "auth.json"), {
|
||||
"openai-codex": { type: "oauth", access: "fixture-pi" },
|
||||
});
|
||||
const inputs = await discover({ ...lookup(), sqlite: () => undefined });
|
||||
const inputs = await logins({ ...lookup(), sqlite: () => undefined });
|
||||
expect(inputs).toEqual([
|
||||
{ route: { store: "pi", path: join(home, ".pi", "agent", "auth.json") } },
|
||||
]);
|
||||
@@ -199,17 +215,17 @@ test("Codex and OpenCode discover accounts in preference order with XDG and CODE
|
||||
await json(join(data, "opencode", "auth.json"), {
|
||||
openai: { type: "oauth", access: "fixture-open", accountId: "same" },
|
||||
});
|
||||
const inputs = await discover(lookup(env));
|
||||
const inputs = await logins(lookup(env));
|
||||
expect(inputs.map((input) => input.route.store)).toEqual(["codex", "codex", "opencode"]);
|
||||
for (const input of inputs) expect(await account(input, env)).toEqual({ key: "same" });
|
||||
for (const input of inputs) expect(await accountIdentity(input, env)).toEqual({ key: "same" });
|
||||
await json(join(data, "opencode", "auth.json"), { openai: { type: "api", key: "fixture-key" } });
|
||||
expect(await discover(lookup(env))).toHaveLength(2);
|
||||
expect(await logins(lookup(env))).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => {
|
||||
const path = join(home, ".omp", "agent", "agent.db");
|
||||
await database(path);
|
||||
const [input] = await discover(lookup());
|
||||
const [input] = await logins(lookup());
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const db = new DatabaseSync(path);
|
||||
db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run(
|
||||
@@ -231,15 +247,13 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login
|
||||
).toBe("unavailable");
|
||||
db.prepare("UPDATE auth_credentials SET disabled_cause = 'revoked' WHERE id = 1").run();
|
||||
db.close();
|
||||
expect(
|
||||
(
|
||||
await fetchUsage(
|
||||
input!,
|
||||
async () => {
|
||||
throw new Error("disabled login must not fetch");
|
||||
},
|
||||
lookup(),
|
||||
)
|
||||
).status,
|
||||
).toBe("unavailable");
|
||||
await expect(
|
||||
fetchUsage(
|
||||
input!,
|
||||
async () => {
|
||||
throw new Error("must not fetch");
|
||||
},
|
||||
lookup(),
|
||||
),
|
||||
).rejects.toThrow("login store no longer exists");
|
||||
});
|
||||
|
||||
@@ -97,7 +97,6 @@ function ompRows(path: string, lookup: StoreLookup) {
|
||||
if (!row.success) return [];
|
||||
try {
|
||||
const oauth = oauthSchema.parse(JSON.parse(row.data.data));
|
||||
if (oauth.expires === undefined || oauth.expires <= (lookup.now ?? Date.now)()) return [];
|
||||
return [{ id: row.data.id, oauth }];
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { fetchUsage, identify } from "./usage.js";
|
||||
import { fetchUsage, discover } from "./usage.js";
|
||||
|
||||
import { inputSchema, type CodexUsageInput } from "../shared/input.js";
|
||||
|
||||
@@ -80,12 +80,12 @@ test("discovery input requires a route and rejects raw credentials", () => {
|
||||
expect(() => inputSchema.parse({})).toThrow();
|
||||
});
|
||||
|
||||
test("identify returns a key when fetch finds Codex token credentials", async () => {
|
||||
test("discovery returns a key when fetch finds Codex token credentials", async () => {
|
||||
await writeAuth("fixture-supplied", "account-supplied");
|
||||
await fetchUsage(authInput(fixtureHome), (_url, init) =>
|
||||
response(init?.headers ?? {}, "account-supplied"),
|
||||
);
|
||||
expect(await identify(authInput(fixtureHome))).toEqual({ key: "account-supplied" });
|
||||
expect(await accountFor(authInput(fixtureHome))).toEqual({ key: "account-supplied" });
|
||||
});
|
||||
|
||||
test("coerces credit balance and marks a 96 percent window dangerous", async () => {
|
||||
@@ -136,12 +136,10 @@ test("summarizes the session and weekly windows by default, not code review", as
|
||||
]);
|
||||
});
|
||||
|
||||
test("HTML usage body is unavailable", async () => {
|
||||
const report = await fetchUsage(
|
||||
authInput(fixtureHome),
|
||||
async () => new Response("<html>Login</html>", { status: 200 }),
|
||||
);
|
||||
expect(report.status).toBe("unavailable");
|
||||
test("HTML usage body is an error", async () => {
|
||||
await expect(
|
||||
fetchUsage(authInput(fixtureHome), async () => new Response("<html>Login</html>")),
|
||||
).rejects.toThrow("Codex usage API returned HTML");
|
||||
});
|
||||
|
||||
test("401 leaves auth.json byte for byte unchanged and makes no refresh request", async () => {
|
||||
@@ -178,14 +176,16 @@ test("auth account claim survives token rotation", async () => {
|
||||
const token = (suffix: string) =>
|
||||
`header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "work-id" }, suffix })).toString("base64url")}.signature`;
|
||||
await writeAuth(token("first"));
|
||||
expect(await identify(authInput(fixtureHome))).toEqual({ key: "work-id" });
|
||||
expect(await accountFor(authInput(fixtureHome))).toEqual({ key: "work-id" });
|
||||
await writeAuth(token("second"));
|
||||
expect(await identify(authInput(fixtureHome))).toEqual({ key: "work-id" });
|
||||
expect(await accountFor(authInput(fixtureHome))).toEqual({ key: "work-id" });
|
||||
await writeAuth("opaque-token");
|
||||
expect(await identify(authInput(fixtureHome))).toBeNull();
|
||||
expect(await accountFor(authInput(fixtureHome))).toEqual({
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
});
|
||||
|
||||
test("identify reads an email label from auth id_token when access token has no profile", async () => {
|
||||
test("discovery reads an email label from auth id_token when access token has no profile", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "usage-codex-label-"));
|
||||
try {
|
||||
process.env["CODEX_HOME"] = home;
|
||||
@@ -196,7 +196,7 @@ test("identify reads an email label from auth id_token when access token has no
|
||||
tokens: { account_id: "account-id", access_token: "opaque-token", id_token: idToken },
|
||||
}),
|
||||
);
|
||||
expect(await identify(authInput(home))).toEqual({
|
||||
expect(await accountFor(authInput(home))).toEqual({
|
||||
key: "account-id",
|
||||
label: "id-owner@example.test",
|
||||
});
|
||||
@@ -206,7 +206,6 @@ test("identify reads an email label from auth id_token when access token has no
|
||||
});
|
||||
|
||||
test("discovers a Pi OAuth login independently of the CLI login", async () => {
|
||||
const { discover } = await import("./usage.js");
|
||||
const directory = await mkdtemp(join(tmpdir(), "usage-pi-discovery-"));
|
||||
try {
|
||||
await mkdir(join(directory, ".pi", "agent"), { recursive: true });
|
||||
@@ -217,10 +216,47 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => {
|
||||
}),
|
||||
);
|
||||
const inputs = await discover({ home: directory, env: {}, platform: "linux" });
|
||||
expect(inputs).toContainEqual({
|
||||
expect(inputs.map((account) => account.input)).toContainEqual({
|
||||
route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") },
|
||||
});
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function accountFor(input: CodexUsageInput) {
|
||||
const accounts = await discover({
|
||||
home: fixtureHome,
|
||||
env: {
|
||||
CODEX_HOME:
|
||||
input.route.store === "codex"
|
||||
? (await import("node:path")).dirname(input.route.path)
|
||||
: fixtureHome,
|
||||
},
|
||||
platform: "linux",
|
||||
});
|
||||
const account = accounts.find(
|
||||
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
|
||||
);
|
||||
if (!account) throw new Error("Expected discovered login");
|
||||
return { key: account.key, ...(account.label ? { label: account.label } : {}) };
|
||||
}
|
||||
|
||||
test.each(["empty home", "unrelated files"])(
|
||||
"discovers no Codex accounts in %s",
|
||||
async (scenario) => {
|
||||
const home = await mkdtemp(join(tmpdir(), "codex-no-login-"));
|
||||
try {
|
||||
if (scenario === "unrelated files") await writeFile(join(home, "unrelated.json"), "{}");
|
||||
expect(await discover({ home, env: {}, platform: "linux" })).toEqual([]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([401, 403])("reports a Codex login rejected with HTTP %i", async (status) => {
|
||||
expect(
|
||||
await fetchUsage(authInput(fixtureHome), async () => new Response(null, { status })),
|
||||
).toEqual({ status: "unavailable", problem: { kind: "rejected", status, refreshedBy: "codex" } });
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@ import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
balanceToneFromRemaining,
|
||||
hashAccountKey,
|
||||
unavailable,
|
||||
type UsageAccount,
|
||||
toneFromUsedPct,
|
||||
windowFromUsedPct,
|
||||
type UsageReport,
|
||||
@@ -39,7 +42,7 @@ interface Auth {
|
||||
expires?: number;
|
||||
}
|
||||
|
||||
export async function discover(lookup: StoreLookup = {}): Promise<CodexUsageInput[]> {
|
||||
export async function discover(lookup: StoreLookup = {}): Promise<UsageAccount[]> {
|
||||
const env = lookup.env ?? process.env;
|
||||
const home = lookup.home ?? homedir();
|
||||
const paths = [
|
||||
@@ -59,8 +62,11 @@ export async function discover(lookup: StoreLookup = {}): Promise<CodexUsageInpu
|
||||
{ route: { store: "pi", path: piAuthPath(lookup) } },
|
||||
...discoverOmp(lookup).map((route) => ({ route })),
|
||||
);
|
||||
const present: CodexUsageInput[] = [];
|
||||
for (const input of candidates) if (await readAuth(input, lookup)) present.push(input);
|
||||
const present: UsageAccount[] = [];
|
||||
for (const input of candidates) {
|
||||
const auth = await readAuth(input, lookup);
|
||||
if (auth) present.push({ ...accountIdentity(auth, input), input });
|
||||
}
|
||||
return present;
|
||||
}
|
||||
|
||||
@@ -104,8 +110,14 @@ export async function fetchUsage(
|
||||
lookup: StoreLookup = {},
|
||||
): Promise<UsageReport> {
|
||||
const auth = await readAuth(input, lookup);
|
||||
if (!auth || (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)()))
|
||||
return { status: "unavailable", windows: [] };
|
||||
if (!auth) throw new Error("Codex login store no longer exists");
|
||||
const refreshedBy = input.route.store;
|
||||
if (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)())
|
||||
return unavailable({
|
||||
kind: "expired",
|
||||
expiresAt: new Date(auth.expires).toISOString(),
|
||||
refreshedBy,
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
Accept: "application/json",
|
||||
@@ -117,10 +129,10 @@ export async function fetchUsage(
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403)
|
||||
return { status: "unavailable", windows: [] };
|
||||
return unavailable({ kind: "rejected", status: response.status, refreshedBy });
|
||||
if (!response.ok) throw new Error(`Codex usage API returned ${response.status}`);
|
||||
const text = await response.text();
|
||||
if (text.trim().startsWith("<")) return { status: "unavailable", windows: [] };
|
||||
if (text.trim().startsWith("<")) throw new Error("Codex usage API returned HTML");
|
||||
const usage = responseSchema.parse(JSON.parse(text));
|
||||
const windows = [
|
||||
usageWindow(
|
||||
@@ -181,9 +193,7 @@ function claimString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export async function identify(input: CodexUsageInput, lookup: StoreLookup = {}) {
|
||||
const auth = await readAuth(input, lookup);
|
||||
if (!auth) return null;
|
||||
function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; label?: string } {
|
||||
const access = jwtClaims(auth.token);
|
||||
const id = jwtClaims(auth.idToken);
|
||||
const accessAuth = claimObject(access, "https://api.openai.com/auth");
|
||||
@@ -193,11 +203,11 @@ export async function identify(input: CodexUsageInput, lookup: StoreLookup = {})
|
||||
claimString(accessAuth?.["chatgpt_account_id"]) ??
|
||||
claimString(access?.["chatgpt_account_id"]) ??
|
||||
claimString(idAuth?.["chatgpt_account_id"]);
|
||||
if (!key) return null;
|
||||
|
||||
const label =
|
||||
claimString(claimObject(access, "https://api.openai.com/profile")?.["email"]) ??
|
||||
claimString(access?.["email"]) ??
|
||||
claimString(claimObject(id, "https://api.openai.com/profile")?.["email"]) ??
|
||||
claimString(id?.["email"]);
|
||||
return { key, ...(label ? { label } : {}) };
|
||||
return { key: key ?? hashAccountKey(JSON.stringify(input.route)), ...(label ? { label } : {}) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user