Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs-site/src/content/docs/reference/cli/providers-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ Use `--api-key` or an OAuth login for anything secret.

## Authentication

### Diagnosing missing main-account quota

`ocx account list openai --quota --refresh --json` includes a `quotaRefresh` object on
the main-account row when that operation attempts a WHAM usage read. The existing
`GET /api/codex-auth/accounts?refresh=1` response exposes the same diagnostic.

Its `status` is `ok`, `not_reported` (no parseable quota in a successful response),
`http_error`, `timeout`, `network_error`, `invalid_response`, or `internal_error`.
Only `http_error` includes a numeric `httpStatus`. No raw response, error message,
credential, or account identifier is included in this object. Cache-only reads,
credential deferrals, and invalidated account snapshots omit it; older servers
also omit it. Absence is not proof of success.

A valid login does not guarantee that this separate usage request succeeds.
These categories do not change authentication, account selection, or quota
freshness rules, and do not turn unknown quota into zero usage. This diagnostic
currently covers the native main account, not pool-account refreshes. When
reporting missing quota, share the category and HTTP status rather than credential
files or a raw network capture.

### `ocx login <provider>`

Start the provider's registered login flow. OAuth providers open a browser and store auto-refreshed
Expand Down
8 changes: 7 additions & 1 deletion src/cli/account-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { runningProxyUpdateHeaders } from "../oauth/login-cli";
import { isPublicOAuthProvider } from "../oauth/index";
import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry";
import type { OcxConfig } from "../types";
import { projectCodexQuotaRefreshOutcome, type CodexQuotaRefreshOutcome } from "../codex/quota-refresh-outcome";

export type AccountType = "codex" | "oauth" | "api-key";

Expand All @@ -24,6 +25,7 @@ export interface AccountRow {
/** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
priority?: number;
quota?: CodexQuotaDto | null;
quotaRefresh?: CodexQuotaRefreshOutcome;
/**
* Whether the pool is holding this account out of rotation.
*
Expand Down Expand Up @@ -237,6 +239,7 @@ interface CodexAccountDto {
needsReauth?: boolean;
priority?: number;
quota?: CodexQuotaDto | null;
quotaRefresh?: unknown;
paused?: boolean;
}

Expand Down Expand Up @@ -299,7 +302,10 @@ export async function fetchCodexRows(
needsReauth: a.needsReauth,
priority: typeof a.priority === "number" ? a.priority : 0,
paused: a.paused === true,
...(includeQuota ? { quota: projectQuota(a.quota) } : {}),
...(includeQuota ? {
quota: projectQuota(a.quota),
quotaRefresh: projectCodexQuotaRefreshOutcome(a.quotaRefresh),
} : {}),
}));
return { rows, activeId, autoSwitchThreshold, status: 200 };
}
Expand Down
34 changes: 30 additions & 4 deletions src/codex/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import {
type MainAccountInfo,
} from "./main-account-cache";
export { clearMainAccountInfoCache } from "./main-account-cache";
import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome";
import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock";
import { maskEmail } from "../lib/privacy";
import { codexWarmupFailureReason, warmCodexAccount } from "./warmup";
Expand Down Expand Up @@ -774,6 +775,8 @@ async function readMainAuthErrorCode(resp: Response): Promise<unknown> {

interface MainAccountInfoFetchResult {
info: MainAccountInfo;
/** Ephemeral result of this attempt, omitted when no WHAM request was made. */
quotaRefresh?: CodexQuotaRefreshOutcome;
/** Whether this attempt safely inspected the physical native-main credential. */
credentialChecked: boolean;
/** Meaningful only when credentialChecked is true. */
Expand All @@ -789,12 +792,14 @@ interface MainAccountInfoFetchResult {
export interface MainAccountInfoSnapshot {
info: MainAccountInfo;
mainIdentityGeneration: number;
quotaRefresh?: CodexQuotaRefreshOutcome;
}

export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise<MainAccountInfoSnapshot> {
const result = await fetchMainAccountInfoAttempt(forceRefresh, 1);
return {
info: result.info,
...(result.quotaRefresh ? { quotaRefresh: result.quotaRefresh } : {}),
mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(),
};
}
Expand Down Expand Up @@ -896,10 +901,13 @@ async function fetchMainAccountInfoWhileOwned(
const mainQuotaWriter = requestAccountId === tokens.account_id
? observeMainQuotaCredential(tokens.access_token, tokens.account_id)
: undefined;
// Keep diagnostics separate from authentication and freshness policy. Never serialize errors.
const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS);
let quotaPhase: "request" | "body" | "decode" | "publish" = "request";
try {
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
signal: quotaSignal,
});
if (!resp.ok) {
const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
Expand All @@ -909,15 +917,21 @@ async function fetchMainAccountInfoWhileOwned(
clearMainAccountInfoCache();
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
}
return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
return {
info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true,
quotaRefresh: { status: "http_error", httpStatus: resp.status },
};
}
quotaPhase = "body";
const data = (await resp.json()) as WhamUsageResponse;
quotaPhase = "decode";
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
if (retried) return retried;
const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
const usage = { ...data, ...(plan ? { plan_type: plan } : {}) };
const quota = parseUsageQuota(usage);
const policyQuota = parseMainPolicyUsageQuota(usage);
quotaPhase = "publish";
const freshResetCredits = quota?.resetCredits;
// Tag the count with the identity it was read from, so a later response that omits the
// summary can restore the badge without ever crossing an account boundary.
Expand Down Expand Up @@ -947,14 +961,24 @@ async function fetchMainAccountInfoWhileOwned(
}
return {
info: result,
quotaRefresh: { status: quota ? "ok" : "not_reported" },
credentialChecked: true,
hasCredential: true,
...(quota ? { freshQuota: quota } : {}),
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
};
} catch {
} catch (error) {
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
if (retried) return retried;
let status: CodexQuotaRefreshOutcome["status"] = "internal_error";
if (quotaSignal.aborted) status = "timeout";
else if (quotaPhase === "request") status = "network_error";
else if (quotaPhase === "body") status = error instanceof SyntaxError ? "invalid_response" : "network_error";
else if (quotaPhase === "decode") status = "invalid_response";
return {
info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true,
quotaRefresh: { status },
};
}
}

