diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 89a679a77e..7907207f73 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -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 ` Start the provider's registered login flow. OAuth providers open a browser and store auto-refreshed diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index b21f72b85d..76c3456c9c 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -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"; @@ -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. * @@ -237,6 +239,7 @@ interface CodexAccountDto { needsReauth?: boolean; priority?: number; quota?: CodexQuotaDto | null; + quotaRefresh?: unknown; paused?: boolean; } @@ -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 }; } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 7afab2fc6a..02ab47f1e3 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -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"; @@ -774,6 +775,8 @@ async function readMainAuthErrorCode(resp: Response): Promise { 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. */ @@ -789,12 +792,14 @@ interface MainAccountInfoFetchResult { export interface MainAccountInfoSnapshot { info: MainAccountInfo; mainIdentityGeneration: number; + quotaRefresh?: CodexQuotaRefreshOutcome; } export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise { const result = await fetchMainAccountInfoAttempt(forceRefresh, 1); return { info: result.info, + ...(result.quotaRefresh ? { quotaRefresh: result.quotaRefresh } : {}), mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(), }; } @@ -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()); @@ -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. @@ -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 }, + }; } } @@ -1051,6 +1075,7 @@ export interface CodexAuthAccountDto { healthSummary: string; healthAction?: string; quotaProbeSkipped?: true; + quotaRefresh?: CodexQuotaRefreshOutcome; mainAccountHardLock?: MainAccountHardLockStatus; } @@ -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), diff --git a/src/codex/quota-refresh-outcome.ts b/src/codex/quota-refresh-outcome.ts new file mode 100644 index 0000000000..fb059ef69d --- /dev/null +++ b/src/codex/quota-refresh-outcome.ts @@ -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; + 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; + } +} diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 678ee07645..cc2ef6ed52 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -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 diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 58146c6383..9966ca26b4 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -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"]); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 1cba651ee5..3dc8130098 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -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, @@ -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;