diff --git a/extensions/codex/src/client.test.ts b/extensions/codex/src/client.test.ts new file mode 100644 index 0000000..1572cef --- /dev/null +++ b/extensions/codex/src/client.test.ts @@ -0,0 +1,336 @@ +import type { Api, AuthResult, Model } from "@earendil-works/pi-ai"; +import { afterEach, expect, it, vi } from "vitest"; +import { fetchCodexUsage, type CodexAccountRegistry } from "./client.ts"; + +const BASE_URL = "https://chatgpt.com/backend-api"; +const model: Model = { + id: "gpt-6-sol", + name: "Sol", + provider: "openai-codex", + api: "openai-codex-responses", + baseUrl: BASE_URL, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 272_000, + maxTokens: 128_000, +}; + +function token(account = "test-account"): string { + const payload = { "https://api.openai.com/auth": { chatgpt_account_id: account } }; + return `e30.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.fake-signature`; +} + +function json(value: unknown): Response { + return new Response(JSON.stringify(value), { headers: { "Content-Type": "application/json" } }); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function fixture() { + const auth: AuthResult = { source: "OAuth", auth: { apiKey: token() } }; + const provider = { baseUrl: BASE_URL, getModels: () => [model] }; + const registry = { + getProvider: vi.fn(() => provider), + getProviderAuth: vi.fn(async () => auth), + isUsingOAuth: vi.fn(() => true), + }; + const fetcher = vi.fn(async () => json({ plan_type: "plus" })); + return { registry, provider, auth, fetcher }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +it("uses Pi's existing Codex auth for the fixed account endpoint", async () => { + const f = fixture(); + f.auth.auth.headers = { "X-Unrelated-Secret": "must-not-forward" }; + expect(await fetchCodexUsage(f.registry, undefined, f.fetcher)).toEqual({ plan_type: "plus" }); + expect(f.registry.getProviderAuth).toHaveBeenCalledExactlyOnceWith("openai-codex"); + expect(f.fetcher).toHaveBeenCalledExactlyOnceWith( + `${BASE_URL}/wham/usage`, + expect.objectContaining({ method: "GET", redirect: "error", signal: expect.any(AbortSignal) }), + ); + const headers = new Headers(f.fetcher.mock.calls[0][1]?.headers); + expect(Object.fromEntries(headers)).toEqual({ + accept: "application/json", + authorization: `Bearer ${f.auth.auth.apiKey}`, + "chatgpt-account-id": "test-account", + }); +}); + +it("derives the account from the token like Pi's legacy Codex transport", async () => { + const f = fixture(); + f.auth.auth.headers = { "ChatGPT-Account-Id": "selected-account" }; + await fetchCodexUsage(f.registry, undefined, f.fetcher); + const headers = new Headers(f.fetcher.mock.calls[0][1]?.headers); + expect(headers.get("chatgpt-account-id")).toBe("test-account"); +}); + +it("resolves auth afresh rather than retaining another account's credentials", async () => { + const f = fixture(); + f.registry.getProviderAuth + .mockResolvedValueOnce({ source: "OAuth", auth: { apiKey: token("first") } }) + .mockResolvedValueOnce({ source: "OAuth", auth: { apiKey: token("second") } }); + await fetchCodexUsage(f.registry, undefined, f.fetcher); + await fetchCodexUsage(f.registry, undefined, f.fetcher); + const accounts = f.fetcher.mock.calls.map(([, init]) => + new Headers(init?.headers).get("chatgpt-account-id"), + ); + expect(accounts).toEqual(["first", "second"]); +}); + +it.each(["provider endpoint", "model endpoint", "auth endpoint"] as const)( + "does not send credentials for an unsupported %s configuration", + async (configuration) => { + const f = fixture(); + if (configuration === "provider endpoint") f.provider.baseUrl = "https://gateway.invalid"; + if (configuration === "model endpoint") { + f.provider.getModels = () => [{ ...model, baseUrl: "https://gateway.invalid" }]; + } + if (configuration === "auth endpoint") f.auth.auth.baseUrl = "https://gateway.invalid"; + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "unsupported", + }); + expect(f.fetcher).not.toHaveBeenCalled(); + }, +); + +it("requires Pi's Codex OAuth login rather than API-key authentication", async () => { + const f = fixture(); + f.registry.isUsingOAuth.mockReturnValue(false); + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "auth", + }); + expect(f.fetcher).not.toHaveBeenCalled(); +}); + +it.each([ + { label: "opaque", key: "opaque-api-key" }, + { label: "token-shaped", key: token() }, +])("rejects resolved $label API keys even if Pi's OAuth snapshot is stale", async ({ key }) => { + const f = fixture(); + f.auth.source = "models.json"; + f.auth.auth.apiKey = key; + f.auth.auth.headers = { "ChatGPT-Account-Id": "stale-account" }; + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "auth", + }); + expect(f.fetcher).not.toHaveBeenCalled(); +}); + +it("rechecks the endpoint after Pi finishes resolving credentials", async () => { + const f = fixture(); + const auth = deferred(); + f.registry.getProviderAuth.mockReturnValue(auth.promise); + const request = fetchCodexUsage(f.registry, undefined, f.fetcher); + f.provider.baseUrl = "https://gateway.invalid"; + auth.resolve(f.auth); + await expect(request).rejects.toMatchObject({ code: "unsupported" }); + expect(f.fetcher).not.toHaveBeenCalled(); +}); + +it("does not fetch or expose Pi's raw authentication errors", async () => { + const f = fixture(); + f.registry.getProviderAuth.mockRejectedValue(new Error("private token refresh details")); + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "auth", + message: "Codex account access is unavailable. Check your Pi Codex login.", + }); + expect(f.fetcher).not.toHaveBeenCalled(); +}); + +it.each([ + undefined, + { source: "OAuth", auth: {} }, + { source: "OAuth", auth: { apiKey: "not-a-codex-token" } }, +])("rejects missing or unusable auth without a request: %j", async (auth) => { + const f = fixture(); + f.registry.getProviderAuth.mockResolvedValue(auth); + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "auth", + }); + expect(f.fetcher).not.toHaveBeenCalled(); +}); + +it("does not expose transport error details", async () => { + const f = fixture(); + f.fetcher.mockRejectedValue(new Error("Authorization: Bearer secret")); + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "network", + message: "Could not reach the Codex account service.", + }); +}); + +it.each([401, 403])("reports HTTP %s without exposing the server's error body", async (status) => { + const f = fixture(); + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + f.fetcher.mockResolvedValue(new Response(body, { status })); + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "auth", + message: "Codex account access is unavailable. Check your Pi Codex login.", + }); + expect(cancel).toHaveBeenCalledOnce(); +}); + +it("returns retry-after information without automatically retrying", async () => { + const f = fixture(); + f.fetcher.mockResolvedValue( + new Response("private error", { status: 429, headers: { "Retry-After": "60" } }), + ); + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "http", + message: "Codex account service is rate limited.", + retryAfterMs: 60_000, + }); + expect(f.fetcher).toHaveBeenCalledOnce(); +}); + +it("does not follow or display a redirected endpoint", async () => { + const f = fixture(); + f.fetcher.mockResolvedValue( + new Response(null, { status: 302, headers: { Location: "https://other.invalid/private" } }), + ); + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "http", + message: "Codex account request failed (HTTP 302).", + }); + expect(f.fetcher.mock.calls[0][1]?.redirect).toBe("error"); + expect(f.fetcher).toHaveBeenCalledOnce(); +}); + +it("cancels a chunked response before retaining more than the byte limit", async () => { + const f = fixture(); + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + controller.enqueue(new Uint8Array(1)); + }, + cancel, + }); + f.fetcher.mockResolvedValue(new Response(body)); + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "response", + message: "Codex account response was too large.", + }); + expect(cancel).toHaveBeenCalledOnce(); +}); + +it("reports invalid JSON without repeating returned content", async () => { + const f = fixture(); + f.fetcher.mockResolvedValue(new Response("private server details")); + await expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "response", + message: "Codex account service returned invalid JSON.", + }); +}); + +it("cancels during handoff of an already-buffered response", async () => { + const f = fixture(); + const started = deferred(); + const incoming = deferred(); + const controller = new AbortController(); + const response = json({ plan_type: "plus" }); + const discard = vi.spyOn(response.body!, "cancel"); + f.fetcher.mockImplementation(() => { + started.resolve(); + return incoming.promise; + }); + const request = fetchCodexUsage(f.registry, controller.signal, f.fetcher); + await started.promise; + incoming.resolve(response); + queueMicrotask(() => controller.abort()); + await expect(request).rejects.toMatchObject({ code: "cancelled" }); + expect(discard).toHaveBeenCalledOnce(); +}); + +it("cancels before auth resolution if the caller is already aborted", async () => { + const f = fixture(); + const controller = new AbortController(); + controller.abort(); + await expect(fetchCodexUsage(f.registry, controller.signal, f.fetcher)).rejects.toMatchObject({ + code: "cancelled", + }); + expect(f.registry.getProviderAuth).not.toHaveBeenCalled(); + expect(f.fetcher).not.toHaveBeenCalled(); +}); + +it("ignores auth that resolves after the caller cancels", async () => { + const f = fixture(); + const auth = deferred(); + f.registry.getProviderAuth.mockReturnValue(auth.promise); + const controller = new AbortController(); + const request = fetchCodexUsage(f.registry, controller.signal, f.fetcher); + controller.abort(); + await expect(request).rejects.toMatchObject({ code: "cancelled" }); + auth.resolve(f.auth); + await auth.promise; + expect(f.fetcher).not.toHaveBeenCalled(); +}); + +it("discards a response arriving after cancellation even if fetch ignores the signal", async () => { + const f = fixture(); + const started = deferred(); + const response = deferred(); + const discarded = deferred(); + f.fetcher.mockImplementation(() => { + started.resolve(); + return response.promise; + }); + const controller = new AbortController(); + const request = fetchCodexUsage(f.registry, controller.signal, f.fetcher); + await started.promise; + controller.abort(); + await expect(request).rejects.toMatchObject({ code: "cancelled" }); + response.resolve(new Response(new ReadableStream({ cancel: () => discarded.resolve() }))); + await discarded.promise; +}); + +it("bounds the wait for Pi's refresh without taking over refresh cancellation", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.registry.getProviderAuth.mockReturnValue(new Promise(() => {})); + const result = expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "timeout", + }); + await vi.advanceTimersByTimeAsync(10_000); + await result; + expect(f.fetcher).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("bounds the wait for response headers", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.fetcher.mockReturnValue(new Promise(() => {})); + const result = expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "timeout", + }); + await vi.advanceTimersByTimeAsync(10_000); + await result; + expect(f.fetcher).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("keeps the deadline active while consuming the response body", async () => { + vi.useFakeTimers(); + const f = fixture(); + const cancel = vi.fn(); + f.fetcher.mockResolvedValue(new Response(new ReadableStream({ cancel }))); + const result = expect(fetchCodexUsage(f.registry, undefined, f.fetcher)).rejects.toMatchObject({ + code: "timeout", + }); + await vi.advanceTimersByTimeAsync(10_000); + await result; + expect(cancel).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/extensions/codex/src/client.ts b/extensions/codex/src/client.ts new file mode 100644 index 0000000..172471f --- /dev/null +++ b/extensions/codex/src/client.ts @@ -0,0 +1,250 @@ +import type { AuthResult, Provider } from "@earendil-works/pi-ai"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; + +const PROVIDER = "openai-codex"; +const BASE_URL = "https://chatgpt.com/backend-api"; +const USAGE_URL = `${BASE_URL}/wham/usage`; +const TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 1024 * 1024; + +export type CodexAccountRegistry = Pick< + ExtensionContext["modelRegistry"], + "getProviderAuth" | "isUsingOAuth" +> & { + getProvider(id: string): Pick | undefined; +}; + +type Failure = "auth" | "unsupported" | "cancelled" | "timeout" | "network" | "response" | "http"; + +export class CodexAccountError extends Error { + constructor( + readonly code: Failure, + message: string, + readonly retryAfterMs?: number, + ) { + super(message); + this.name = "CodexAccountError"; + } +} + +/** Read account data with Pi's existing login. Pi owns credential refresh and persistence. */ +export async function fetchCodexUsage( + registry: CodexAccountRegistry, + signal?: AbortSignal, + fetcher: typeof fetch = fetch, +): Promise { + const deadline = new AbortController(); + const timer = setTimeout(() => deadline.abort(), TIMEOUT_MS); + const requestSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal; + try { + requestSignal.throwIfAborted(); + const headers = await resolveAccountHeaders(registry, requestSignal); + requestSignal.throwIfAborted(); + const data = await requestUsage(headers, requestSignal, fetcher); + requestSignal.throwIfAborted(); + return data; + } catch (error) { + if (signal?.aborted) + throw new CodexAccountError("cancelled", "Codex account request cancelled."); + if (deadline.signal.aborted) + throw new CodexAccountError("timeout", "Codex account request timed out."); + if (error instanceof CodexAccountError) throw error; + // Neither auth errors nor fetch errors are safe to display verbatim. + throw new CodexAccountError("network", "Could not reach the Codex account service."); + } finally { + clearTimeout(timer); + deadline.abort(); + } +} + +async function resolveAccountHeaders( + registry: CodexAccountRegistry, + signal: AbortSignal, +): Promise { + const provider = registry.getProvider(PROVIDER); + const model = provider + ?.getModels() + .find( + (candidate) => + candidate.provider === PROVIDER && + candidate.api === "openai-codex-responses" && + officialBase(candidate.baseUrl), + ); + if (!officialBase(provider?.baseUrl) || !model) { + throw new CodexAccountError( + "unsupported", + "Account usage requires the official Codex provider.", + ); + } + if (!registry.isUsingOAuth(model)) throw authError(); + + let resolvedAuth: AuthResult | undefined; + try { + // Pi owns token refresh; aborting here only stops our wait for its result. + resolvedAuth = await waitWithAbort(registry.getProviderAuth(PROVIDER), signal); + } catch { + throw authError(); + } + signal.throwIfAborted(); + // Pi's resolved source describes these credentials, unlike its availability snapshot. + if (!resolvedAuth || resolvedAuth.source !== "OAuth") throw authError(); + if ( + !officialBase(registry.getProvider(PROVIDER)?.baseUrl) || + (resolvedAuth.auth.baseUrl !== undefined && !officialBase(resolvedAuth.auth.baseUrl)) + ) { + throw new CodexAccountError( + "unsupported", + "Account usage does not support endpoint overrides.", + ); + } + const accessToken = resolvedAuth.auth.apiKey; + if (!accessToken) throw authError(); + // Match Pi's legacy transport: token routing overrides configured account headers. + const accountId = tokenAccountId(accessToken); + if (!accountId) throw authError(); + try { + // Forward only the two credentials needed by this fixed endpoint, never + // arbitrary provider headers or provider-scoped environment values. + return new Headers({ + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + "ChatGPT-Account-Id": accountId, + }); + } catch { + throw authError(); + } +} + +function officialBase(url: string | undefined): boolean { + return url?.replace(/\/+$/, "") === BASE_URL; +} + +function tokenAccountId(token: string): string | undefined { + try { + const parts = token.split("."); + if (parts.length !== 3) return undefined; + // Routing metadata only, not signature verification. Legacy Pi Codex auth + // returns the access token without the account header its transport derives. + const payload: unknown = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); + if (!payload || typeof payload !== "object" || !("https://api.openai.com/auth" in payload)) + return undefined; + const claim = payload["https://api.openai.com/auth"]; + if (!claim || typeof claim !== "object" || !("chatgpt_account_id" in claim)) return undefined; + const accountId = claim.chatgpt_account_id; + if (typeof accountId !== "string" || !accountId.trim()) return undefined; + return accountId; + } catch { + return undefined; + } +} + +function authError(): CodexAccountError { + return new CodexAccountError( + "auth", + "Codex account access is unavailable. Check your Pi Codex login.", + ); +} + +async function requestUsage( + headers: Headers, + requestSignal: AbortSignal, + fetcher: typeof fetch, +): Promise { + let receivedResponse: Response | undefined; + try { + const request = fetcher(USAGE_URL, { + method: "GET", + headers, + redirect: "error", + signal: requestSignal, + }).then((response) => { + receivedResponse = response; + // Fetch adapters can ignore abort and deliver a response after we stopped waiting. + if (requestSignal.aborted) { + discardBody(response); + requestSignal.throwIfAborted(); + } + return response; + }); + const response = await waitWithAbort(request, requestSignal); + if (!response.ok) throw responseError(response); + return await readJson(response, requestSignal); + } finally { + if (receivedResponse && !receivedResponse.bodyUsed) discardBody(receivedResponse); + } +} + +function responseError(response: Response): CodexAccountError { + if (response.status === 401 || response.status === 403) return authError(); + if (response.status === 429) { + const header = response.headers.get("retry-after"); + let retryAfterMs: number | undefined; + if (header) { + const seconds = Number(header); + const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - Date.now(); + if (Number.isFinite(delay) && delay >= 0) retryAfterMs = delay; + } + return new CodexAccountError("http", "Codex account service is rate limited.", retryAfterMs); + } + return new CodexAccountError("http", `Codex account request failed (HTTP ${response.status}).`); +} + +function discardBody(response: Response): void { + void response.body?.cancel().catch(() => {}); +} + +async function readJson(response: Response, signal: AbortSignal): Promise { + const bytes = await readResponseBytes(response, signal); + signal.throwIfAborted(); + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return JSON.parse(text); + } catch { + throw new CodexAccountError("response", "Codex account service returned invalid JSON."); + } +} + +async function readResponseBytes(response: Response, signal: AbortSignal): Promise { + if (!response.body) throw new CodexAccountError("response", "Codex account response was empty."); + const reader = response.body.getReader(); + const bytes = new Uint8Array(MAX_RESPONSE_BYTES); + let bytesRead = 0; + let fullyRead = false; + try { + for (;;) { + const { value, done } = await waitWithAbort(reader.read(), signal); + if (done) { + fullyRead = true; + return bytes.subarray(0, bytesRead); + } + if (value.byteLength > bytes.byteLength - bytesRead) { + throw new CodexAccountError("response", "Codex account response was too large."); + } + bytes.set(value, bytesRead); + bytesRead += value.byteLength; + } + } finally { + if (!fullyRead) void reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +async function waitWithAbort(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + // The operation has already started; observe a late rejection without accepting its result. + void promise.catch(() => {}); + signal.throwIfAborted(); + } + let abort = () => {}; + const cancelled = new Promise((_resolve, reject) => { + abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + }); + try { + const value = await Promise.race([promise, cancelled]); + signal.throwIfAborted(); + return value; + } finally { + signal.removeEventListener("abort", abort); + } +} diff --git a/extensions/codex/src/index.test.ts b/extensions/codex/src/index.test.ts index d377de2..bd06bae 100644 --- a/extensions/codex/src/index.test.ts +++ b/extensions/codex/src/index.test.ts @@ -3,6 +3,12 @@ import { join } from "node:path"; import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { afterAll, expect, it, vi } from "vitest"; import codex from "./index.ts"; +import { fetchCodexUsage } from "./client.ts"; + +vi.mock("./client.ts", async (importOriginal) => ({ + ...(await importOriginal()), + fetchCodexUsage: vi.fn(), +})); const directory = await vi.hoisted(async () => { const { mkdtempSync } = await import("node:fs"); @@ -21,6 +27,7 @@ afterAll(() => { }); async function load(prefix = "eleith") { + vi.mocked(fetchCodexUsage).mockReset(); writeFileSync(configPath, JSON.stringify({ commandPrefix: prefix })); type Command = Parameters[1]; type Listener = ( @@ -49,6 +56,7 @@ async function load(prefix = "eleith") { hasUI: false, isIdle: () => true, getContextUsage: () => undefined, + modelRegistry: {}, ui: { notify, confirm: vi.fn(async () => false) }, sessionManager: { getBranch: () => [], getSessionId: () => "session", getLeafId: () => null }, } as unknown as ExtensionCommandContext; @@ -82,6 +90,7 @@ it("registers only the configured native command once after binding", async () = expect(await context.command().getArgumentCompletions?.("")).toEqual([ { value: "extend", label: "extend" }, { value: "restore", label: "restore" }, + { value: "usage", label: "usage" }, ]); }); @@ -93,7 +102,7 @@ it("handles no argument as status rather than an invalid default action", async expect(context.pi.setModel).not.toHaveBeenCalled(); await context.command().handler("extend restore", context.ctx); expect(context.notify).toHaveBeenLastCalledWith( - "Usage: /eleith:codex [extend|restore]", + "Usage: /eleith:codex [extend|restore|usage]", "warning", ); }); @@ -111,8 +120,50 @@ it("wires restore/reconcile hooks without provider calls for an unsupported mode expect(context.pi.setModel).not.toHaveBeenCalled(); expect(context.pi.appendEntry).not.toHaveBeenCalled(); expect(context.pi.on.mock.calls.map(([name]) => name)).not.toContain("before_agent_start"); + expect(fetchCodexUsage).not.toHaveBeenCalled(); }); +it("routes usage through the configured command without requiring an idle Codex selection", async () => { + const context = await load("personal"); + vi.mocked(fetchCodexUsage).mockResolvedValue({ plan_type: "plus" }); + await context.emit("session_start"); + await context.command().handler("usage extra", context.ctx); + expect(fetchCodexUsage).not.toHaveBeenCalled(); + context.notify.mockClear(); + await context.command().handler("usage", { ...context.ctx, isIdle: () => false }); + expect(fetchCodexUsage).toHaveBeenCalledExactlyOnceWith( + context.ctx.modelRegistry, + expect.any(AbortSignal), + ); + expect(context.notify).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Plan: plus"), + "info", + ); + expect(context.pi.setModel).not.toHaveBeenCalled(); + expect(context.pi.appendEntry).not.toHaveBeenCalled(); +}); + +it.each(["model_select", "session_tree", "session_shutdown"])( + "%s cancels a pending usage report instead of displaying a late reply", + async (event) => { + const context = await load(); + let finish!: (value: unknown) => void; + vi.mocked(fetchCodexUsage).mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + await context.emit("session_start"); + const request = context.command().handler("usage", context.ctx); + const signal = vi.mocked(fetchCodexUsage).mock.calls[0][1]!; + await context.emit(event); + expect(signal.aborted).toBe(true); + finish({ plan_type: "plus" }); + await request; + expect(context.notify).not.toHaveBeenCalled(); + }, +); + it("fails invalid prefix settings before registering any capabilities", async () => { writeFileSync(configPath, '{"commandPrefix":"Invalid Prefix"}'); const on = vi.fn(), diff --git a/extensions/codex/src/index.ts b/extensions/codex/src/index.ts index 0d854d7..6fff236 100644 --- a/extensions/codex/src/index.ts +++ b/extensions/codex/src/index.ts @@ -2,24 +2,38 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { registerCommand } from "@eleith/pi-internal/commands"; import { getSettings } from "@eleith/pi-internal/config"; import { ContextController } from "./controller.ts"; +import { UsageController } from "./usage-controller.ts"; export default async function codex(pi: ExtensionAPI): Promise { const settings = await getSettings(); const commandName = `${settings.commandPrefix}:codex`; const controller = new ContextController(pi, commandName); + const usage = new UsageController(); registerCommand(pi, { name: commandName, - description: "Codex context window controls", - actions: ["extend", "restore"], + description: "Codex context controls and account usage", + actions: ["extend", "restore", "usage"], defaultAction: "", - handler: (action, ctx) => controller.handle(action, ctx), + handler(action, ctx) { + if (action === "usage") return usage.report(ctx); + return controller.handle(action, ctx); + }, }); pi.on("session_start", (_event, ctx) => controller.restore(ctx)); - pi.on("session_tree", (_event, ctx) => controller.restore(ctx)); - pi.on("model_select", (_event, ctx) => controller.modelSelected(ctx)); - pi.on("session_shutdown", (_event, ctx) => controller.shutdown(ctx)); + pi.on("session_tree", (_event, ctx) => { + usage.cancel(); + return controller.restore(ctx); + }); + pi.on("model_select", (_event, ctx) => { + usage.cancel(); + return controller.modelSelected(ctx); + }); + pi.on("session_shutdown", (_event, ctx) => { + usage.dispose(); + controller.shutdown(ctx); + }); pi.on("input", async (event, ctx) => { if (event.streamingBehavior === undefined && ctx.isIdle()) await controller.reconcile(ctx); }); diff --git a/extensions/codex/src/usage-controller.test.ts b/extensions/codex/src/usage-controller.test.ts new file mode 100644 index 0000000..1663d16 --- /dev/null +++ b/extensions/codex/src/usage-controller.test.ts @@ -0,0 +1,146 @@ +import { expect, it, vi } from "vitest"; +import { CodexAccountError, type CodexAccountRegistry, type fetchCodexUsage } from "./client.ts"; +import { UsageController, type UsageHost } from "./usage-controller.ts"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function fixture() { + const clock = { now: Date.parse("2026-01-10T12:00:00Z") }; + const fetchUsage = vi.fn(async () => ({ plan_type: "plus" })); + const ctx = { + modelRegistry: {} as CodexAccountRegistry, + sessionManager: { getSessionId: vi.fn(() => "session") }, + ui: { notify: vi.fn() }, + } satisfies UsageHost; + const controller = new UsageController(fetchUsage, () => clock.now); + return { controller, ctx, fetchUsage, clock }; +} + +it("reports the configured Codex account independently of the selected model", async () => { + const f = fixture(); + await f.controller.report(f.ctx); + expect(f.fetchUsage).toHaveBeenCalledExactlyOnceWith( + f.ctx.modelRegistry, + expect.any(AbortSignal), + ); + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("codex account usage · openai-codex"), + "info", + ); +}); + +it("supersedes an earlier request and ignores its late reply", async () => { + const f = fixture(); + const first = deferred(); + f.fetchUsage.mockReturnValueOnce(first.promise).mockResolvedValueOnce({ plan_type: "new-plan" }); + const oldRequest = f.controller.report(f.ctx); + const oldSignal = f.fetchUsage.mock.calls[0][1]!; + await f.controller.report(f.ctx); + expect(oldSignal.aborted).toBe(true); + first.resolve({ plan_type: "old-plan" }); + await oldRequest; + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Plan: new-plan"), + "info", + ); +}); + +it.each(["cancel", "dispose"] as const)( + "%s discards a late reply before accessing expired context", + async (action) => { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockReturnValue(response.promise); + const request = f.controller.report(f.ctx); + const signal = f.fetchUsage.mock.calls[0][1]!; + const notify = f.ctx.ui.notify; + f.controller[action](); + Object.defineProperty(f.ctx, "sessionManager", { + get: () => { + throw new Error("Expired context"); + }, + }); + Object.defineProperty(f.ctx, "ui", { + get: () => { + throw new Error("Expired context"); + }, + }); + response.resolve({ plan_type: "plus" }); + await request; + expect(signal.aborted).toBe(true); + expect(notify).not.toHaveBeenCalled(); + if (action === "dispose") await f.controller.report(f.ctx); + }, +); + +it("disposal suppresses a late error and is idempotent", async () => { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockReturnValue(response.promise); + const request = f.controller.report(f.ctx); + f.controller.dispose(); + f.controller.dispose(); + response.reject(new Error("private late failure")); + await request; + await f.controller.report(f.ctx); + expect(f.ctx.ui.notify).not.toHaveBeenCalled(); + expect(f.fetchUsage).toHaveBeenCalledOnce(); +}); + +it("does not deliver a reply to a replacement session", async () => { + const f = fixture(); + const response = deferred(); + f.fetchUsage.mockReturnValue(response.promise); + const request = f.controller.report(f.ctx); + f.ctx.sessionManager.getSessionId.mockReturnValue("different-session"); + response.resolve({ plan_type: "plus" }); + await request; + expect(f.ctx.ui.notify).not.toHaveBeenCalled(); +}); + +it("reports an unrecognized schema without dumping account data", async () => { + const f = fixture(); + f.fetchUsage.mockResolvedValue({ private_account_data: "do-not-display" }); + await f.controller.report(f.ctx); + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( + "Codex account service returned an unrecognized usage response.", + "warning", + ); +}); + +it("does not expose unexpected error details", async () => { + const f = fixture(); + f.fetchUsage.mockRejectedValue(new Error("private failure details")); + await f.controller.report(f.ctx); + expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith( + "Could not read Codex account usage.", + "warning", + ); +}); + +it("honors retry-after without polling or making another request until explicitly asked", async () => { + const f = fixture(); + f.fetchUsage.mockRejectedValueOnce( + new CodexAccountError("http", "Codex account service is rate limited.", 60_000), + ); + await f.controller.report(f.ctx); + await f.controller.report(f.ctx); + expect(f.fetchUsage).toHaveBeenCalledOnce(); + expect(f.ctx.ui.notify).toHaveBeenLastCalledWith( + "Codex account service is rate limited. Try again in 60s.", + "warning", + ); + f.clock.now += 60_000; + expect(f.fetchUsage).toHaveBeenCalledOnce(); + await f.controller.report(f.ctx); + expect(f.fetchUsage).toHaveBeenCalledTimes(2); + expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Plan: plus"), "info"); +}); diff --git a/extensions/codex/src/usage-controller.ts b/extensions/codex/src/usage-controller.ts new file mode 100644 index 0000000..02a55de --- /dev/null +++ b/extensions/codex/src/usage-controller.ts @@ -0,0 +1,69 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { CodexAccountError, fetchCodexUsage, type CodexAccountRegistry } from "./client.ts"; +import { formatCodexUsage } from "./usage.ts"; + +export interface UsageHost { + readonly modelRegistry: CodexAccountRegistry; + readonly sessionManager: Pick; + readonly ui: Pick; +} + +export class UsageController { + private pending: AbortController | undefined; + private retryAt = 0; + private disposed = false; + + constructor( + private readonly fetchUsage: typeof fetchCodexUsage = fetchCodexUsage, + private readonly now: () => number = Date.now, + ) {} + + async report(ctx: UsageHost): Promise { + if (this.disposed) return; + this.cancel(); + if (this.now() < this.retryAt) { + const seconds = Math.ceil((this.retryAt - this.now()) / 1000); + ctx.ui.notify(`Codex account service is rate limited. Try again in ${seconds}s.`, "warning"); + return; + } + const sessionId = ctx.sessionManager.getSessionId(); + const operation = new AbortController(); + this.pending = operation; + try { + const data = await this.fetchUsage(ctx.modelRegistry, operation.signal); + if (!this.canDeliverReport(operation, ctx, sessionId)) return; + const report = formatCodexUsage(data, this.now()); + if (report === undefined) { + ctx.ui.notify("Codex account service returned an unrecognized usage response.", "warning"); + } else { + ctx.ui.notify(report, "info"); + } + } catch (error) { + if (!this.canDeliverReport(operation, ctx, sessionId)) return; + const message = + error instanceof CodexAccountError ? error.message : "Could not read Codex account usage."; + if (error instanceof CodexAccountError && error.retryAfterMs !== undefined) { + this.retryAt = this.now() + error.retryAfterMs; + } + ctx.ui.notify(message, "warning"); + } finally { + if (this.pending === operation) this.pending = undefined; + } + } + + cancel(): void { + this.pending?.abort(); + this.pending = undefined; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.cancel(); + } + + private canDeliverReport(operation: AbortController, ctx: UsageHost, sessionId: string): boolean { + if (this.disposed || this.pending !== operation || operation.signal.aborted) return false; + return ctx.sessionManager.getSessionId() === sessionId; + } +} diff --git a/extensions/codex/src/usage-sdk.test.ts b/extensions/codex/src/usage-sdk.test.ts new file mode 100644 index 0000000..63c1427 --- /dev/null +++ b/extensions/codex/src/usage-sdk.test.ts @@ -0,0 +1,169 @@ +import { rmSync } from "node:fs"; +import { afterAll, expect, it, vi } from "vitest"; +import { + InMemoryModelsStore, + type Api, + type CredentialStore, + type Model, + type OAuthCredential, +} from "@earendil-works/pi-ai"; +import { + createAgentSession, + DefaultResourceLoader, + ModelRuntime, + SessionManager, + SettingsManager, + type ExtensionUIContext, +} from "@earendil-works/pi-coding-agent"; +import codex from "./index.ts"; + +const directory = await vi.hoisted(async () => { + const { mkdtempSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const directory = mkdtempSync(join(tmpdir(), "codex-usage-sdk-test-")); + vi.stubEnv("PI_CODING_AGENT_DIR", directory); + vi.stubEnv("PI_OFFLINE", "1"); + return directory; +}); +afterAll(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); +}); + +it("SDK usage reuses Pi's OAuth without a model turn or credential write", async () => { + const usageResponse = { + plan_type: "plus", + rate_limit: { + primary_window: { + used_percent: 25, + limit_window_seconds: 18_000, + reset_after_seconds: 1800, + }, + }, + }; + const accountRequest = vi.fn(async (url) => { + if (url !== "https://chatgpt.com/backend-api/wham/usage") { + throw new Error("Unexpected network request"); + } + return new Response(JSON.stringify(usageResponse)); + }); + vi.stubGlobal("fetch", accountRequest); + const { runtime, model, accessToken, credentialWrites, modelTurns } = + await createOfflineCodexRuntime(); + const { session, sessionManager, notify, extensionErrors, closeSession } = + await openCodexExtensionSession(runtime, model); + try { + const branch = structuredClone(sessionManager.getBranch()); + await session.prompt("/eleith:codex"); + expect(accountRequest).not.toHaveBeenCalled(); + + await session.prompt("/eleith:codex usage"); + expect(notify).toHaveBeenLastCalledWith( + expect.stringContaining("Primary (5h): 25% used"), + "info", + ); + expect(accountRequest).toHaveBeenCalledOnce(); + const headers = new Headers(accountRequest.mock.calls[0][1]?.headers); + expect(headers.get("authorization")).toBe(`Bearer ${accessToken}`); + expect(headers.get("chatgpt-account-id")).toBe("sdk-test-account"); + expect(sessionManager.getBranch()).toEqual(branch); + expect(session.model).toEqual(model); + expect(credentialWrites).not.toHaveBeenCalled(); + expect(modelTurns).not.toHaveBeenCalled(); + expect(extensionErrors).toEqual([]); + } finally { + await closeSession(); + } +}); + +async function createOfflineCodexRuntime() { + const payload = { "https://api.openai.com/auth": { chatgpt_account_id: "sdk-test-account" } }; + const accessToken = `e30.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.fake-signature`; + const credential: OAuthCredential = { + type: "oauth", + access: accessToken, + refresh: "must-not-refresh", + expires: Date.now() + 3_600_000, + }; + const credentialWrites = vi.fn(async () => { + throw new Error("Credential writes forbidden"); + }); + const credentials: CredentialStore = { + read: async (id) => (id === "openai-codex" ? credential : undefined), + list: async () => [{ providerId: "openai-codex", type: "oauth" }], + modify: credentialWrites, + delete: async () => { + throw new Error("Credential deletion forbidden"); + }, + }; + const runtime = await ModelRuntime.create({ + credentials, + modelsPath: null, + modelsStore: new InMemoryModelsStore(), + allowModelNetwork: false, + refreshOnCreate: false, + }); + // Populate Pi's auth-availability snapshot without probing other providers or the network. + const refresh = await runtime.refresh({ providers: ["openai-codex"], allowNetwork: false }); + expect(refresh.errors.size).toBe(0); + expect(runtime.isUsingOAuth("openai-codex")).toBe(true); + const modelTurns = vi.spyOn(runtime, "streamSimple").mockImplementation(() => { + throw new Error("Model turns forbidden"); + }); + const model = runtime.getModel("openai-codex", "gpt-6-sol"); + if (!model) throw new Error("Built-in Codex model missing"); + return { runtime, model, accessToken, credentialWrites, modelTurns }; +} + +async function openCodexExtensionSession(runtime: ModelRuntime, model: Model) { + const settingsManager = SettingsManager.inMemory({ + packages: [], + enableInstallTelemetry: false, + enableAnalytics: false, + compaction: { enabled: false }, + retry: { enabled: false }, + cacheWarming: "off", + }); + const resourceLoader = new DefaultResourceLoader({ + cwd: directory, + agentDir: directory, + settingsManager, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + extensionFactories: [codex], + }); + await resourceLoader.reload(); + const sessionManager = SessionManager.inMemory(directory); + const { session } = await createAgentSession({ + cwd: directory, + agentDir: directory, + resourceLoader, + settingsManager, + modelRuntime: runtime, + model, + sessionManager, + noTools: "all", + }); + const notify = vi.fn(); + const extensionErrors: unknown[] = []; + await session.bindExtensions({ + mode: "tui", + uiContext: { notify } as unknown as ExtensionUIContext, + onError: (error) => extensionErrors.push(error), + }); + return { + session, + sessionManager, + notify, + extensionErrors, + async closeSession() { + await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); + session.dispose(); + }, + }; +} diff --git a/extensions/codex/src/usage.test.ts b/extensions/codex/src/usage.test.ts new file mode 100644 index 0000000..2283ad1 --- /dev/null +++ b/extensions/codex/src/usage.test.ts @@ -0,0 +1,154 @@ +import { expect, it } from "vitest"; +import { formatCodexUsage } from "./usage.ts"; + +const NOW = Date.parse("2026-01-10T12:00:00Z"); +const primary = { + used_percent: 25, + limit_window_seconds: 1800, + reset_at: NOW / 1000 + 3600, +}; + +it("formats an exact account report without mutating the payload", () => { + const payload = { + plan_type: "plus", + account_id: "do-not-display-account-id", + rate_limit: { allowed: true, primary_window: primary, secondary_window: null }, + credits: { has_credits: true, unlimited: false, balance: "12.50" }, + future_field: { anything: true }, + }; + const original = structuredClone(payload); + const report = formatCodexUsage(payload, NOW)!; + expect(report).toBe(`codex account usage · openai-codex +Plan: plus + +Codex + Primary (30m): 25% used · 75% remaining + Resets: 2026-01-10 13:00:00 UTC (in 1h) + Secondary: not reported + +Credits: 12.5 balance (available)`); + expect(payload).toEqual(original); +}); + +it("does not invent windows or unlimited credits for plans with missing quota data", () => { + const report = formatCodexUsage( + { + plan_type: "pro", + rate_limit: null, + code_review_rate_limit: null, + credits: null, + }, + NOW, + )!; + expect(report).toContain("Codex\n Not reported"); + expect(report).toContain("Code review\n Not reported"); + expect(report).toContain("Credits: not reported"); + expect(report).not.toMatch(/0%|5h|7d|unlimited/); +}); + +it.each([ + [0, "0% used · 100% remaining"], + [-1, "usage unknown"], + [101, "usage unknown"], + [NaN, "usage unknown"], + ["25", "usage unknown"], +] as const)("keeps invalid percentages distinct from genuine zero: %s", (used, expected) => { + const report = formatCodexUsage( + { + rate_limit: { primary_window: { ...primary, used_percent: used } }, + }, + NOW, + )!; + expect(report).toContain(expected); +}); + +it("uses supplied relative resets without guessing a window duration", () => { + const report = formatCodexUsage( + { + rate_limit: { + primary_window: { + used_percent: 40, + limit_window_seconds: "18000", + reset_after_seconds: 90, + }, + }, + }, + NOW, + )!; + expect(report).toContain("Primary (duration unknown): 40% used · 60% remaining"); + expect(report).toContain("2026-01-10 12:01:30 UTC (in 1m 30s)"); +}); + +it("reports unknown invalid reset times and never resets a measured balance locally", () => { + const report = formatCodexUsage( + { + rate_limit: { + limit_reached: true, + primary_window: { ...primary, used_percent: 100, reset_at: NOW / 1000 - 1 }, + secondary_window: { ...primary, reset_at: Infinity }, + }, + }, + NOW, + )!; + expect(report).toContain("100% used · 0% remaining"); + expect(report).toContain("reset time passed; refresh usage"); + expect(report).toContain("Resets: unknown"); + expect(report).toContain("Limit reached"); +}); + +it.each([ + [{ unlimited: true }, "unlimited"], + [{ unlimited: true, has_credits: false }, "unlimited (unavailable)"], + [{ has_credits: false, balance: "0" }, "0 balance (unavailable)"], + [{ has_credits: true }, "available; balance not reported"], + [{ has_credits: false, balance: "0x10" }, "unavailable; balance not reported"], +] as const)("reports only supplied credit facts: %j", (credits, expected) => { + expect(formatCodexUsage({ credits }, NOW)).toContain(`Credits: ${expected}`); +}); + +it("includes review and additional buckets plus explicit account restrictions", () => { + const report = formatCodexUsage( + { + code_review_rate_limit: { primary_window: { ...primary, limit_window_seconds: 604_800 } }, + additional_rate_limits: [ + { + metered_feature: "codex_other", + limit_name: "Other model pool", + rate_limit: { allowed: false, primary_window: primary }, + }, + ], + spend_control: { reached: true }, + rate_limit_reached_type: { type: "workspace_owner_credits_depleted" }, + }, + NOW, + )!; + expect(report).toContain("Code review\n Primary (7d)"); + expect(report).toContain("Other model pool\n Primary (30m)"); + expect(report).toContain("Requests not currently allowed"); + expect(report).toContain("Workspace spending limit: reached"); + expect(report).toContain("Account limit: workspace owner credits depleted"); +}); + +it("bounds and sanitizes backend labels and additional-bucket output", () => { + const payload = { + plan_type: "plus\u001b[31m\n", + additional_rate_limits: Array.from({ length: 10 }, (_, index) => ({ + limit_name: `Pool ${index}\r\n${"x".repeat(1000)}`, + rate_limit: { primary_window: primary }, + })), + }; + const report = formatCodexUsage(payload, NOW)!; + // oxlint-disable-next-line no-control-regex -- Newlines are layout; other terminal controls must be absent. + expect(report).not.toMatch(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/); + expect(report).not.toContain("x".repeat(65)); + expect(report).toContain("2 additional entries omitted."); + expect(report).not.toContain("Pool 8"); + expect(report.length).toBeLessThan(5000); +}); + +it.each([null, [], {}, { unrelated: 10 }, { rate_limit: "wrong shape" }])( + "rejects unrecognized response shapes instead of fabricating healthy usage: %j", + (payload) => { + expect(formatCodexUsage(payload, NOW)).toBeUndefined(); + }, +); diff --git a/extensions/codex/src/usage.ts b/extensions/codex/src/usage.ts new file mode 100644 index 0000000..0ae2b28 --- /dev/null +++ b/extensions/codex/src/usage.ts @@ -0,0 +1,197 @@ +const MAX_ADDITIONAL_BUCKETS = 8; +const MAX_LABEL_LENGTH = 64; +const NUMBERS = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }); + +type JsonObject = Record; + +interface UsageSections { + plan: string | undefined; + codex: JsonObject | undefined; + includeCodeReview: boolean; + codeReview: JsonObject | undefined; + additional: unknown[] | undefined; + credits: JsonObject | undefined; + spendingLimitReached: boolean; + accountLimit: string | undefined; +} + +/** Account allowance only; this is not Pi's context occupancy or a cost estimate. */ +export function formatCodexUsage(value: unknown, now = Date.now()): string | undefined { + const sections = readUsageSections(value); + if (!sections) return undefined; + + const rows = [ + "codex account usage · openai-codex", + `Plan: ${sections.plan ?? "unknown"}`, + "", + ...bucketRows("Codex", sections.codex, now), + ]; + if (sections.includeCodeReview) { + rows.push("", ...bucketRows("Code review", sections.codeReview, now)); + } + rows.push(...additionalBucketRows(sections.additional, now)); + rows.push("", `Credits: ${creditStatus(sections.credits)}`); + if (sections.spendingLimitReached) rows.push("Workspace spending limit: reached"); + if (sections.accountLimit) + rows.push(`Account limit: ${sections.accountLimit.replaceAll("_", " ")}`); + return rows.join("\n"); +} + +function readUsageSections(value: unknown): UsageSections | undefined { + const payload = readObject(value); + if (!payload) return undefined; + + const plan = sanitizeDisplayLabel(payload.plan_type); + const codex = readObject(payload.rate_limit); + const codeReview = readObject(payload.code_review_rate_limit); + const credits = readObject(payload.credits); + const spendingControl = readObject(payload.spend_control); + const reachedLimit = readObject(payload.rate_limit_reached_type); + const additional = Array.isArray(payload.additional_rate_limits) + ? payload.additional_rate_limits + : undefined; + const hasUsageData = [ + plan, + codex, + codeReview, + credits, + spendingControl, + reachedLimit, + additional, + ].some((section) => section !== undefined); + if (!hasUsageData) return undefined; + + const includeCodeReview = "code_review_rate_limit" in payload; + const spendingLimitReached = spendingControl?.reached === true; + const accountLimit = sanitizeDisplayLabel(reachedLimit?.type); + return { + plan, + codex, + includeCodeReview, + codeReview, + additional, + credits, + spendingLimitReached, + accountLimit, + }; +} + +function bucketRows(name: string, bucket: JsonObject | undefined, now: number): string[] { + if (!bucket) return [name, " Not reported"]; + const rows = [ + name, + ...windowRows("Primary", bucket.primary_window, now), + ...windowRows("Secondary", bucket.secondary_window, now), + ]; + if (bucket.limit_reached === true) rows.push(" Limit reached"); + else if (bucket.allowed === false) rows.push(" Requests not currently allowed"); + return rows; +} + +function windowRows(name: string, value: unknown, now: number): string[] { + const window = readObject(value); + if (!window) return [` ${name}: not reported`]; + + const seconds = finiteNonnegativeNumber(window.limit_window_seconds); + let interval = "duration unknown"; + if (seconds !== undefined && Number.isSafeInteger(seconds) && seconds > 0) + interval = duration(seconds); + const used = finiteNonnegativeNumber(window.used_percent); + let usage = "usage unknown"; + if (used !== undefined && used <= 100) { + usage = `${NUMBERS.format(used)}% used · ${NUMBERS.format(100 - used)}% remaining`; + } + return [` ${name} (${interval}): ${usage}`, ` Resets: ${resetTime(window, now)}`]; +} + +function additionalBucketRows(additional: unknown[] | undefined, now: number): string[] { + if (!additional) return []; + const rows: string[] = []; + for (const [index, value] of additional.slice(0, MAX_ADDITIONAL_BUCKETS).entries()) { + const entry = readObject(value); + const name = + sanitizeDisplayLabel(entry?.limit_name) ?? + sanitizeDisplayLabel(entry?.metered_feature) ?? + `Additional ${index + 1}`; + const quota = readObject(entry?.rate_limit); + rows.push("", ...bucketRows(name, quota, now)); + } + if (additional.length > MAX_ADDITIONAL_BUCKETS) { + rows.push("", `${additional.length - MAX_ADDITIONAL_BUCKETS} additional entries omitted.`); + } + return rows; +} + +function creditStatus(credits: JsonObject | undefined): string { + if (!credits) return "not reported"; + if (credits.unlimited === true) { + return credits.has_credits === false ? "unlimited (unavailable)" : "unlimited"; + } + let availability = "availability unknown"; + if (credits.has_credits === true) availability = "available"; + else if (credits.has_credits === false) availability = "unavailable"; + const balance = parseCreditBalance(credits.balance); + if (balance === undefined) return `${availability}; balance not reported`; + return `${NUMBERS.format(balance)} balance (${availability})`; +} + +function parseCreditBalance(value: unknown): number | undefined { + if (typeof value === "number") return finiteNonnegativeNumber(value); + if (typeof value !== "string") return undefined; + const decimal = value.trim(); + if (!/^\d+(?:\.\d+)?$/.test(decimal)) return undefined; + return finiteNonnegativeNumber(Number(decimal)); +} + +function resetTime(window: JsonObject, now: number): string { + const absolute = finiteNonnegativeNumber(window.reset_at); + const relative = finiteNonnegativeNumber(window.reset_after_seconds); + let timestamp = NaN; + if (absolute !== undefined) timestamp = absolute * 1000; + else if (relative !== undefined) timestamp = now + relative * 1000; + const date = new Date(timestamp); + if (!Number.isFinite(date.getTime())) return "unknown"; + const time = date.toISOString().replace("T", " ").replace(".000Z", " UTC"); + const remaining = Math.ceil((timestamp - now) / 1000); + return remaining > 0 + ? `${time} (in ${duration(remaining)})` + : `${time} (reset time passed; refresh usage)`; +} + +function duration(seconds: number): string { + let remaining = Math.ceil(seconds); + const parts: string[] = []; + for (const [unit, size] of [ + ["d", 86_400], + ["h", 3600], + ["m", 60], + ["s", 1], + ] as const) { + const amount = Math.floor(remaining / size); + if (amount > 0) parts.push(`${amount}${unit}`); + remaining %= size; + } + return parts.join(" ") || "0s"; +} + +function readObject(value: unknown): JsonObject | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as JsonObject) + : undefined; +} + +function sanitizeDisplayLabel(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + return ( + value + // oxlint-disable-next-line no-control-regex -- Strip terminal controls from backend labels. + .replace(/[\x00-\x1f\x7f-\x9f]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_LABEL_LENGTH) || undefined + ); +} + +function finiteNonnegativeNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +}