From a3dae346ee47d2e178f2526c4290279bb454b207 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:41:29 +0900 Subject: [PATCH 1/3] docs: refine fenced quota history contracts for the follow-up --- devlog/_plan/260912_accounts/050_history.md | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/devlog/_plan/260912_accounts/050_history.md b/devlog/_plan/260912_accounts/050_history.md index dd96c9019e..651a7a1c35 100644 --- a/devlog/_plan/260912_accounts/050_history.md +++ b/devlog/_plan/260912_accounts/050_history.md @@ -23,3 +23,25 @@ Reflection REF-04: fixed aggregate bounds: 64 account identities, 4096 rows, 2 M A1 accepted: native main history is deliberately NOT hydrated from disk in this slice. It can be sampled in-process only after identity observation and cleared on identity change; persistence omits __main__. Pool history envelopes bind a stable private publication UUID; hydration prunes identity mismatches, while ordinary generation changes on refresh retain prior observations. This avoids attributing offline identity replacements to an old main label. Acceptance explicitly covers main replacement while stopped and account-id reuse. Main cross-restart history remains a documented limitation; bounded durable history is provided for stored pool accounts. P refinement depends on new048 history-identity cycle. Adopt HIST-01..06: generation gates each physical sample; private random publication UUID persists through refresh and changes on explicit save. Capture PoolQuotaWriter before upstream calls, refresh it after replay token resolution, and forward through every WHAM/WS/HTTP/compact/warmup path. Omit staged login/reauth samples until first post-publication fenced observation; do not retrofit ambient provenance. Native main is excluded from durable endpoint/capacity in this slice. Raw QuotaObservation carries observedAt, wham|response-header source, bounded windows with account|spark family and short|weekly|monthly name, percentage/resetAtMs/duration/primary provenance; no arbitrary upstream label. Envelope private identity binds samples but is omitted from read DTO. Retain best-effort single-writer atomic cache semantics; no multi-process merge/durability claim. Read endpoint GET /api/codex-auth/quota/history?accountId=&limit=<1..200>; CLI ocx account history openai [--limit N] [--json]. Unknown/deleted404, invalid/duplicate selector400, emptyhistory200. No upstream call on reads. + +## Executable history child contract after identity foundation D + +Parent PR4375/e9007429c5 provides PoolQuotaWriter and store capture/live/retention helpers. This child depends on that branch; the capacity child follows this one. Previous D delivered only identity and deferred hosted proof. + +NEW src/codex/quota-history.ts, pure leaf (imports quota types and pure account-id only): closed HistoryWindow family account|spark, window short|weekly|monthly, usedPercent, optional resetAtMs/windowSeconds/monthlyIsPrimaryWindow; HistorySample observedAt/source/credentialGeneration/windows; private envelope identity/samples. CodexQuotaHistory owns append/hydrate/read/clear/reconcile/serialize. Keep 200 samples/account,30days,64accounts,4096samples,2MiB conservative serialized-byte budget; max5 windows/sample. Track per-sample byte costs incrementally, evict by observedAt then accountId and insertion order. Hydration admits only bounded validated rows (at most64 sorted account keys and last200 rows per account), then global bounds. Unknown fields/labels never survive. Read returns deep copies; private identity never reaches API. No filesystem/config/store import in the leaf. + +MODIFY quota.ts: own the history instance and optional history:{version:1,accounts:{...}} in existing quota-cache version1. Hydrate history before latest-quota six-hour TTL filtering; native-main never hydrates/records in this durable layer. Replace unbounded file allocation with a local fd/readSync loop capped at4MiB+1; oversized/corrupt cache is a cache miss, never an inference failure. Keep latest in-memory state untouched. Existing debounced atomic persistence serializes bounded history, so no new timer/store and no multi-process merge claim. Clear and roster reconcile remove history-only identities too; read compares current store UUID before returning, even after offline replacement. + +setAccountQuotaFromParsed gains optional sixth QuotaObservationEvidence {writer,observedAt,source,raw}. After config/main write guards, append only when writer.accountId matches and isPoolQuotaWriterLive. Convert only fresh raw percentages into closed history windows, normalizing resets with resetAtToMs. Account short/weekly/monthly map directly; Spark uses existing short label plus a new canonical weekly-label constant shared with the WHAM parser. No arbitrary custom labels. Credits-only/metadata-only updates append nothing. The legacy latest-snapshot merge remains unchanged. applyAccountQuotaFromUpstreamHeaders options adds poolWriter; builds evidence from original parse result BEFORE custom-window carry. Missing writer/evidence preserves latest cache but appends no trusted sample. + +MODIFY auth-context.ts pool union with poolQuotaWriter?:PoolQuotaWriter, capture immediately after getValidCodexToken before dispatch. MODIFY core.ts WS closure, rejected-first response, ordinary HTTP, and refreshedAuthCtx to forward/re-capture exact serving writer; compact.ts refresh/rejection follows same rule. MODIFY quota-auto-refresh.ts pool warmup captures before I/O. MODIFY auth-api.ts WHAM initial and refreshed replay capture before fetch, commitPoolQuotaResponse carries writer and sends raw parsed result with observedAt after JSON read; keep all current generation/mayPublish checks. Staged login quota writes intentionally omit history until a post-publication observation; native main and legacy updateAccountQuota omit it. No token material is added to response objects/logs. + +GET /api/codex-auth/quota/history?accountId=&limit=<1..200> is read-only cached data, no upstream/auth refresh/warmup. Add before existing /quota handler; registry entry+capability map. Validate exactly one accountId, optional single numeric limit and no unknown query fields. Invalid/main400, unknown configured pool404, known account200 even empty. DTO: {accountId,observations:[{observedAt,source,windows}],retention:{maxObservations:200,maxAgeDays:30},truncated:boolean}; omit UUID and credential generation. Public array follows ascending observed time, limit chooses newest rows. Capacity is added only in next child. + +NEW src/cli/account-history.ts exports cmdAccountHistory(args,deps). Shape `ocx account history openai [--limit N] [--json]`; reject other provider/main/extraargs before any network. Use resolveBaseUrl/apiJson/apiError/proxyUnreachable from account-api owner. JSON prints DTO; human output prints observed time/source/window/percent/reset and no-observation state. Wire lazy dispatcher and help/capabilities; source-only skill surface generator allowed (not product suite). + +Tests: new pure codex-quota-history.test.ts (register both layout maps), existing quota-store integration hydration harness for raw-vs-carried, writer mismatch/refresh/replacement, stage omission, native omission, clear/reconcile and disk limits; authenticated server route tests+CLI transport fixture. No local runtime execution. All touched source-area ownership docs and English+Korean account command docs synchronized; other translations must not contradict additions. + +Read unavailability refinement: undefined current identity (legacy/missing/unreadable) returns empty/unavailable evidence without deleting a retained envelope. Only a confirmed different UUID or authoritative roster removal clears it; this avoids transient permission/read errors destroying history. Restored matching identity may expose retained valid rows again. Cache eviction/expiry remains bounded. + +Deferred history-plan review findings (actual A entry was refused because persisted active work phase is tun): reuse pre-clamp invalid-percentage checking for all WHAM primary/secondary/tertiary and additional Spark windows, and response-header raw usage fields; any invalid numeric/nonfinite/out-of-range percentage omits the ENTIRE trusted observation while leaving legacy display behavior unchanged. Add before-clamp history parser/evidence guard so clamped values cannot masquerade as measured percentages. Hydration rejects an entire over-limit history payload (>64accounts,>200rows/account,>4096rows,>2MiB) instead of slicing by lexical key/array position; bounded accepted rows are sorted by observedAt before retention. Tests include65th-newestaccount and unordered rows. These need fresh independent A review when history resumes. From e2d213b3f13d6fc956efe014f5d7bc4341d7e09c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:36:12 +0900 Subject: [PATCH 2/3] feat(codex): retain bounded credential-fenced quota observations --- devlog/_plan/260912_accounts/050_history.md | 4 +- .../260912_accounts/051_history_delivery.md | 7 + .../ko/reference/cli/providers-accounts.md | 6 + .../docs/reference/cli/providers-accounts.md | 6 + scripts/test-layout/layout.json | 1 + .../ocx/references/01_management_surface.md | 19 ++- src/cli/account-history.ts | 39 +++++ src/cli/account.ts | 5 + src/cli/capabilities.ts | 12 ++ src/codex/auth-api.ts | 27 ++- src/codex/auth-context.ts | 4 + src/codex/quota-auto-refresh.ts | 5 +- src/codex/quota-history.ts | 160 ++++++++++++++++++ src/codex/quota.ts | 101 ++++++++++- src/server/management/route-registry.ts | 1 + src/server/responses/compact.ts | 10 +- src/server/responses/core.ts | 8 +- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/codex-home.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/openai-tiers.md | 8 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + tests/cli/cli-account.test.ts | 16 ++ .../codex-quota-history.test.ts | 81 +++++++++ .../main-quota-provenance.test.ts | 50 ++++++ tests/fixtures/test-layout-expected.json | 1 + .../responses-compaction-routing.test.ts | 25 +++ .../account-pool-management-api.test.ts | 24 +++ 41 files changed, 635 insertions(+), 19 deletions(-) create mode 100644 devlog/_plan/260912_accounts/051_history_delivery.md create mode 100644 src/cli/account-history.ts create mode 100644 src/codex/quota-history.ts create mode 100644 tests/codex-integration/codex-quota-history.test.ts diff --git a/devlog/_plan/260912_accounts/050_history.md b/devlog/_plan/260912_accounts/050_history.md index 651a7a1c35..1e870236fc 100644 --- a/devlog/_plan/260912_accounts/050_history.md +++ b/devlog/_plan/260912_accounts/050_history.md @@ -28,7 +28,7 @@ P refinement depends on new048 history-identity cycle. Adopt HIST-01..06: genera Parent PR4375/e9007429c5 provides PoolQuotaWriter and store capture/live/retention helpers. This child depends on that branch; the capacity child follows this one. Previous D delivered only identity and deferred hosted proof. -NEW src/codex/quota-history.ts, pure leaf (imports quota types and pure account-id only): closed HistoryWindow family account|spark, window short|weekly|monthly, usedPercent, optional resetAtMs/windowSeconds/monthlyIsPrimaryWindow; HistorySample observedAt/source/credentialGeneration/windows; private envelope identity/samples. CodexQuotaHistory owns append/hydrate/read/clear/reconcile/serialize. Keep 200 samples/account,30days,64accounts,4096samples,2MiB conservative serialized-byte budget; max5 windows/sample. Track per-sample byte costs incrementally, evict by observedAt then accountId and insertion order. Hydration admits only bounded validated rows (at most64 sorted account keys and last200 rows per account), then global bounds. Unknown fields/labels never survive. Read returns deep copies; private identity never reaches API. No filesystem/config/store import in the leaf. +NEW src/codex/quota-history.ts, pure leaf (imports quota types and pure account-id only): closed HistoryWindow family account|spark, window short|weekly|monthly, usedPercent, optional resetAtMs/windowSeconds/monthlyIsPrimaryWindow; HistorySample observedAt/source/credentialGeneration/windows; private envelope identity/samples. CodexQuotaHistory owns append/hydrate/read/clear/reconcile/serialize. Keep 200 samples/account,30days,64accounts,4096samples,2MiB conservative serialized-byte budget; max5 windows/sample. Track per-sample byte costs incrementally, evict by observedAt then accountId and insertion order. Hydration rejects an over-limit envelope before admitting rows (>64 accounts, >200 rows/account, >4096 total samples or >2MiB serialized payload); accepted rows are validated and sorted by timestamp before age retention. Unknown fields/labels never survive. Read returns deep copies; private identity never reaches API. No filesystem/config/store import in the leaf. MODIFY quota.ts: own the history instance and optional history:{version:1,accounts:{...}} in existing quota-cache version1. Hydrate history before latest-quota six-hour TTL filtering; native-main never hydrates/records in this durable layer. Replace unbounded file allocation with a local fd/readSync loop capped at4MiB+1; oversized/corrupt cache is a cache miss, never an inference failure. Keep latest in-memory state untouched. Existing debounced atomic persistence serializes bounded history, so no new timer/store and no multi-process merge claim. Clear and roster reconcile remove history-only identities too; read compares current store UUID before returning, even after offline replacement. @@ -45,3 +45,5 @@ Tests: new pure codex-quota-history.test.ts (register both layout maps), existin Read unavailability refinement: undefined current identity (legacy/missing/unreadable) returns empty/unavailable evidence without deleting a retained envelope. Only a confirmed different UUID or authoritative roster removal clears it; this avoids transient permission/read errors destroying history. Restored matching identity may expose retained valid rows again. Cache eviction/expiry remains bounded. Deferred history-plan review findings (actual A entry was refused because persisted active work phase is tun): reuse pre-clamp invalid-percentage checking for all WHAM primary/secondary/tertiary and additional Spark windows, and response-header raw usage fields; any invalid numeric/nonfinite/out-of-range percentage omits the ENTIRE trusted observation while leaving legacy display behavior unchanged. Add before-clamp history parser/evidence guard so clamped values cannot masquerade as measured percentages. Hydration rejects an entire over-limit history payload (>64accounts,>200rows/account,>4096rows,>2MiB) instead of slicing by lexical key/array position; bounded accepted rows are sorted by observedAt before retention. Tests include65th-newestaccount and unordered rows. These need fresh independent A review when history resumes. + +Implementation review HIST-IMPL-01 accepted: compact final response now records actualoutcomeCtx poolwriter beforebuffering, coveringordinary/401replay/alternate; rejectedfirstaccount retains its separateexistingwrite, so everyresponse contributesonce. Add compactregression withquotaheaders onoriginalsuccess andA429→Bsuccess. This sourcework is user-authorized whilehostgoal remainsblocked; no FSM A/B/C/D advancement claimed. diff --git a/devlog/_plan/260912_accounts/051_history_delivery.md b/devlog/_plan/260912_accounts/051_history_delivery.md new file mode 100644 index 0000000000..3ae2698d79 --- /dev/null +++ b/devlog/_plan/260912_accounts/051_history_delivery.md @@ -0,0 +1,7 @@ +# Bounded raw quota history implementation + +Extends publication identity foundation #4375 with a pure bounded history leaf, existing quota-cache persistence, fenced WHAM/HTTP/WS/compact/warmup producers, a management read route and account history CLI. Invalid upstream percentages never become trusted samples after display clamping. Native-main, staged-login and legacy unproven setters are omitted. + +Regression sources cover chronological retention, limits/corrupt disk, private-field stripping, generation/identity changes, raw-versus-carried windows, cached API auth/validation, CLI argument rejection and compact serving-account attribution. Independent source review identified missing compact final-response capture; it was added with ordinary/alternate regressions. CLI skill surface regenerated by its source-only generator, not a product build or suite. Local suites/build/typecheck/install NOT RUN. + +This child targets the existing history-identity branch at19cbe826d8. The pending plan-only commit was rebased onto the parent-updated branch; foundation product bytes were unchanged. Host goal remains blocked; actual FSMB(tun) remains untouched under explicit user instruction. These are authorized source implementation and independent reviews, not a claimed new persisted PABCD cycle. Complete hosted verification belongs to the eventual cumulative history/capacity tip; no merge or issue closure. diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 0fc4bc2608..4f832d3dbb 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -364,3 +364,9 @@ ocx models remove deepseek/deepseek-v4 --yes 슬래시가 있는 모델 선택기는 라우팅됩니다(`anthropic/claude-opus-5`). 슬래시가 없는 id는 native OpenAI 모델로 취급되므로, 라우팅된 것처럼 보일 수 있는 id에 대해 그 읽기를 강제하려면 `--native`가 필요합니다. `--modalities`는 `text`, `image`, `audio`만 허용합니다. Codex는 이 필드를 닫힌 enum으로 해석하고 다른 값이 하나라도 있으면 카탈로그 전체를 거부하므로, `add`, `edit`, 관리 API는 나중에 카탈로그 작성기가 정리해야 할 값을 저장하지 않도록 잘못된 값을 바로 거부합니다(#759). + +### 저장된 쿼터 기록 + +`ocx account history openai [--limit 1-200] [--json]`은 제공자에게 요청하지 않고 저장된 관측을 읽습니다. 관측 시각, WHAM·응답 헤더 출처, 한도 종류와 사용률을 구분해 표시합니다. 계정마다 최대 200개를 30일간 보관하며 전체 저장량에도 제한이 있습니다. + +일반 토큰 갱신은 기록을 유지합니다. 재로그인·삭제·계정 교체는 이전 기록과 분리합니다. 네이티브 메인 계정과 로그인 저장 전 조회는 포함하지 않습니다. 기록이 없다는 것은 관측 부족이며 사용량 0을 뜻하지 않습니다. 이 명령은 토큰 용량을 추정하거나 쿼터를 소비하지 않습니다. 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 f7e94a1833..f39c1f3a57 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -581,3 +581,9 @@ otherwise look routed. and rejects an entire catalog containing any other value, so `add`, `edit`, and the management API all refuse the bad value rather than storing something the catalog writer would have to strip later (#759). + +### Cached quota history + +`ocx account history openai [--limit 1-200] [--json]` reads stored observations without contacting the provider. The output separates actual observation time, WHAM or response-header source, window family and usage percentage. At most 200 observations per account are retained for 30 days, with global storage bounds. + +Ordinary token refresh preserves history. Reauthentication, removal or account replacement retires the old publication. Native main and probes performed before a login is published are not included. Missing history means insufficient observations, not zero usage. This command does not estimate token capacity or spend quota. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8f40c0714d..3691347aa2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -479,6 +479,7 @@ "codex-prompt-text-probe.test.ts": "codex-integration", "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-quota-history.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", "codex-quota-rejection.test.ts": "codex-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index f9fec70034..4cf010e1fc 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -104,6 +104,23 @@ Recently detected quota resets and whether reset notifications are enabled. JSON mode: `payload`. +### `ocx account history` + +Cached quota observations for one stored Codex pool account. + +| Method | Route | +|---|---| +| GET | `/api/codex-auth/quota/history` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the bounded observation history. | +| `--limit` | number | Return the newest 1 to 200 observations. | + +JSON mode: `payload`. + +- Use account history openai . Reads cached observations only; no refresh or warmup. Native main is not included. + ### `ocx account list` Codex OAuth accounts with pool priority and pause state. @@ -769,6 +786,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 41 +- declared capabilities: 42 - of those, state-changing: 20 - head-resolved invocations: 2 diff --git a/src/cli/account-history.ts b/src/cli/account-history.ts new file mode 100644 index 0000000000..fb813cbdbf --- /dev/null +++ b/src/cli/account-history.ts @@ -0,0 +1,39 @@ +import { isValidCodexAccountId } from "../codex/account-id"; +import { apiError, apiJson, proxyUnreachable, resolveBaseUrl, type AccountDeps } from "./account-api"; + +/** Read cached pool observations without refreshing credentials or spending quota. */ +export async function cmdAccountHistory(args: string[], deps: AccountDeps): Promise { + const [provider, accountId, ...flags] = args; + let json = false; + let limit = 200; + let hasLimit = false; + let valid = provider === "openai" && isValidCodexAccountId(accountId); + for (let index = 0; index < flags.length; index++) { + if (flags[index] === "--json" && !json) json = true; + else if (flags[index] === "--limit" && !hasLimit && /^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(flags[index + 1] ?? "")) { + limit = Number(flags[++index]); hasLimit = true; + } else valid = false; + } + if (!valid) { + console.error("Usage: ocx account history openai [--limit <1-200>] [--json]"); + return 1; + } + const baseUrl = await resolveBaseUrl(deps); + if (!baseUrl) return proxyUnreachable(); + const result = await apiJson(deps, baseUrl, "GET", `/api/codex-auth/quota/history?accountId=${encodeURIComponent(accountId)}&limit=${limit}`); + if (result.status === 0) return proxyUnreachable(result.transportError); + if (result.status !== 200) return apiError(result.json, "Quota history unavailable", result.status); + if (json) { console.log(JSON.stringify(result.json, null, 2)); return 0; } + const observations = result.json.observations; + if (!Array.isArray(observations)) return apiError({}, "Invalid quota history response", 502); + console.log("OBSERVED\tSOURCE\tWINDOW\tUSED\tRESET"); + if (!observations.length) console.log("No quota observations for this credential publication."); + for (const observation of observations) { + if (!observation || typeof observation !== "object" || !Array.isArray(observation.windows) + || !Number.isFinite(observation.observedAt)) return apiError({}, "Invalid quota history response", 502); + for (const window of observation.windows) { + console.log(`${new Date(observation.observedAt).toISOString()}\t${observation.source}\t${window.family}/${window.window}\t${window.usedPercent}%\t${typeof window.resetAtMs === "number" ? new Date(window.resetAtMs).toISOString() : "unknown"}`); + } + } + return 0; +} diff --git a/src/cli/account.ts b/src/cli/account.ts index 4a8c6a0427..5383707aba 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -41,6 +41,7 @@ const REPLACEMENT_STYLE_OAUTH = new Set(); const ACCOUNT_USAGE = `Usage: ocx account list [provider] [--json] [--all] [--quota [--refresh]] + ocx account history openai [--limit <1-200>] [--json] ocx account current [--json] ocx account use [--json] ocx account refresh [--json] @@ -335,6 +336,10 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis const [sub, ...rest] = args; try { if (sub === "list") return await cmdList(rest, deps); + if (sub === "history") { + const { cmdAccountHistory } = await import("./account-history"); + return await cmdAccountHistory(rest, deps); + } if (sub === "current") return await cmdCurrent(rest, deps); if (sub === "use") return await cmdUse(rest, deps); if (sub === "refresh") return await cmdRefresh(rest, deps); diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 63cf313676..add59eeb74 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -235,6 +235,18 @@ export const CAPABILITIES: readonly Capability[] = [ "Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.", ], }, + { + command: ["account", "history"], + summary: "Cached quota observations for one stored Codex pool account.", + routes: [{ method: "GET", path: "/api/codex-auth/quota/history" }], + flags: [ + { name: "--json", value: "boolean", summary: "Emit the bounded observation history." }, + { name: "--limit", value: "number", summary: "Return the newest 1 to 200 observations." }, + ], + mutates: false, + json: "payload", + details: ["Use account history openai . Reads cached observations only; no refresh or warmup. Native main is not included."], + }, { command: ["account", "list"], summary: "Codex OAuth accounts with pool priority and pause state.", diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 09becf51ea..4c112d03b0 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -1,3 +1,6 @@ +import { capturePoolQuotaWriter } from "./account-store"; +import type { PoolQuotaWriter } from "./quota-types"; +import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "./quota"; import { ConfigMutationLockError, loadConfig, @@ -1358,6 +1361,7 @@ async function recoverPoolQuotaFrom401(ctx: { const writerGeneration = captureConfigGeneration(); markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation); + const poolWriter = capturePoolQuotaWriter(accountId, refreshed); const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${refreshed.accessToken}`, @@ -1376,7 +1380,7 @@ async function recoverPoolQuotaFrom401(ctx: { return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation }; } const result = await commitPoolQuotaResponse(replay, { - accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, + accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, poolWriter, mayPublish: ctx.quotaProbeEvidence.mayPublish, }); return result.freshCredentialGeneration === refreshed.generation ? { @@ -1418,11 +1422,13 @@ async function commitPoolQuotaResponse( configuredPlan: string | undefined; generation: number; writerGeneration: number; + poolWriter?: PoolQuotaWriter; mayPublish?: () => boolean; }, ): Promise { const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx; const data = (await resp.json()) as WhamUsageResponse; + const observedAt = Date.now(); if (ctx.mayPublish?.() === false) { return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation }; } @@ -1440,7 +1446,8 @@ async function commitPoolQuotaResponse( if (!isCodexAccountGenerationLive(accountId, generation)) { return { quota: null, needsReauth: false, credentialGeneration: generation }; } - setAccountQuotaFromParsed(accountId, quota, writerGeneration); + setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, quota, + ctx.poolWriter && isValidWhamHistoryObservation(data) ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined); return { quota: getAccountQuota(accountId), needsReauth: false, @@ -1464,6 +1471,7 @@ async function fetchFreshPoolAccountQuota( let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation; try { const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId); + const poolWriter = capturePoolQuotaWriter(accountId, { accessToken, chatgptAccountId, generation }); requestCredentialGeneration = generation; onCredentialGeneration?.(generation); markQuotaProbeAttempted(quotaProbeEvidence, generation); @@ -1494,7 +1502,7 @@ async function fetchFreshPoolAccountQuota( return withQuotaProbeEvidence(recovered, quotaProbeEvidence); } const committed = await commitPoolQuotaResponse(resp, { - accountId, existing, configuredPlan, generation, writerGeneration, + accountId, existing, configuredPlan, generation, writerGeneration, poolWriter, mayPublish: quotaProbeEvidence.mayPublish, }); return withQuotaProbeEvidence(committed, quotaProbeEvidence); @@ -2526,6 +2534,19 @@ export async function handleCodexAuthAPI( return jsonResponse({ ok: true }); } + if (url.pathname === "/api/codex-auth/quota/history" && req.method === "GET") { + const accountId = url.searchParams.get("accountId"); + const rawLimit = url.searchParams.get("limit"); + if (url.searchParams.getAll("accountId").length !== 1 || !isValidCodexAccountId(accountId) + || url.searchParams.getAll("limit").length > 1 + || [...url.searchParams.keys()].some(key => key !== "accountId" && key !== "limit") + || (rawLimit !== null && !/^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(rawLimit))) { + return jsonResponse({ error: "A stored pool accountId and optional limit from 1 to 200 are required" }, 400); + } + if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404); + return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, rawLimit === null ? 200 : Number(rawLimit)) }); + } + if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") { const quotas: Record = {}; for (const [id, q] of listAccountQuotas()) quotas[id] = q; diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 2cb97df2e9..007d6866f6 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,3 +1,4 @@ +import type { PoolQuotaWriter } from "./quota-types"; import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { CodexCredentialGenerationConflictError, @@ -6,6 +7,7 @@ import { CodexCredentialRefreshStaleError, getCodexAccountCredential, getValidCodexToken, + capturePoolQuotaWriter, isCodexAccountGenerationLive, readCodexAccountRecord, } from "./account-store"; @@ -120,6 +122,7 @@ export type CodexAuthContext = accountId: string; writerGeneration: number; generation: number; + poolQuotaWriter?: PoolQuotaWriter; accessToken: string; chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ @@ -1030,6 +1033,7 @@ export async function resolveCodexAuthContext( accountId, writerGeneration, generation: token.generation, + poolQuotaWriter: capturePoolQuotaWriter(accountId, token), accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts index ce11de97e4..cf21a46e16 100644 --- a/src/codex/quota-auto-refresh.ts +++ b/src/codex/quota-auto-refresh.ts @@ -8,7 +8,7 @@ import { isSelectableCodexPoolAccount } from "./account-id"; import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { isCodexAccountPaused } from "./account-pause"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; -import { getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; +import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; import { getMainAccountToken, getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { isMainAccountHardLocked } from "./main-account-hard-lock"; @@ -170,10 +170,11 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise { if (isCodexAccountGenerationLive(accountId, token.generation)) { - applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration); + applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, undefined, { poolWriter }); } } }); } catch (error) { diff --git a/src/codex/quota-history.ts b/src/codex/quota-history.ts new file mode 100644 index 0000000000..a792f89597 --- /dev/null +++ b/src/codex/quota-history.ts @@ -0,0 +1,160 @@ +import { isValidCodexAccountId } from "./account-id"; +import type { PoolQuotaWriter } from "./quota-types"; + +export const QUOTA_HISTORY_LIMITS = { perAccount: 200, accounts: 64, samples: 4096, bytes: 2 * 1024 * 1024, ageMs: 30 * 86400_000 } as const; +export interface QuotaHistoryWindow { + family: "account" | "spark"; + window: "short" | "weekly" | "monthly"; + usedPercent: number; + resetAtMs?: number; + windowSeconds?: number; + monthlyIsPrimaryWindow?: boolean; +} +export interface QuotaHistorySample { + observedAt: number; + source: "wham" | "response-header"; + credentialGeneration: number; + windows: QuotaHistoryWindow[]; +} +type Envelope = { identity: string; samples: QuotaHistorySample[] }; +type Bucket = Envelope & { costs: number[]; overhead: number }; +const encoder = new TextEncoder(); +const byteSize = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength; +const identityPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const record = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); +const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0; + +/** Reconstruct allowlisted data at the disk boundary; malformed windows cannot be partial evidence. */ +function parseSample(value: unknown, now: number): QuotaHistorySample | undefined { + if (!record(value) || !finite(value.observedAt) || value.observedAt > now + || value.observedAt < now - QUOTA_HISTORY_LIMITS.ageMs + || !Number.isSafeInteger(value.credentialGeneration) || (value.credentialGeneration as number) < 0 + || (value.source !== "wham" && value.source !== "response-header") + || !Array.isArray(value.windows) || !value.windows.length || value.windows.length > 5) return undefined; + const windows: QuotaHistoryWindow[] = []; + const seen = new Set(); + for (const item of value.windows) { + if (!record(item) || (item.family !== "account" && item.family !== "spark") + || (item.window !== "short" && item.window !== "weekly" && item.window !== "monthly") + || (item.family === "spark" && item.window === "monthly") + || !finite(item.usedPercent) || item.usedPercent > 100) return undefined; + const key = `${item.family}:${item.window}`; + if (seen.has(key)) return undefined; + seen.add(key); + if ((item.resetAtMs !== undefined && !finite(item.resetAtMs)) + || (item.windowSeconds !== undefined && (!finite(item.windowSeconds) || item.windowSeconds === 0)) + || (item.monthlyIsPrimaryWindow !== undefined && typeof item.monthlyIsPrimaryWindow !== "boolean")) return undefined; + windows.push({ family: item.family, window: item.window, usedPercent: item.usedPercent, + ...(item.resetAtMs !== undefined ? { resetAtMs: item.resetAtMs as number } : {}), + ...(item.windowSeconds !== undefined ? { windowSeconds: item.windowSeconds as number } : {}), + ...(item.monthlyIsPrimaryWindow === true && item.family === "account" && item.window === "monthly" ? { monthlyIsPrimaryWindow: true } : {}), + }); + } + return { observedAt: value.observedAt, source: value.source, credentialGeneration: value.credentialGeneration as number, windows }; +} + +/** Bounded in-process observations. The quota cache owns persistence and credential admission. */ +export class CodexQuotaHistory { + private accounts = new Map(); + private bytes = 32; + private count = 0; + + append(writer: PoolQuotaWriter, sample: QuotaHistorySample, now = Date.now()): void { + if (!isValidCodexAccountId(writer.accountId) || !identityPattern.test(writer.historyIdentity) + || sample.credentialGeneration !== writer.credentialGeneration) return; + const parsed = parseSample(sample, now); + if (!parsed) return; + let bucket = this.accounts.get(writer.accountId); + if (bucket && bucket.identity !== writer.historyIdentity) { this.clear(writer.accountId); bucket = undefined; } + if (!bucket) { + const overhead = byteSize(writer.accountId) + byteSize({ identity: writer.historyIdentity, samples: [] }) + 8; + bucket = { identity: writer.historyIdentity, samples: [], costs: [], overhead }; + this.accounts.set(writer.accountId, bucket); + this.bytes += overhead; + } + const index = bucket.samples.findIndex(row => row.observedAt > parsed.observedAt); + const position = index < 0 ? bucket.samples.length : index; + const cost = byteSize(parsed) + 1; + bucket.samples.splice(position, 0, parsed); + bucket.costs.splice(position, 0, cost); + this.bytes += cost; + this.count++; + while (bucket.samples.length > QUOTA_HISTORY_LIMITS.perAccount) this.dropFirst(writer.accountId); + this.prune(now); + } + + read(accountId: string, identity: string | undefined, now = Date.now(), limit: number = QUOTA_HISTORY_LIMITS.perAccount): { samples: QuotaHistorySample[]; truncated: boolean } { + this.prune(now); + const bucket = this.accounts.get(accountId); + if (!identity || !bucket) return { samples: [], truncated: false }; + if (bucket.identity !== identity) { this.clear(accountId); return { samples: [], truncated: false }; } + const capped = Math.max(1, Math.min(QUOTA_HISTORY_LIMITS.perAccount, Math.trunc(limit))); + return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped }; + } + + clear(accountId?: string): number { + if (accountId === undefined) { + const count = this.accounts.size; + this.accounts.clear(); this.count = 0; this.bytes = 32; + return count; + } + const bucket = this.accounts.get(accountId); + if (!bucket) return 0; + this.bytes -= bucket.overhead + bucket.costs.reduce((a, b) => a + b, 0); + this.count -= bucket.samples.length; + this.accounts.delete(accountId); + return 1; + } + + reconcile(ids: ReadonlySet): number { + let removed = 0; + for (const id of this.accounts.keys()) if (!ids.has(id)) removed += this.clear(id); + return removed; + } + + serialize(now = Date.now()): { version: 1; accounts: Record } { + this.prune(now); + return { version: 1, accounts: Object.fromEntries([...this.accounts].map(([id, bucket]) => [id, + { identity: bucket.identity, samples: structuredClone(bucket.samples) }])) }; + } + + hydrate(value: unknown, now = Date.now()): void { + this.clear(); + if (!record(value) || value.version !== 1 || !record(value.accounts) || byteSize(value) > QUOTA_HISTORY_LIMITS.bytes) return; + const entries = Object.entries(value.accounts); + if (entries.length > QUOTA_HISTORY_LIMITS.accounts) return; + let count = 0; + for (const [id, envelope] of entries) { + if (!isValidCodexAccountId(id) || !record(envelope) || typeof envelope.identity !== "string" + || !identityPattern.test(envelope.identity) || !Array.isArray(envelope.samples) + || envelope.samples.length > QUOTA_HISTORY_LIMITS.perAccount) return; + count += envelope.samples.length; + if (count > QUOTA_HISTORY_LIMITS.samples) return; + } + for (const [accountId, raw] of entries) { + const envelope = raw as Envelope; + for (const sample of envelope.samples) { + const parsed = parseSample(sample, now); + if (parsed) this.append({ accountId, historyIdentity: envelope.identity, credentialGeneration: parsed.credentialGeneration }, parsed, now); + } + } + } + + private dropFirst(id: string): void { + const bucket = this.accounts.get(id)!; + this.bytes -= bucket.costs.shift()!; + bucket.samples.shift(); this.count--; + if (!bucket.samples.length) { this.bytes -= bucket.overhead; this.accounts.delete(id); } + } + + private prune(now: number): void { + for (const [id, bucket] of this.accounts) { + while (bucket.samples.length && bucket.samples[0].observedAt < now - QUOTA_HISTORY_LIMITS.ageMs) this.dropFirst(id); + } + while (this.accounts.size > QUOTA_HISTORY_LIMITS.accounts || this.count > QUOTA_HISTORY_LIMITS.samples || this.bytes > QUOTA_HISTORY_LIMITS.bytes) { + const first = [...this.accounts].sort(([a, x], [b, y]) => x.samples[0].observedAt - y.samples[0].observedAt || a.localeCompare(b))[0]; + if (!first) break; + this.dropFirst(first[0]); + } + } +} diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 1a6260636c..f7bff7dc0e 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; @@ -6,10 +6,12 @@ import { isThirtyDayOnlyCodexPlan } from "./plan"; import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache"; -import type { StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types"; +import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history"; +import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store"; +import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types"; export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types"; -/** Disk snapshot under OPENCODEX_HOME — quota and policy identity only, never credential tags. */ +/** Disk snapshot: quota, private non-secret publication UUIDs and policy identity; never token-derived fingerprints. */ const QUOTA_CACHE_FILENAME = "codex-quota-cache.json"; /** Keep last-known bars across restarts; WHAM still refreshes on TTL in live/prime paths. */ const QUOTA_DISK_MAX_AGE_MS = 6 * 60 * 60_000; @@ -19,6 +21,7 @@ type QuotaDiskFile = { version: 1; quotas: Record; mainPolicyQuota?: MainPolicyQuota; + history?: ReturnType; }; type MainPolicyQuota = { identityKey: string; quota: StoredAccountQuota }; @@ -61,6 +64,7 @@ export function resetAtToMs(resetAt: number): number { } const accountQuota = new Map(); +const quotaHistory = new CodexQuotaHistory(); let lastReconciledGeneration = 0; let liveAccountIds = new Set(); @@ -268,6 +272,7 @@ export function setAccountQuotaFromParsed( writerGeneration = captureConfigGeneration(), mainWriter?: MainQuotaWriter, policyQuota: Omit | null = quota, + historyEvidence?: QuotaObservationEvidence, ): void { if (!quota) return; if (!mayCommitAccountQuota(accountId, writerGeneration)) return; @@ -276,6 +281,11 @@ export function setAccountQuotaFromParsed( hydrateAccountQuotasFromDisk(); const legacyExisting = accountQuota.get(accountId); const updatedAt = Date.now(); + if (historyEvidence && historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer)) { + quotaHistory.append(historyEvidence.writer, { observedAt: historyEvidence.observedAt, source: historyEvidence.source, + credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw), + }, updatedAt); + } // Legacy rotation keeps its existing carry behavior, but never inherits policy-only // evidence that outlived its disk TTL. Policy has a separate, identity-checked base. const next = mergeAccountQuota(quota, legacyExisting, updatedAt); @@ -446,6 +456,7 @@ const SPARK_MODEL_MARKER = "codex-spark"; * must write the SAME label so a header refresh replaces the WHAM reading instead of doubling it. */ const SPARK_SHORT_WINDOW_LABEL = "GPT-5.3-Codex-Spark 5h"; +const SPARK_WEEKLY_WINDOW_LABEL = "GPT-5.3-Codex-Spark Weekly"; /** True when the routed model belongs to the Spark family, which carries its own rate limit. */ function isCodexSparkModel(modelId: string | undefined): boolean { @@ -540,7 +551,7 @@ export function applyAccountQuotaFromUpstreamHeaders( headers: Headers, writerGeneration = captureConfigGeneration(), mainWriter?: MainQuotaWriter, - options?: { modelId?: string }, + options?: { modelId?: string; poolWriter?: PoolQuotaWriter }, ): void { const quota = parseUpstreamQuotaHeaders(headers, options); if (!quota) return; @@ -564,7 +575,10 @@ export function applyAccountQuotaFromUpstreamHeaders( legacyQuota = { ...quota, customWindows: merged }; } } - setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota); + const validHistory = !["x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent"] + .some(name => isInvalidPolicyUsagePercent(headers.get(name))); + setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota, + options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined); } export function updateAccountQuota( @@ -658,9 +672,10 @@ function hydrateAccountQuotasFromDisk(): void { try { const path = join(getConfigDir(), QUOTA_CACHE_FILENAME); if (!existsSync(path)) return; - const raw = readFileSync(path, "utf8"); + const raw = readQuotaCacheBounded(path); const parsed = JSON.parse(raw) as QuotaDiskFile; if (!parsed || parsed.version !== 1 || !parsed.quotas || typeof parsed.quotas !== "object") return; + quotaHistory.hydrate(parsed.history); // Policy evidence deliberately outlives the legacy six-hour rotation-cache TTL. mainPolicyQuota = readMainPolicyQuota(parsed.mainPolicyQuota); const now = Date.now(); @@ -687,6 +702,7 @@ function schedulePersistAccountQuotas(): void { version: 1, quotas, ...(mainPolicyQuota ? { mainPolicyQuota } : {}), + history: quotaHistory.serialize(), }; atomicWriteFile(join(getConfigDir(), QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`); } catch { @@ -735,6 +751,8 @@ function forgetCodexQuotaBaseline(accountId?: string): void { } export function clearAccountQuota(accountId?: string): void { + if (accountId) hydrateAccountQuotasFromDisk(); + quotaHistory.clear(accountId); if (accountId) { hydrateAccountQuotasFromDisk(); accountQuota.delete(accountId); @@ -762,7 +780,7 @@ export function clearAccountQuota(accountId?: string): void { export function reconcileCodexQuotaAccounts(context: GenerationContext): number { if (context.generation <= lastReconciledGeneration) return 0; hydrateAccountQuotasFromDisk(); - let removed = 0; + let removed = quotaHistory.reconcile(context.codexAccountIds); for (const accountId of accountQuota.keys()) { if (context.codexAccountIds.has(accountId)) continue; accountQuota.delete(accountId); @@ -888,7 +906,7 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit = []; for (const [label, window] of [ [SPARK_SHORT_WINDOW_LABEL, sparkShort], - ["GPT-5.3-Codex-Spark Weekly", sparkWeekly], + [SPARK_WEEKLY_WINDOW_LABEL, sparkWeekly], ] as const) { const percent = normalizeUsagePercent(window?.used_percent); if (percent === undefined) continue; @@ -902,3 +920,70 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit; +} + +/** Reject raw invalid readings before the compatibility parser clamps them into valid-looking bars. */ +export function isValidWhamHistoryObservation(data: WhamUsageResponse): boolean { + const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; + for (const limit of Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []) { + if (limit && typeof limit === "object") windows.push(limit.rate_limit?.primary_window, limit.rate_limit?.secondary_window); + } + return !windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent)); +} + +function historyWindows(quota: Omit): QuotaHistoryWindow[] { + const windows: QuotaHistoryWindow[] = []; + for (const window of ["short", "weekly", "monthly"] as const) { + const percent = quota[`${window}Percent`]; + const reset = quota[`${window}ResetAt`]; + if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) continue; + windows.push({ family: "account", window, usedPercent: percent, + ...(typeof reset === "number" && Number.isFinite(reset) && reset >= 0 ? { resetAtMs: resetAtToMs(reset) } : {}), + ...(window === "short" && quota.shortWindowSeconds ? { windowSeconds: quota.shortWindowSeconds } : {}), + ...(window === "monthly" && quota.monthlyIsPrimaryWindow ? { monthlyIsPrimaryWindow: true } : {}), + }); + } + for (const [label, window] of [[SPARK_SHORT_WINDOW_LABEL, "short"], [SPARK_WEEKLY_WINDOW_LABEL, "weekly"]] as const) { + const raw = quota.customWindows?.find(row => row.label === label); + if (!raw || !Number.isFinite(raw.percent) || raw.percent < 0 || raw.percent > 100) continue; + windows.push({ family: "spark", window, usedPercent: raw.percent, + ...(typeof raw.resetAt === "number" && Number.isFinite(raw.resetAt) && raw.resetAt >= 0 ? { resetAtMs: resetAtToMs(raw.resetAt) } : {}), + }); + } + return windows; +} + +/** A cache read is bounded even if a file grows between stat and read. */ +function readQuotaCacheBounded(path: string): string { + const limit = 4 * 1024 * 1024; + const flags = fsConstants.O_RDONLY | (process.platform === "win32" ? 0 : fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW); + const fd = openSync(path, flags); + try { + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > limit) throw new Error("quota cache exceeds bounds"); + const chunks: Buffer[] = []; + let total = 0; + while (total <= limit) { + const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, limit + 1 - total)); + const size = readSync(fd, chunk, 0, chunk.length, null); + if (!size) return Buffer.concat(chunks, total).toString("utf8"); + chunks.push(chunk.subarray(0, size)); total += size; + } + throw new Error("quota cache exceeds bounds"); + } finally { closeSync(fd); } +} + +/** Cached pool observations only. An unavailable identity never authorizes publication or deletion. */ +export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_HISTORY_LIMITS.perAccount) { + hydrateAccountQuotasFromDisk(); + const result = quotaHistory.read(accountId, poolQuotaHistoryIdentity(accountId), Date.now(), limit); + return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample), + truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } }; +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 9fb71e662c..ab698f05c2 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -91,6 +91,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/codex-auth/active", module: "codex/auth-api", mutates: false }, { method: "GET", path: "/api/codex-auth/login-status", module: "codex/auth-api", mutates: false }, { method: "GET", path: "/api/codex-auth/quota", module: "codex/auth-api", mutates: false }, + { method: "GET", path: "/api/codex-auth/quota/history", module: "codex/auth-api", mutates: false }, { method: "GET", path: "/api/codex-auth/reset-credits", module: "codex/auth-api", mutates: false }, { method: "PATCH", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/accounts", module: "codex/auth-api", mutates: true }, diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 21958143db..28fd4a564f 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1,3 +1,4 @@ +import { capturePoolQuotaWriter } from "../../codex/account-store"; import type { Server } from "bun"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; import { @@ -356,6 +357,7 @@ async function refreshPoolCompactContext(args: { accessToken: refreshed.accessToken, chatgptAccountId: refreshed.chatgptAccountId, generation: refreshed.generation, + poolQuotaWriter: capturePoolQuotaWriter(authCtx.accountId, refreshed), }; const refreshedProvider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(provider), @@ -1037,7 +1039,7 @@ export async function handleResponsesCompact( upstream.headers, authCtx.writerGeneration, authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined, - { modelId: route.modelId }, + { modelId: route.modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined }, ); } recordCompactPoolOutcome(authCtx, upstream.status, { @@ -1078,6 +1080,12 @@ export async function handleResponsesCompact( } } } + // Capture the final serving account as well as an earlier rejected account, once per response. + if (outcomeCtx.kind === "pool") { + const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/quota"); + applyAccountQuotaFromUpstreamHeaders(outcomeCtx.accountId, upstream.headers, outcomeCtx.writerGeneration, + undefined, { modelId: route.modelId, poolWriter: outcomeCtx.poolQuotaWriter }); + } const retryAfter = upstream.headers.get("retry-after"); const resetAt = [ upstream.headers.get("x-codex-primary-reset-at"), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e141b55ef0..10cc5f9bd3 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1,3 +1,4 @@ +import { capturePoolQuotaWriter } from "../../codex/account-store"; import type { Server } from "bun"; import { randomUUID } from "node:crypto"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; @@ -1041,7 +1042,7 @@ function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderCo const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined; return headers => { if (credentialGeneration !== undefined && !isCodexAccountGenerationLive(accountId, credentialGeneration)) return; - applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter, { modelId }); + applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter, { modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined }); }; } @@ -1446,7 +1447,7 @@ async function retryCodexPoolOnAlternateAccount( firstResponse.headers, firstAuthCtx.writerGeneration, firstAuthCtx.kind === "main-pool" ? firstAuthCtx.mainQuotaWriter : undefined, - { modelId: route.modelId }, + { modelId: route.modelId, poolWriter: firstAuthCtx.kind === "pool" ? firstAuthCtx.poolQuotaWriter : undefined }, ); } const deferFirstOutcome = shouldDeferCodexResetDerivedCooldown( @@ -2420,6 +2421,7 @@ async function refreshPoolForwardAuth(args: { accessToken: refreshed.accessToken, chatgptAccountId: refreshed.chatgptAccountId, generation: refreshed.generation, + poolQuotaWriter: capturePoolQuotaWriter(authCtx.accountId, refreshed), }; const provider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(route.provider), @@ -5915,7 +5917,7 @@ async function handleResponsesInner( if (!isCodexWsQuotaObservedResponse(upstreamResponse)) { applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers, authCtx.writerGeneration, authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined, - { modelId: route.modelId }); + { modelId: route.modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined }); } if (terminalBodyWillRecord) { options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => { diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index a4dc21adbf..658daca743 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -66,3 +66,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/catalog.md b/structure/catalog.md index 0d2be61254..6ba73c03d1 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -280,3 +280,5 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 2914823958..aa95b9f74a 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -91,3 +91,5 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](../data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/codex-home.md b/structure/codex-home.md index 8085223426..d9c51d351d 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -238,3 +238,5 @@ The legacy external writer is now refused for affected rows in any store whose s Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/config.md b/structure/config.md index de957f0c7f..6e5f280401 100644 --- a/structure/config.md +++ b/structure/config.md @@ -207,3 +207,5 @@ The Cline client keeps connection settings and models in a separate native file `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 25646c7de4..b91e84e95c 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -79,3 +79,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2d17c11875..4e8122a954 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -128,3 +128,5 @@ changes prompt roles, not conversation identity, and cannot guarantee upstream c Instruction notice extraction scans fence ranges once and walks original lines backwards with a decreasing cursor. It accepts exactly one ASCII space inside the token notice, preserves unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 42a70082db..3d186568fa 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -545,3 +545,5 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi [the integration contract](clients/integrations.md#cline-paired-files) defines recovery. The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 6e86dfbf43..9d1c835637 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -316,3 +316,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Private pool credential metadata follows the [quota-history publication identity contract](../providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 39dc9a82da..62b33fd55f 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -142,3 +142,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/overview.md b/structure/overview.md index d5d1a2207a..297f30106a 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -108,3 +108,5 @@ The management quota DTO keeps Combo editing aligned with scoped inference evide see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). Cline CLI is a managed file integration: its provider settings and catalog share one recoverable journal operation. The [paired-file contract](clients/integrations.md#cline-paired-files) defines its stop/restart requirement. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 10f3016a73..b3137f178d 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -408,3 +408,11 @@ successful main usage refresh clears the runtime mark. `src/codex/account-store.ts` assigns each explicit pool credential publication a private random `quotaHistoryIdentity`. Same-account token refresh preserves it, including each alias record's own identity; replacement or deletion retires it. A refresh CAS with a changed upstream account identity rotates the tag and does not propagate that changed identity to old aliases. Credential-only projections omit this metadata. `capturePoolQuotaWriter` captures the exact dispatched access/account pair and generation. Legacy identity initialization rechecks under the credential mutation lock, persists metadata without advancing credential generation or mutation epoch, and fails to no optional evidence on read/lock/write errors. Append admission uses the captured generation and tag; history retention compares the tag across ordinary refresh. Native main is excluded from this pool proof. These interfaces supply the bounded observation layer; the identity alone is neither a quota sample nor proof of capacity. + +## Bounded pool quota observations + +`src/codex/quota-history.ts` retains at most 200 raw observations per stored pool account for 30 days, bounded globally to 64 identities, 4096 observations and 2 MiB. `src/codex/quota.ts` persists these alongside the latest quota cache; the file reader caps allocation at 4 MiB and rejects nonregular/oversized input. Invalid history envelopes are discarded without blocking inference. Atomic cache replacement is best-effort single-writer persistence, not cross-process merging. + +WHAM and response-header producers pass the exact captured pool writer, including refreshed replay and compact outcomes. Admission rechecks credential generation and publication UUID. Same-account refresh preserves prior history; replacement/removal invalidates it. Raw invalid percentages discard the entire trusted observation before display clamping; carried windows, reset credits alone, native main and staged-login probes never become durable pool history. + +`GET /api/codex-auth/quota/history` and `ocx account history openai ` read only cached, identity-checked observations. The optional limit is 1–200. Public results omit the internal publication UUID and credential generation. These observations are inputs for capacity estimation; percentages alone do not establish absolute token capacity. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5b149ac6a2..87242b2580 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -65,3 +65,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/runtime.md b/structure/runtime.md index c94e65d7e5..93ffb9993f 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -227,3 +227,5 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/subagents.md b/structure/subagents.md index c93aa8be21..29b258ceb3 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -216,3 +216,5 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2fc3b3fae..b79a70a2b5 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -70,3 +70,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2321d78dd6..1e7cb6e71e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -523,3 +523,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 68093843ea..48200dd371 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -199,3 +199,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 214270e4be..46d2329b02 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,22 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("history reads one cached endpoint and rejects invalid arguments before I/O", async () => { + let calls = 0; + const deps: AccountDeps = { baseUrl: "http://127.0.0.1:10100", fetchImpl: (async input => { + calls++; + expect(String(input)).toBe("http://127.0.0.1:10100/api/codex-auth/quota/history?accountId=pool-a&limit=2"); + return Response.json({ accountId: "pool-a", observations: [], retention: { maxObservations: 200, maxAgeDays: 30 }, truncated: false }); + }) as typeof fetch }; + const result = await run(["history", "openai", "pool-a", "--limit", "2", "--json"], deps); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).observations).toEqual([]); + for (const args of [["anthropic", "pool-a"], ["openai", "__main__"], ["openai", "pool-a", "--limit", "201"], ["openai", "pool-a", "--unknown"]]) { + expect((await run(["history", ...args], deps)).code).toBe(1); + } + expect(calls).toBe(1); + }); + test.each([100, 12])("pending validation stays visible at %s percent usage without exposing raw health details", async weeklyPercent => { codexAccounts = [{ id: "pending", email: "p***@example.test", quota: { weeklyPercent }, health: { status: "warning", reason: "validation_pending", message: RAW_SENTINEL } }]; diff --git a/tests/codex-integration/codex-quota-history.test.ts b/tests/codex-integration/codex-quota-history.test.ts new file mode 100644 index 0000000000..b63309fc81 --- /dev/null +++ b/tests/codex-integration/codex-quota-history.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistorySample } from "../../src/codex/quota-history"; +import type { PoolQuotaWriter } from "../../src/codex/quota-types"; + +const now = 1_800_000_000_000; +const identity = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const replacement = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const writer: PoolQuotaWriter = { accountId: "pool-a", credentialGeneration: 1, historyIdentity: identity }; +function sample(at = now, usedPercent = 10): QuotaHistorySample { + return { observedAt: at, source: "wham", credentialGeneration: 1, + windows: [{ family: "account", window: "weekly", usedPercent, resetAtMs: now + 100_000 }] }; +} + +describe("bounded quota observation history", () => { + test("keeps the newest observations by time and returns independent copies", () => { + const history = new CodexQuotaHistory(); + for (let index = 200; index >= 0; index--) history.append(writer, sample(now - index), now); + const result = history.read(writer.accountId, identity, now); + expect(result.samples).toHaveLength(200); + expect(result.samples[0].observedAt).toBe(now - 199); + expect(result.samples[199].observedAt).toBe(now); + result.samples[0].windows[0].usedPercent = 99; + expect(history.read(writer.accountId, identity, now).samples[0].windows[0].usedPercent).toBe(10); + expect(history.read(writer.accountId, identity, now, 1)).toMatchObject({ truncated: true, samples: [sample(now)] }); + }); + + test("refresh retains history but replacement and roster removal retire it", () => { + const history = new CodexQuotaHistory(); + history.append(writer, sample(), now); + history.append({ ...writer, credentialGeneration: 2 }, { ...sample(now + 1), credentialGeneration: 2 }, now + 1); + expect(history.read(writer.accountId, undefined, now + 1).samples).toEqual([]); + expect(history.read(writer.accountId, identity, now + 1).samples).toHaveLength(2); + expect(history.read(writer.accountId, replacement, now + 1).samples).toEqual([]); + history.append({ ...writer, historyIdentity: replacement }, sample(now + 2), now + 2); + expect(history.reconcile(new Set())).toBe(1); + expect(history.serialize(now + 2).accounts).toEqual({}); + }); + + test("rejects invalid observations and never admits native-main identity", () => { + const history = new CodexQuotaHistory(); + for (const used of [-1, 101, Number.NaN, Infinity]) history.append(writer, sample(now, used), now); + history.append(writer, sample(now + 1), now); + history.append({ ...writer, accountId: "__main__" }, sample(), now); + history.append(writer, { ...sample(), windows: [] }, now); + history.append(writer, { ...sample(), windows: [sample().windows[0], sample().windows[0]] }, now); + expect(history.serialize(now).accounts).toEqual({}); + }); + + test("disk hydration rejects overflow rather than losing a newer 65th account", () => { + const history = new CodexQuotaHistory(); + const accounts = Object.fromEntries(Array.from({ length: 65 }, (_, i) => [`pool-${i}`, { identity, samples: [sample(now - 65 + i)] }])); + history.hydrate({ version: 1, accounts }, now); + expect(history.serialize(now).accounts).toEqual({}); + history.hydrate({ version: 1, accounts: { "pool-a": { identity, samples: Array.from({ length: 201 }, () => sample()) } } }, now); + expect(history.serialize(now).accounts).toEqual({}); + history.hydrate({ version: 1, accounts: {}, extra: "x".repeat(QUOTA_HISTORY_LIMITS.bytes) }, now); + expect(history.serialize(now).accounts).toEqual({}); + }); + + test("valid unordered disk rows are sorted and arbitrary payload fields are discarded", () => { + const history = new CodexQuotaHistory(); + history.hydrate({ version: 1, accounts: { "pool-a": { identity, secret: "private-token", samples: [ + { ...sample(now), secret: "private-token" }, sample(now - 2), sample(now - 1), + ] } } }, now); + expect(history.read("pool-a", identity, now).samples.map(row => row.observedAt)).toEqual([now - 2, now - 1, now]); + expect(JSON.stringify(history.serialize(now))).not.toContain("private-token"); + expect(history.read("pool-a", identity, now + QUOTA_HISTORY_LIMITS.ageMs + 1).samples).toEqual([]); + }); + + test("global retention evicts oldest samples and stays below the serialized byte bound", () => { + const history = new CodexQuotaHistory(); + for (let account = 0; account < 65; account++) { + for (let i = 0; i < 100; i++) history.append({ ...writer, accountId: `pool-${account}` }, sample(now - 6500 + account * 100 + i), now); + } + const disk = history.serialize(now); + expect(Object.keys(disk.accounts).length).toBeLessThanOrEqual(QUOTA_HISTORY_LIMITS.accounts); + expect(Object.values(disk.accounts).reduce((n, row) => n + row.samples.length, 0)).toBeLessThanOrEqual(QUOTA_HISTORY_LIMITS.samples); + expect(new TextEncoder().encode(JSON.stringify(disk)).byteLength).toBeLessThanOrEqual(QUOTA_HISTORY_LIMITS.bytes); + expect(disk.accounts["pool-64"].samples.at(-1)?.observedAt).toBe(now - 1); + }); +}); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 72d596e75f..903d50f79b 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -1,3 +1,5 @@ +import { capturePoolQuotaWriter, saveCodexAccountCredential, saveCodexAccountCredentialIfGeneration } from "../../src/codex/account-store"; +import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "../../src/codex/quota"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -400,3 +402,51 @@ describe("main policy quota durability and lifecycle", () => { expect(matchesMainQuotaCredential("fixture-bearer-a", "fixture-main-a")).toBe(false); }); }); + + +test("pool history records fresh windows only and preserves identity across token refresh", () => { + const credential = { accessToken: "history-token", refreshToken: "history-refresh", chatgptAccountId: "history-account", expiresAt: Date.now() + 3600_000 }; + const generation = saveCodexAccountCredential("history-pool", credential); + const writer = capturePoolQuotaWriter("history-pool", { ...credential, generation })!; + const raw = { weeklyPercent: 10, weeklyResetAt: Date.now() / 1000 + 1000 }; + setAccountQuotaFromParsed("history-pool", raw, undefined, undefined, raw, { writer, observedAt: Date.now(), source: "wham", raw }); + applyAccountQuotaFromUpstreamHeaders("history-pool", new Headers({ + "x-codex-primary-used-percent": "20", "x-codex-primary-window-minutes": "300", "x-codex-primary-reset-at": String(Date.now() / 1000 + 300), + }), undefined, undefined, { poolWriter: writer }); + let rows = getAccountQuotaHistory("history-pool").observations; + expect(rows).toHaveLength(2); + expect(rows[1].windows.map(window => window.window)).toEqual(["short"]); + expect(getAccountQuota("history-pool")?.weeklyPercent).toBe(10); + setAccountQuotaFromParsed("history-pool", { resetCredits: 2 }); + expect(getAccountQuotaHistory("history-pool").observations).toHaveLength(2); + const refreshed = { ...credential, accessToken: "history-new-token" }; + expect(saveCodexAccountCredentialIfGeneration("history-pool", generation, refreshed)).toBe(true); + applyAccountQuotaFromUpstreamHeaders("history-pool", new Headers({ "x-codex-primary-used-percent": "30" }), undefined, undefined, { poolWriter: writer }); + expect(getAccountQuotaHistory("history-pool").observations).toHaveLength(2); + const refreshedWriter = capturePoolQuotaWriter("history-pool", { ...refreshed, generation: generation + 1 })!; + applyAccountQuotaFromUpstreamHeaders("history-pool", new Headers({ "x-codex-primary-used-percent": "-20" }), undefined, undefined, { poolWriter: refreshedWriter }); + rows = getAccountQuotaHistory("history-pool").observations; + expect(rows).toHaveLength(2); + expect(isValidWhamHistoryObservation({ rate_limit: { primary_window: { used_percent: 101 } } })).toBe(false); + expect(isValidWhamHistoryObservation({ additional_rate_limits: [{ rate_limit: { primary_window: { used_percent: -1 } } }] })).toBe(false); + const body = flushPersistence(); + expect(JSON.parse(body).history.accounts["history-pool"].samples).toHaveLength(2); + expect(body).not.toContain("history-token"); + expect(body).not.toContain("history-refresh"); + clearAccountQuota(); + writeSnapshot(JSON.parse(body)); + expect(getAccountQuotaHistory("history-pool").observations).toHaveLength(2); + saveCodexAccountCredential("history-pool", refreshed); + expect(getAccountQuotaHistory("history-pool").observations).toEqual([]); +}); + +test("native main observations and oversized cache never become pool history", () => { + const raw = { weeklyPercent: 20 }; + setAccountQuotaFromParsed(MAIN, raw, undefined, writerFor()); + expect(getAccountQuotaHistory(MAIN).observations).toEqual([]); + const persisted = JSON.parse(flushPersistence()); + expect(persisted.history.accounts).not.toHaveProperty(MAIN); + clearAccountQuota(); + writeFileSync(join(testDir, "codex-quota-cache.json"), " ".repeat(4 * 1024 * 1024 + 1)); + expect(getAccountQuotaHistory("history-pool").observations).toEqual([]); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f116d70a11..6aff5af109 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -314,6 +314,7 @@ "codex-prompt-text-probe.test.ts": "codex-integration", "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-quota-history.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", "codex-quota-rejection.test.ts": "codex-integration", diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index f703a1899e..336ebca564 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1,3 +1,4 @@ +import { getAccountQuotaHistory } from "../../src/codex/quota"; import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; /** @@ -1229,6 +1230,30 @@ describe("compact alternate-account attempt (#913)", () => { }); }); + test.each([false, true])("compact final quota history follows the serving account with alternate=%s", async alternate => { + await withPoolEnv("ocx-compact-history-", async config => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + const rejected = alternate && calls === 1; + return Response.json(rejected ? { error: { message: "pool exhausted" } } : completedPayload("history compact"), { + status: rejected ? 429 : 200, + headers: { "x-codex-primary-used-percent": rejected ? "100" : "25", "x-codex-primary-window-minutes": "10080" }, + }); + }) as typeof fetch; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({})), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + const first = getAccountQuotaHistory("pool-a").observations; + expect(first).toHaveLength(1); + expect(first[0].windows[0].usedPercent).toBe(alternate ? 100 : 25); + const second = getAccountQuotaHistory("pool-b").observations; + expect(second).toHaveLength(alternate ? 1 : 0); + if (alternate) expect(second[0].windows[0].usedPercent).toBe(25); + expect(calls).toBe(alternate ? 2 : 1); + }); + }); + test("canonical trailing slashes are pinned before native compact sends pool credentials", async () => { await withPoolEnv("ocx-compact-canonical-url-", async config => { config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index feec9a8151..ae3a9100b2 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -667,6 +667,30 @@ describe("unified pool-settings contract (#695 wp5c)", () => { if (dir) removeTreeWithRetry(dir); }); + test("quota history is a protected bounded cached read for stored pool accounts", async () => { + const config = loadConfig(); + config.codexAccounts = [{ id: "history-row", email: "history@example.test", isMain: false }]; + saveConfig(config); + const server = startServer(0); + try { + const endpoint = "/api/codex-auth/quota/history"; + const denied = await globalThis.fetch(new URL(`${endpoint}?accountId=history-row`, server.url)); + expect(denied.status).toBe(401); + await denied.text(); + for (const query of ["", "?accountId=__main__", "?accountId=history-row&accountId=history-row", "?accountId=history-row&limit=201", "?accountId=history-row&refresh=1"]) { + const response = await fetch(new URL(endpoint + query, server.url)); + expect(response.status).toBe(400); + await response.text(); + } + const unknown = await fetch(new URL(`${endpoint}?accountId=missing`, server.url)); + expect(unknown.status).toBe(404); + await unknown.text(); + const response = await fetch(new URL(`${endpoint}?accountId=history-row&limit=1`, server.url)); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ accountId: "history-row", observations: [], retention: { maxObservations: 200, maxAgeDays: 30 }, truncated: false }); + } finally { await server.stop(true); } + }); + test("every kind answers with the same keys and declares what it supports", async () => { const server = startServer(0); try { From 0d98205fcd1985607d8ae257ebacc4df86ac6e63 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:41:31 +0900 Subject: [PATCH 3/3] fix(codex): harden quota history dates and producer regression evidence --- .../260912_accounts/051_history_delivery.md | 2 ++ src/cli/account-history.ts | 8 ++++++- tests/cli/cli-account.test.ts | 10 +++++++++ .../codex-integration/codex-auth-api.test.ts | 4 ++++ .../codex-quota-auto-refresh.test.ts | 3 +++ .../codex-quota-history.test.ts | 21 ++++++++++++++++++- .../main-quota-provenance.test.ts | 8 ++++++- .../responses/responses-account-label.test.ts | 6 +++++- .../responses-compaction-routing.test.ts | 13 ++++++++++++ .../account-pool-management-api.test.ts | 14 +++++++++++++ 10 files changed, 85 insertions(+), 4 deletions(-) diff --git a/devlog/_plan/260912_accounts/051_history_delivery.md b/devlog/_plan/260912_accounts/051_history_delivery.md index 3ae2698d79..0e38a408c8 100644 --- a/devlog/_plan/260912_accounts/051_history_delivery.md +++ b/devlog/_plan/260912_accounts/051_history_delivery.md @@ -5,3 +5,5 @@ Extends publication identity foundation #4375 with a pure bounded history leaf, Regression sources cover chronological retention, limits/corrupt disk, private-field stripping, generation/identity changes, raw-versus-carried windows, cached API auth/validation, CLI argument rejection and compact serving-account attribution. Independent source review identified missing compact final-response capture; it was added with ordinary/alternate regressions. CLI skill surface regenerated by its source-only generator, not a product build or suite. Local suites/build/typecheck/install NOT RUN. This child targets the existing history-identity branch at19cbe826d8. The pending plan-only commit was rebased onto the parent-updated branch; foundation product bytes were unchanged. Host goal remains blocked; actual FSMB(tun) remains untouched under explicit user instruction. These are authorized source implementation and independent reviews, not a claimed new persisted PABCD cycle. Complete hosted verification belongs to the eventual cumulative history/capacity tip; no merge or issue closure. + +Review corrections: human CLI formats out-of-range dates as unknown; byte-limit fixtures now carry valid populated data and exercise append-byte eviction before row limits; authenticated API returns a populated sanitized history; WHAM refresh/replay, HTTP/WS and real warmup producer fixtures assert history including stale WS replacement rejection. Local suites remain NOT RUN. diff --git a/src/cli/account-history.ts b/src/cli/account-history.ts index fb813cbdbf..c7662d3ad4 100644 --- a/src/cli/account-history.ts +++ b/src/cli/account-history.ts @@ -1,6 +1,12 @@ import { isValidCodexAccountId } from "../codex/account-id"; import { apiError, apiJson, proxyUnreachable, resolveBaseUrl, type AccountDeps } from "./account-api"; +function historyDate(value: unknown): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "unknown"; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toISOString() : "unknown"; +} + /** Read cached pool observations without refreshing credentials or spending quota. */ export async function cmdAccountHistory(args: string[], deps: AccountDeps): Promise { const [provider, accountId, ...flags] = args; @@ -32,7 +38,7 @@ export async function cmdAccountHistory(args: string[], deps: AccountDeps): Prom if (!observation || typeof observation !== "object" || !Array.isArray(observation.windows) || !Number.isFinite(observation.observedAt)) return apiError({}, "Invalid quota history response", 502); for (const window of observation.windows) { - console.log(`${new Date(observation.observedAt).toISOString()}\t${observation.source}\t${window.family}/${window.window}\t${window.usedPercent}%\t${typeof window.resetAtMs === "number" ? new Date(window.resetAtMs).toISOString() : "unknown"}`); + console.log(`${historyDate(observation.observedAt)}\t${observation.source}\t${window.family}/${window.window}\t${window.usedPercent}%\t${historyDate(window.resetAtMs)}`); } } return 0; diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 46d2329b02..a41187e27f 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,16 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("human quota history renders populated rows and safely handles oversized reset dates", async () => { + const result = await run(["history", "openai", "pool-a"], { baseUrl: "http://127.0.0.1:10100", fetchImpl: (async () => Response.json({ + observations: [{ observedAt: 1_800_000_000_000, source: "wham", windows: [ + { family: "account", window: "weekly", usedPercent: 20, resetAtMs: 1e20 }, + ] }], + })) as typeof fetch }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("2027-01-15T08:00:00.000Z\twham\taccount/weekly\t20%\tunknown"); + }); + test("history reads one cached endpoint and rejects invalid arguments before I/O", async () => { let calls = 0; const deps: AccountDeps = { baseUrl: "http://127.0.0.1:10100", fetchImpl: (async input => { diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 89813bb058..51a05d9170 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1,3 +1,4 @@ +import { getAccountQuotaHistory } from "../../src/codex/quota"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import type { ServerWebSocket } from "bun"; import { Database } from "bun:sqlite"; @@ -1796,6 +1797,8 @@ describe("codex-auth API", () => { const data = await resp!.json() as { accounts: { id: string; quota: unknown }[] }; const pool = data.accounts.find(a => a.id === "pool-refresh"); expect(pool?.quota).toMatchObject({ weeklyPercent: 6, weeklyResetAt: 1782628379 }); + expect(getAccountQuotaHistory("pool-refresh").observations).toHaveLength(1); + expect(getAccountQuotaHistory("pool-refresh").observations[0]).toMatchObject({ source: "wham", windows: [{ family: "account", window: "weekly", usedPercent: 6, resetAtMs: 1782628379000 }] }); expect(calls).toBe(1); } finally { globalThis.fetch = originalFetch; @@ -6097,6 +6100,7 @@ describe("manual reset cooldown recovery (#3973)", () => { expect((await consume(config))?.status).toBe(200); expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).toBeNull(); expect(readCodexAccountRecord("manual-a")!.generation).toBe(generation + 1); + expect(getAccountQuotaHistory("manual-a").observations.some(row => row.source === "wham")).toBe(true); expect(urls).toEqual([CONSUME, USAGE, "https://auth.openai.com/oauth/token", USAGE]); }); diff --git a/tests/codex-integration/codex-quota-auto-refresh.test.ts b/tests/codex-integration/codex-quota-auto-refresh.test.ts index 605247db16..375317d9a1 100644 --- a/tests/codex-integration/codex-quota-auto-refresh.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh.test.ts @@ -12,6 +12,7 @@ import { import { clearAccountQuota, getAccountQuota, + getAccountQuotaHistory, setAccountQuotaFromParsed, type StoredAccountQuota, } from "../../src/codex/quota"; @@ -173,6 +174,8 @@ describe("Codex quota window auto refresh", () => { expect(getAccountQuota("pool-a")).toMatchObject({ shortPercent: 0, shortResetAt: RESET_SECONDS + 18_000 }); resetCodexQuotaAutoRefreshForTests(); await runCodexQuotaAutoRefresh(loadConfig(), NOW + 18_000_000, deps); + expect(getAccountQuotaHistory("pool-a").observations).toHaveLength(2); + expect(getAccountQuotaHistory("pool-a").observations.every(row => row.source === "response-header" && row.windows[0]?.usedPercent === 0)).toBe(true); expect(calls).toBe(2); expect(loadConfig().codexQuotaAutoRefresh?.["pool-a"]?.lastFiveHourResetAt).toBe(NOW + 18_000_000); }); diff --git a/tests/codex-integration/codex-quota-history.test.ts b/tests/codex-integration/codex-quota-history.test.ts index b63309fc81..feb1ea8cc1 100644 --- a/tests/codex-integration/codex-quota-history.test.ts +++ b/tests/codex-integration/codex-quota-history.test.ts @@ -53,7 +53,7 @@ describe("bounded quota observation history", () => { expect(history.serialize(now).accounts).toEqual({}); history.hydrate({ version: 1, accounts: { "pool-a": { identity, samples: Array.from({ length: 201 }, () => sample()) } } }, now); expect(history.serialize(now).accounts).toEqual({}); - history.hydrate({ version: 1, accounts: {}, extra: "x".repeat(QUOTA_HISTORY_LIMITS.bytes) }, now); + history.hydrate({ version: 1, accounts: { "pool-a": { identity, samples: [sample()] } }, extra: "x".repeat(QUOTA_HISTORY_LIMITS.bytes) }, now); expect(history.serialize(now).accounts).toEqual({}); }); @@ -79,3 +79,22 @@ describe("bounded quota observation history", () => { expect(disk.accounts["pool-64"].samples.at(-1)?.observedAt).toBe(now - 1); }); }); + + +test("append byte budget evicts samples before any row or account count limit", () => { + const history = new CodexQuotaHistory(); + const windows = ([ + ["account", "short"], ["account", "weekly"], ["account", "monthly"], ["spark", "short"], ["spark", "weekly"], + ] as const).map(([family, window]) => ({ family, window, usedPercent: 12.345678901234567, + resetAtMs: 1_800_000_123_456.789, windowSeconds: 123_456_789.12345678, + ...(window === "monthly" ? { monthlyIsPrimaryWindow: true } : {}), + })); + for (let account = 0; account < 32; account++) for (let index = 0; index < 128; index++) { + history.append({ ...writer, accountId: `long-account-${account}` }, { ...sample(now - 4096 + account * 128 + index), windows }, now); + } + const persisted = history.serialize(now); + const retained = Object.values(persisted.accounts).reduce((count, row) => count + row.samples.length, 0); + expect(retained).toBeGreaterThan(0); + expect(retained).toBeLessThan(4096); + expect(new TextEncoder().encode(JSON.stringify(persisted)).byteLength).toBeLessThanOrEqual(QUOTA_HISTORY_LIMITS.bytes); +}); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 903d50f79b..a1e8b85418 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -447,6 +447,12 @@ test("native main observations and oversized cache never become pool history", ( const persisted = JSON.parse(flushPersistence()); expect(persisted.history.accounts).not.toHaveProperty(MAIN); clearAccountQuota(); - writeFileSync(join(testDir, "codex-quota-cache.json"), " ".repeat(4 * 1024 * 1024 + 1)); + const credential = { accessToken: "large-cache-access", refreshToken: "large-cache-refresh", chatgptAccountId: "large-cache-account", expiresAt: Date.now() + 3600_000 }; + const generation = saveCodexAccountCredential("history-pool", credential); + const writer = capturePoolQuotaWriter("history-pool", { ...credential, generation })!; + writeFileSync(join(testDir, "codex-quota-cache.json"), JSON.stringify({ version: 1, quotas: {}, history: { version: 1, accounts: { + "history-pool": { identity: writer.historyIdentity, samples: [{ observedAt: Date.now(), source: "wham", credentialGeneration: generation, + windows: [{ family: "account", window: "weekly", usedPercent: 20 }] }] }, + } }, padding: "x".repeat(4 * 1024 * 1024) })); expect(getAccountQuotaHistory("history-pool").observations).toEqual([]); }); diff --git a/tests/responses/responses-account-label.test.ts b/tests/responses/responses-account-label.test.ts index e96c4556d9..1980c4a5e4 100644 --- a/tests/responses/responses-account-label.test.ts +++ b/tests/responses/responses-account-label.test.ts @@ -16,7 +16,7 @@ import { handleResponses } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { CodexWsMetadata } from "../../src/server/responses/codex-ws-metadata"; -import { applyAccountQuotaFromUpstreamHeaders } from "../../src/codex/quota"; +import { applyAccountQuotaFromUpstreamHeaders, getAccountQuotaHistory } from "../../src/codex/quota"; const originalFetch = globalThis.fetch; @@ -183,6 +183,8 @@ describe("Responses account usage attribution", () => { await response.text(); expect(getAccountQuota(accountId)?.weeklyPercent).toBe(20); expect(getAccountQuota("untouched-account")?.weeklyPercent).toBe(7); + expect(getAccountQuotaHistory(accountId).observations.map(row => row.windows[0].usedPercent)) + .toEqual(accountId === MAIN_CODEX_ACCOUNT_ID ? [] : [10, 20]); } }); } finally { @@ -235,6 +237,7 @@ describe("Responses account usage attribution", () => { codexWsRuntimeIdentity: "1.4.0", }); expect(getAccountQuota("pool-ws-replaced")?.weeklyPercent).toBe(10); + expect(getAccountQuotaHistory("pool-ws-replaced").observations.map(row => row.windows[0].usedPercent)).toEqual([10]); savePoolCredential("pool-ws-replaced"); clearAccountQuota("pool-ws-replaced"); @@ -242,6 +245,7 @@ describe("Responses account usage attribution", () => { await response.text(); expect(getAccountQuota("pool-ws-replaced")).toBeNull(); + expect(getAccountQuotaHistory("pool-ws-replaced").observations).toEqual([]); }); } finally { releaseFinalQuota(); diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 336ebca564..d7b14cc1ce 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1230,6 +1230,19 @@ describe("compact alternate-account attempt (#913)", () => { }); }); + test("ordinary pooled HTTP responses publish their captured quota history writer", async () => { + await withPoolEnv("ocx-http-history-", async config => { + globalThis.fetch = (async () => Response.json(completedPayload("ordinary history"), { + headers: { "x-codex-primary-used-percent": "31", "x-codex-primary-window-minutes": "10080" }, + })) as typeof fetch; + const response = await handleResponses(compactionRequest({ model: "gpt-5.5", input: [{ role: "user", content: "hello" }], stream: false }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(getAccountQuotaHistory("pool-a").observations).toHaveLength(1); + expect(getAccountQuotaHistory("pool-a").observations[0].windows[0].usedPercent).toBe(31); + }); + }); + test.each([false, true])("compact final quota history follows the serving account with alternate=%s", async alternate => { await withPoolEnv("ocx-compact-history-", async config => { let calls = 0; diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index ae3a9100b2..63a1feefc3 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -688,6 +688,20 @@ describe("unified pool-settings contract (#695 wp5c)", () => { const response = await fetch(new URL(`${endpoint}?accountId=history-row&limit=1`, server.url)); expect(response.status).toBe(200); expect(await response.json()).toEqual({ accountId: "history-row", observations: [], retention: { maxObservations: 200, maxAgeDays: 30 }, truncated: false }); + const { saveCodexAccountCredential, capturePoolQuotaWriter } = await import("../../src/codex/account-store"); + const { setAccountQuotaFromParsed } = await import("../../src/codex/quota"); + const credential = { accessToken: "history-secret-access", refreshToken: "history-secret-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "private-history-account" }; + const generation = saveCodexAccountCredential("history-row", credential); + const writer = capturePoolQuotaWriter("history-row", { ...credential, generation })!; + const raw = { weeklyPercent: 21 }; + setAccountQuotaFromParsed("history-row", raw, undefined, undefined, raw, { writer, observedAt: Date.now(), source: "wham", raw }); + const populated = await fetch(new URL(`${endpoint}?accountId=history-row`, server.url)); + const body = await populated.json() as { observations: Array<{ source: string; windows: Array<{ usedPercent: number }> }> }; + expect(body.observations).toHaveLength(1); + expect(body.observations[0]).toMatchObject({ source: "wham", windows: [{ family: "account", window: "weekly", usedPercent: 21 }] }); + const serialized = JSON.stringify(body); + for (const privateValue of [credential.accessToken, credential.refreshToken, writer.historyIdentity, "credentialGeneration"]) expect(serialized).not.toContain(privateValue); + } finally { await server.stop(true); } });