Expand Down Expand Up @@ -1051,6 +1075,7 @@ export interface CodexAuthAccountDto {
healthSummary: string;
healthAction?: string;
quotaProbeSkipped?: true;
quotaRefresh?: CodexQuotaRefreshOutcome;
mainAccountHardLock?: MainAccountHardLockStatus;
}

Expand Down Expand Up @@ -1760,6 +1785,7 @@ export async function listCodexAuthAccountsSnapshot(
id: MAIN_CODEX_ACCOUNT_ID,
email: maskEmail(mainInfo.email) ?? "Codex App login",
plan: mainInfo.plan,
...(mainSnapshotLive && mainResult.quotaRefresh ? { quotaRefresh: mainResult.quotaRefresh } : {}),
logLabel: "main",
isMain: true,
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
Expand Down
27 changes: 27 additions & 0 deletions src/codex/quota-refresh-outcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** Diagnostic only: never use this outcome as quota, entitlement, or admission evidence. */
export type CodexQuotaRefreshOutcome =
| { status: "http_error"; httpStatus: number }
| { status: "ok" | "not_reported" | "timeout" | "network_error" | "invalid_response" | "internal_error" };

/** The management response is untrusted at the CLI boundary; copy only the fixed vocabulary. */
export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefreshOutcome | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
const row = value as Record<string, unknown>;
if (row.status === "http_error") {
return typeof row.httpStatus === "number" && Number.isInteger(row.httpStatus)
&& row.httpStatus >= 100 && row.httpStatus <= 599
? { status: "http_error", httpStatus: row.httpStatus }
: undefined;
}
switch (row.status) {
case "ok":
case "not_reported":
case "timeout":
case "network_error":
case "invalid_response":
case "internal_error":
return { status: row.status };
default:
return undefined;
}
}
14 changes: 14 additions & 0 deletions structure/05_gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,20 @@ keeps the saved state and renders fixed `ocx sync` guidance without server/accou

## Usage accounting

Main-account WHAM refresh diagnostics are an ephemeral `quotaRefresh` outcome carried
from `fetchMainAccountInfoWhileOwned` to the generation-checked account DTO and the
opt-in CLI quota JSON. They are not persisted or consumed by admission/rotation.
The CLI reconstructs the object using a fixed vocabulary and bounded numeric HTTP
status, so an unexpected management response cannot add raw upstream material.

[Decision Log]
- 목적과 의도: Explain missing main-account quota without confusing a working login with a successful WHAM read.
- 기존 구현 및 제약 조건: HTTP failures and body/transport exceptions returned identical null metadata; existing authentication and freshness policy must remain unchanged.
- 검토한 주요 대안: Copy raw errors, infer plan/quota, reuse stale evidence, or add a bounded diagnostic outcome.
- 선택한 방식: Carry a non-persisted fixed category and optional numeric HTTP status through the existing management and CLI read paths.
- 다른 대안 대신 이 방식을 선택한 이유: It gives reporters actionable evidence without disclosing payloads, changing permissions, or introducing another cache.
- 장점, 단점 및 영향: Main-account failures become distinguishable; root-cause repair and pool diagnostics remain separate work, and clients must tolerate an absent field.

`src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600`.
An opt-in shadow-call rewrite persists the bounded, redacted original helper model as
`shadowCallRewrittenFrom`, so helper traffic remains identifiable after restart without storing
Expand Down
24 changes: 24 additions & 0 deletions tests/cli/cli-account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,30 @@ afterEach(() => {
});

