feat(codex): report account usage #3
No files matched your search
@@ -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<Api> = {
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<CodexAccountRegistry["getProvider"]>(() => provider),
|
||||
getProviderAuth: vi.fn<CodexAccountRegistry["getProviderAuth"]>(async () => auth),
|
||||
isUsingOAuth: vi.fn<CodexAccountRegistry["isUsingOAuth"]>(() => true),
|
||||
};
|
||||
const fetcher = vi.fn<typeof fetch>(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<AuthResult>();
|
||||
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<Uint8Array>({ 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<Uint8Array>({
|
||||
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("<html>private server details</html>"));
|
||||
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<void>();
|
||||
const incoming = deferred<Response>();
|
||||
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<AuthResult>();
|
||||
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<void>();
|
||||
const response = deferred<Response>();
|
||||
const discarded = deferred<void>();
|
||||
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<Uint8Array>({ 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);
|
||||
});
|
||||
@@ -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<Provider, "baseUrl" | "getModels"> | 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<unknown> {
|
||||
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<Headers> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<Uint8Array> {
|
||||
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<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
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<never>((_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);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof import("./client.ts")>()),
|
||||
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<ExtensionAPI["registerCommand"]>[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(),
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((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<typeof fetchCodexUsage>(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<unknown>();
|
||||
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<unknown>();
|
||||
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<unknown>();
|
||||
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<unknown>();
|
||||
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");
|
||||
});
|
||||
@@ -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<ExtensionContext["sessionManager"], "getSessionId">;
|
||||
readonly ui: Pick<ExtensionContext["ui"], "notify">;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof fetch>(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<CredentialStore["modify"]>(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<Api>) {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user