describe("ocx account CLI (issue #180 matrix)", () => {
test("main quota diagnostics survive opt-in JSON without copying upstream data", async () => {
codexAccounts = [{ id: "__main__", isMain: true, quota: null,
quotaRefresh: { status: "http_error", httpStatus: 503, message: RAW_SENTINEL } }];
const result = await run(["list", "openai", "--quota", "--refresh", "--json"]);
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout).accounts[0].quotaRefresh).toEqual({ status: "http_error", httpStatus: 503 });
expect(result.output).not.toContain(RAW_SENTINEL);
const ordinary = await run(["list", "openai", "--json"]);
expect(JSON.parse(ordinary.stdout).accounts[0]).not.toHaveProperty("quotaRefresh");
});

test.each([
{ status: "private-status-canary" },
{ status: "http_error", httpStatus: "503" },
{ status: "http_error", httpStatus: 999 },
{ status: "http_error", httpStatus: 503.5 },
null,
])("invalid quota diagnostic is omitted: %j", async quotaRefresh => {
codexAccounts = [{ id: "__main__", isMain: true, quota: null, quotaRefresh }];
const result = await run(["list", "openai", "--quota", "--json"]);
expect(JSON.parse(result.stdout).accounts[0]).not.toHaveProperty("quotaRefresh");
expect(result.output).not.toContain("canary");
});

test("1: list renders all three account families, main alias, and padded columns", async () => {
const result = await run(["list"]);

Expand Down
57 changes: 56 additions & 1 deletion tests/codex-integration/codex-auth-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
handleCodexAuthAPI, updateAccountQuota, getAccountQuota,
checkAccountIdCollision, getMainChatgptAccountId,
markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota,
clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo,
clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo, fetchMainAccountInfoSnapshot,
clearCodexQuotaPrimeState, primeCodexPoolQuotas, seedCodexAuthAdmissionForTests,
type CodexAuthAccountDto,
listCodexAuthAccounts,
Expand Down Expand Up @@ -255,6 +255,61 @@ function seedPoolAccount(
});
}

describe("main quota refresh diagnostics", () => {
function writeMain(): void {
writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({
tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), account_id: "fixture-account" },
}));
}

test.each([401, 403, 429, 503])("HTTP %s is diagnostic, not proof of sign-out", async status => {
writeMain();
globalThis.fetch = (async () => new Response("private-upstream-canary", { status })) as typeof fetch;
const main = (await listCodexAuthAccounts(makeConfig(), true)).find(row => row.isMain);
expect(main).toMatchObject({ quotaRefresh: { status: "http_error", httpStatus: status },
plan: null, quota: null, hasCredential: true, needsReauth: false });
expect(JSON.stringify(main)).not.toContain("private-upstream-canary");
});

test.each(["network_error", "invalid_response", "not_reported", "body_reset"] as const)("classifies %s without serializing errors", async kind => {
writeMain();
globalThis.fetch = (async () => {
if (kind === "network_error") throw new TypeError("private-network-canary");
if (kind === "body_reset") return new Response(new ReadableStream({
start(controller) { controller.error(new TypeError("private-stream-canary")); },
}));
return kind === "invalid_response" ? new Response("private-json-canary") : Response.json({});
}) as typeof fetch;
const result = await fetchMainAccountInfoSnapshot(true);
expect(result.quotaRefresh).toEqual({ status: kind === "body_reset" ? "network_error" : kind });
expect(result.info.quota).toBeNull();
expect(JSON.stringify(result)).not.toContain("canary");
});

test("timeout reports only the fixed category", async () => {
writeMain();
const signal = AbortSignal.abort(new DOMException("private-timeout-canary", "TimeoutError"));
const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(signal);
globalThis.fetch = (async () => { throw signal.reason; }) as typeof fetch;
try {
expect((await fetchMainAccountInfoSnapshot(true)).quotaRefresh).toEqual({ status: "timeout" });
} finally { timeout.mockRestore(); }
});

test("fresh success reports ok while cache reuse never claims another probe", async () => {
writeMain();
let reads = 0;
globalThis.fetch = (async () => { reads++; return Response.json({ plan_type: "plus",
rate_limit: { primary_window: { used_percent: 37 } } }); }) as typeof fetch;
const fresh = await fetchMainAccountInfoSnapshot(true);
expect(fresh.quotaRefresh).toEqual({ status: "ok" });
const cached = await fetchMainAccountInfoSnapshot(false);
expect(cached.quotaRefresh).toBeUndefined();
expect(cached.info.quota).toEqual(fresh.info.quota);
expect(reads).toBe(1);
});
});

beforeEach(() => {
resetLifecycleDrainStateForTests();
previousOpencodexHome = process.env.OPENCODEX_HOME;
Expand Down
Loading