diff --git a/devlog/_plan/260912_accounts/000_plan.md b/devlog/_plan/260912_accounts/000_plan.md index df54929c99..ed3e4574c1 100644 --- a/devlog/_plan/260912_accounts/000_plan.md +++ b/devlog/_plan/260912_accounts/000_plan.md @@ -46,3 +46,5 @@ Two design follow-ups encountered inherited-model capacity errors; one same-hand ## Roadmap cycle outcome Independent design reflection and A re-audit passed with the source restrictions in 001_roadmap_audit.md. B freezes the contracts as documentation only. C checks document paths/numbering and git whitespace; local product suites NOT RUN. D next direction: execute 010_callback.md independently, then the remaining dependency-ordered cycles. Runtime behavior has not improved yet; the rejected hypotheses were native history identity by sentinel alone, attempt timing inferred from untimed attempts, and one-shot implying one physical request through a retrying primitive. + +History P split:048_history_identity.md supplies stable publication identity and fenced writer capture before050 history. This is a new foundation cycle, registered in the same goalplan; intended manual chain history-identity → history → capacity. It is independent of reset-first. Staged login samples are omitted until a fenced post-publication observation; native history remains nondurable and excluded from capacity. diff --git a/devlog/_plan/260912_accounts/020_eligibility.md b/devlog/_plan/260912_accounts/020_eligibility.md index 94719a4f58..734bc42d89 100644 --- a/devlog/_plan/260912_accounts/020_eligibility.md +++ b/devlog/_plan/260912_accounts/020_eligibility.md @@ -17,3 +17,5 @@ Field chain: existing excludedPlans config create/save/load → same normalized MODIFY existing `tests/codex-integration/codex-pool-plan-exclusion.test.ts`: replace last-account soft fallback test with none/preview none; test normalized plan update and explicit fixed route. Extend account API/CLI and card tests for reason and renewal clearing. Sync ownership docs and providers configuration pages that describe the old soft exception. Retain source attribution of #4238; no recarry of already-landed commits. Local tests/build/typecheck NOT RUN. Hosted CI plus rendered artifact from final tip supplies execution proof. Exclusion reason derives from the routing config plan, not a display-only freshly observed plan if persistence failed. This preserves truth between selection and explanation. + +P revalidation on dev d6fb87197a: keep exported existing predicate in routing.ts; pass runtimeConfig into both poolAccountDto calls. Alongside closed selectionExcludedReason include selectionExcludedPlan from the same routing config when excluded, so a display-only fresh WHAM tier cannot mislabel the reason. CLI/card render this policy plan. Exact GUI type owner is hooks/useCodexAccountPool.ts; component type file re-exports it. Docs source is guides/codex-integration.md in every locale; revise all-excluded fallback paragraphs there. Callback D delivered PR4352 and left hosted acceptance open; this cycle is independent from current dev. diff --git a/devlog/_plan/260912_accounts/021_eligibility_delivery.md b/devlog/_plan/260912_accounts/021_eligibility_delivery.md new file mode 100644 index 0000000000..ed4405ac17 --- /dev/null +++ b/devlog/_plan/260912_accounts/021_eligibility_delivery.md @@ -0,0 +1,9 @@ +# Plan exclusion completion + +Built on already-landed #4238, independently from current dev d6fb87197a. Existing normalized predicate is shared with the account DTO; both preview and real automatic fallback reject excluded plans when no eligible account remains. Explicit account-qualified routes retain normal auth, pause and entitlement checks. Native main remains exempt. + +CLI and dashboard display the policy's routing-plan reason separately from credential health and a possibly newer display-only plan. The automatic Set-as-next action is suppressed for excluded rows because pinning does not bypass this policy; explicit account-qualified routes remain available. All nine UI locale catalogs and eight affected integration guides are synchronized. Source ownership docs link the canonical plan-exclusion contract. + +Regression sources cover all-excluded preview/resolve, renewal, explicit route with pause/reauth, API reasons, CLI normalization and card display/renewal. No new test file or dependency. Local suites/build/typecheck/install: NOT RUN. Hosted CI and rendered preview remain pending. Source searches: isCodexAccountPlanExcluded, getPoolAccountPlan, poolAccountDto, CodexAccountEntry, selection guards and excludedPlans docs; reused the existing predicate rather than a parallel policy. + +Prior callback cycle delivered PR4352 and remains pending hosted verification. This is an independent dev PR, with no callback code and no manual chain dependency. diff --git a/devlog/_plan/260912_accounts/022_eligibility_ci_repair.md b/devlog/_plan/260912_accounts/022_eligibility_ci_repair.md new file mode 100644 index 0000000000..eb33329f4c --- /dev/null +++ b/devlog/_plan/260912_accounts/022_eligibility_ci_repair.md @@ -0,0 +1,7 @@ +# Eligibility hosted regression repair + +Exact-head run34680496052 at a1f24df5ed90848f32d2499303b22620d91eed42 failed in Linux test4/4 job103523074988 and macOS2/2 job103523074889. The reset-ticket source oracle still required the old next-session guard without plan exclusion. The implementation correctly retained all health guards and added plan exclusion. + +The oracle now also requires the plan-exclusion guard, preserving ticket co-render and all health checks. A rendered regression fixture confirms eligible accounts show next-session and tickets together; excluded accounts retain tickets and omit next-session. No production code changed. No assertion was removed or loosened. Local suites/build/typecheck/install NOT RUN; remote final-head verification follows. + +Other failures in these runs concern Cline registry/localization/asset/test-layout and native history restoration. They are recorded in task scratch with exact job logs for owner integration; no other-lane files were changed. The parent-updated branch was fast-forwarded without rebase or merge commit. Hostgoal remains blocked and FSMB is unchanged; no new completed PABCD cycle is claimed for this source repair. diff --git a/devlog/_plan/260912_accounts/048_history_identity.md b/devlog/_plan/260912_accounts/048_history_identity.md new file mode 100644 index 0000000000..d8273fab4e --- /dev/null +++ b/devlog/_plan/260912_accounts/048_history_identity.md @@ -0,0 +1,19 @@ +# Bind quota history to credential publication identity + +New foundation cycle history-identity, C4 credential metadata, before history and capacity. Current source saveCodexAccountCredential publishes a new generation, while normal refresh CAS also increments generation and preserves replacedAt. Neither generation equality nor a millisecond timestamp alone establishes durable quota-history continuity. Reuse the credential store and its mutation lock; no new store or token-derived fingerprint. + +MODIFY src/types/accounts.ts CodexAccountCredentialRecord: optional private quotaHistoryIdentity UUID, not credential material and never projected to API/CLI. MODIFY src/codex/account-store.ts: every explicit save creates a fresh UUID; saveCodexAccountCredentialIfGeneration and commitRefreshedCodexCredentialWithAliases preserve each record's own UUID, including aliases. Deletes retain no old history identity. Existing credential projection excludes metadata automatically. + +Add PoolQuotaWriter type in dependency-free src/codex/quota-types.ts: +```ts +export interface PoolQuotaWriter { accountId: string; credentialGeneration: number; historyIdentity: string } +``` +Add capturePoolQuotaWriter(accountId, dispatched:{accessToken,chatgptAccountId,generation}) in account-store.ts. Under existing withCredentialMutationLockSync, read record and require exact dispatched credential and generation, live/nondeleted state. For a legacy/malformed missing UUID initialize one once and persist under that lock without changing credential generation; do not mint on normal reads. A mismatch returns undefined. Lock/persistence failures at this optional evidence boundary return undefined, never fail the request. Credentials remain transient and never enter returned proof. Existing valid UUID capture needs only read matching record, no mutation lock or rewrite; legacy slow path rechecks under lock. + +Add isPoolQuotaWriterLive(writer): compare current live record's UUID and generation. Add poolQuotaHistoryIdentity(accountId): read valid current UUID only, never initialize or mutate. These separate append admission from retention, which matches UUID across ordinary refresh. Both are narrow production interfaces for the next history layer, not public management capabilities. + +Tests extend existing codex-account-store.test.ts: new saves unique; same-millisecond explicit replacement changes UUID; refresh preserves; alias refresh preserves distinct destination identities; stale dispatched access/generation/account cannot capture; legacy initialization stable and does not advance generation; metadata omitted from getCodexAccountCredential/load compatibility projection; delete/recreate invalidates old writer. Local tests/build/typecheck/install NOT RUN. Hosted cumulative history/capacity tip verifies these regression sources. Source security review separate from runtime proof. + +Field chain: explicit save/legacy capture creates UUID → existing atomic credential record serialization → existing read with UUID validity checked at history boundary → capture/live/retention helpers → next cycle's auth-context/WHAM/header history admission. All explicit record reconstructions are enumerated: save at161, validation spreads186/234 preserve, refresh279/338 preserve, alias366 preserves its own, deletion387 drops. Source ownership docs updated with private metadata semantics. No credential/token/string values enter docs or log output. + +A implementation checks accepted: legacy tag init uses plain persist, preserving both generation and credentialMutationEpoch. UUID validation stays at history boundary; malformed optional metadata never discards usable credentials. Catch read/hardening failures as well as lock/write failures and return no optional proof. Capture excludes the reserved native-main sentinel. If a CAS caller supplies a different upstream account identity, rotate the history UUID instead of treating that as ordinary same-account refresh. diff --git a/devlog/_plan/260912_accounts/049_history_identity_delivery.md b/devlog/_plan/260912_accounts/049_history_identity_delivery.md new file mode 100644 index 0000000000..6af02c6026 --- /dev/null +++ b/devlog/_plan/260912_accounts/049_history_identity_delivery.md @@ -0,0 +1,7 @@ +# Quota history identity foundation + +Adds a private random publication UUID to pool credential records. Explicit saves rotate it, refresh CAS preserves it for the same upstream account, and aliases retain distinct identities. Captured writer proofs require exact dispatched credential generation and access/account pairing; legacy identity initialization occurs under the existing lock without changing the credential generation or mutation epoch. Read/lock/write failure yields no optional proof. Metadata never enters credential-only projection. + +Regression sources cover refresh versus same-time replacement, aliases, deletion/recreation, legacy stable initialization, stale capture, malformed metadata, secret-free proof and identity-changing CAS. The latter rotates owner history and does not propagate the new identity into old aliases. No new test file/dependency. Local suites/build/typecheck/install NOT RUN. Source checks are not runtime proof; hosted final cumulative history/capacity tip remains required. + +Structural decision: proof type stays in quota-types.ts (type-only), credential record/lock/persistence stay in account-store.ts, future pure history leaf consumes plain evidence. Rejected generation-only retention because ordinary refresh increments it; rejected timestamp identity because publication can share a millisecond. The small foundation is the first ordinary manual-chain PR, then history, then capacity. No merge. diff --git a/devlog/_plan/260912_accounts/050_history.md b/devlog/_plan/260912_accounts/050_history.md index fab9e603be..1e870236fc 100644 --- a/devlog/_plan/260912_accounts/050_history.md +++ b/devlog/_plan/260912_accounts/050_history.md @@ -20,4 +20,30 @@ Field chain: creation is guarded quota commit; serialization is existing atomic Reflection REF-04: fixed aggregate bounds: 64 account identities, 4096 rows, 2 MiB serialized history payload and 4 MiB whole cache read bound. During append/hydrate evict oldest observed rows, tie-break account key; prune accounts absent from authoritative roster. Never include dynamic raw account identities in logs. History retains actual per-window provenance (response-header or WHAM where available), reset boundary and window family; partial inherited values do not count. Overlarge/malformed cache read fails to empty history without blocking newest quota. Tests include many-account overflow, byte overflow, deterministic ties and remove/restart. -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 stable configured account identity and stored credential generation, pruning mismatches on hydrate. 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. +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 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. + +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. + +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..0e38a408c8 --- /dev/null +++ b/devlog/_plan/260912_accounts/051_history_delivery.md @@ -0,0 +1,9 @@ +# 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. + +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/devlog/_plan/260912_accounts/060_capacity.md b/devlog/_plan/260912_accounts/060_capacity.md index 931e0b6646..d222802811 100644 --- a/devlog/_plan/260912_accounts/060_capacity.md +++ b/devlog/_plan/260912_accounts/060_capacity.md @@ -2,17 +2,31 @@ Cycle capacity depends on history. Source: `src/usage/log.ts` already persists accountLogLabel, timestamp, reported/estimated usage and per-attempt attribution; `src/codex/account-label.ts` owns safe labels. Use those existing records instead of storing credentials or duplicating request attribution. -NEW `src/codex/quota-capacity.ts`: a pure estimator receives copied raw history and account-attributed reported usage observations. For each short/weekly/monthly window, pair adjacent fresh percentage observations only when reset identity matches, time increases and percentage delta is positive. Sum reported token usage in that interval, count per-attempt records once, exclude estimated/local/unattributed usage and reset/refund crossings. Estimate tokens per full window as observedTokens * 100 / percentageDelta; aggregate defensible intervals with median and report sampleCount plus observed-token lower-bound caveat. No valid interval returns null, never zero or a fabricated capacity. Bounded scan is invoked on management request, never routing; estimation is informational and does not overrule live quota. +NEW `src/codex/quota-capacity.ts`: a pure estimator receives copied raw history and account-attributed reported usage observations. For each short/weekly/monthly window, pair adjacent fresh percentage observations only when reset identity matches, time increases and percentage delta is positive. Sum reported token usage in that interval, count per-attempt records once, exclude estimated/local/unattributed usage and reset/refund crossings. Estimate tokens per full window as observedTokens * 100 / percentageDelta; aggregate defensible intervals with median and report sampleCount plus an explicit low-confidence inference caveat. No valid interval returns null, never zero or a fabricated capacity. Bounded scan is invoked on management request, never routing; estimation is informational and does not overrule live quota. ```ts export type CodexCapacityEstimate = { window: "short" | "weekly" | "monthly"; estimatedTokens: number; sampleCount: number; - confidence: "observed-lower-bound"; + confidence: "low"; }; ``` MODIFY history read API/CLI projection to attach per-window estimates with sample count and caveat; expose an existing account-card detail surface only if it can be honestly rendered and verified. Field chain: pure estimator creation; API JSON serialization; existing typed CLI/client deserialization; explicit informational display consumers. No persisted estimate schema needed. Tests feed independently hand-calculated intervals, 0% delta, reset rollover, missing timestamps/identity, cross-account records, retries, estimated usage, and extreme numeric input. Sync quota/usage ownership docs and user configuration guidance. Full closure of #3376 requires both history and meaningful capacity; reset-first alone stays partial. Local suites NOT RUN; hosted final cumulative tip is the verifier. A2 accepted: use readUsageSnapshotForManagement; if truncatedPrefixBytes>0, entriesTruncated, entriesDropped>0, missing revision, or invalid timing then return insufficient-evidence with no estimate. Treat each request as interval [timestamp, timestamp+durationMs] (request-log.ts:1039/1072); include only requests wholly contained in a quota-observation interval. Boundary-spanning requests contribute nothing. For included requests count reported physical attempts matching the exact pool label once; do not count both request total and attempts. Without attempts accept request-level reported usage only with matching label and no recovery ambiguity. Native main is excluded from token capacity because its historical label cannot establish identity after replacement. Current pool logLabel must be unique; legacy fallback labels/id reuse require insufficient evidence unless continuity is proven by history generation. Same-reset positive deltas only. Hand-worked boundary-spanning, truncation, missing identity and retry rows are mandatory regression fixtures. + +P future refinement from history sidecar: do not call estimate a mathematical lower bound. It is an observed effective token estimate under rounded/delayed quota and local coverage assumptions. Admit only single-send reported nonestimated attempts; present-but-empty attempt arrays cannot fall back to parent totals. Deduplicate requestId+ordinal and reject conflicting duplicates. Use interval (left,right] with whole request containment to avoid zero-duration double counting. Existing parser can skip malformed rows without a rejected counter: report retained-valid-ledger-only assumption explicitly or add rejected-row metadata before claiming complete coverage. Loglabel alone is not history identity; history publication UUID and current stable unique configured label must bind sample period. All source tests remain hosted-only. + +## Resumed capacity contract + +Depends on history PR4404/0d98205fcd. Add pure quota-capacity.ts estimator receiving public sanitized observations, validated usage rows and the current explicit unique random pool logLabel; no native-main/fallback labels. Per account short/weekly/monthly, pair adjacent raw observations only with same source/reset boundary, increasing localtime and percentage delta>=1. Count only whole requests within (left,right], single-send reported nonestimated nonlocal attempts matching that label. Presence of an empty attempts array never falls back to request totals. Deduplicate requestId; conflicting duplicates yield insufficient evidence. No inferred absolute attempt start. Exclude boundary-spanning requests and unknown/multisend usage; no valid pair yields insufficient-evidence. + +Use reported totalTokens or input+output exactly once, not reasoning/cache detail additions. Median effective tokens per100percentage over defensible intervals, sampleCount explicit. Output confidence low and assumptions array: rounded/delayed quota, only retained valid proxy ledger rows, label continuity assumed inside the observation interval, external usage not observed. This is an observed effective estimate, never a provider token limit or proven lower bound. The private credential publication UUID must match before/after async ledger read; current explicit logLabel and uniqueness must still match config. Any mismatch yields insufficient-evidence, not mixed identity. No estimate is used for scheduling. + +Extend existing history GET result with capacity:{status:estimated|insufficient-evidence,estimates:[{window,estimatedTokens,sampleCount,confidence:low}],reason?,assumptions}. Cached history remains visible on ledger read failure. Use readUsageSnapshotForManagement; reject truncatedPrefixBytes/entriesTruncated/entriesDropped, missingrevision and >10000 retainedrows before estimator scan. This deliberately does not attest missing/rejected historical ledger lines; assumptions state that limitation. CLI history humanoutput renders estimates and sample counts/caveat; JSON carries fullobject. No new config, timer, persistence, GUI surface or inference call. + +Tests handcomputed10→20% plus1000reportedtokens→10000estimate; mixed sources/reset/refund/0delta/rounding/timestampintervals, duplicate request IDs, absent-vs-empty attempts, multisend, local/estimated/unattributed tokens, nonfiniteoutput, truncatedledger andidentitychangedawait. Sample storage/read provides current publication evidence; retrospective label continuity is explicitly low-confidence inference, not independently verified identity. This clarification replaces earlier mathematically unprovable lower-bound wording without reducing raw-data/identity fences. Local suites/build/typecheck/install NOTRUN. Independent source design/review plus final cumulative tip hostedCI required; hostFSMblockedB remains unchanged. + +Implementation refinements: reject absent physicalattempts, deduplicate ordinals, countonlyaccountfamily/sharedmodelscope and matchingwindowduration/primaryprovenance withresetnotelapsed. Preserve locallyAnswered duringexistingusagenormalization so capacitycanexcludeit. CaptureUUIDbeforehistoryread and recheckbefore/afterasyncledgerread; usefullboundedhistory forestimationindependentofdisplaylimit. Labels re-read fromcurrentruntimeconfig. diff --git a/devlog/_plan/260912_accounts/061_capacity_delivery.md b/devlog/_plan/260912_accounts/061_capacity_delivery.md new file mode 100644 index 0000000000..da434e3e12 --- /dev/null +++ b/devlog/_plan/260912_accounts/061_capacity_delivery.md @@ -0,0 +1,7 @@ +# Informational effective quota capacity + +This child of #4404 estimates observed reported tokens per100percentage from bounded raw observation intervals. It preserves private publication UUID checks and requires an explicit unique pool log label. The estimate is low-confidence with disclosed rounding, retained-valid-row, external-usage and label-continuity assumptions; it is not a provider limit or scheduling policy. + +Regression sources cover a hand-computed1000tokens/10points=10000, duplicates, single-send evidence, provenance/reset/interval/independent-model conditions, numeric overflow, bounded ledger rejection, populated API/CLI output and identity replacement during async usage read. Existing local-answer provenance now survives attempt normalization. No local suite/build/typecheck/install was run. Independent design source audit passed; implementation source review and final cumulative hostedCI remain pending. Actual hostgoal blocked/FSMB untouched; no persisted capacity PABCD cycle is claimed. + +Source review corrections: API accepts only explicit shared quota scope, excluding blank/undefined model identity through an actual populated API regression. CLI prints insufficient-evidence reasons through the closed reason parser, with estimated/insufficient human+JSON fixtures. A positive fraction that rounds to zero yields no estimate. Local suites remain NOTRUN. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 57b5b63fde..4f07ffaced 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -399,8 +399,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' C'est une politique de sélection, pas un blocage. Un compte écarté conserve ses identifiants, son historique de quota et son affinité de thread, reste visible dans la liste des comptes et demeure joignable par sélection explicite comme `work/gpt-5.5`. Seule la rotation automatique cesse de le choisir, y compris lorsqu'il est déjà le compte actif ou déjà lié à un thread — l'état exact que laisse un abonnement expiré. -Deux limites volontaires. Le compte Codex principal n'est jamais écarté par forfait, car le routage en mode sélection seule ne lit pas son forfait dans les identifiants natifs protégés ; une règle le couvrant se contredirait. Et lorsqu'il ne reste aucun compte non écarté, le compte écarté répond quand même au lieu d'échouer : mettre tous les comptes en pause reste le moyen d'arrêter complètement le service. Il n'existe pas de `minimumPlan`, car classer les forfaits ChatGPT entre eux exige un ordre total qui n'existe pas ici. - +Le compte Codex principal reste exempt de l’exclusion par forfait : le routage en mode sélection seule ne lit pas ses identifiants natifs protégés. Si tous les comptes éligibles du pool sont exclus, la sélection automatique ne renvoie aucun compte. Les routes désignant explicitement un compte restent disponibles, avec les contrôles de pause, d’authentification et de droits du modèle. La carte et le CLI affichent le forfait exclu séparément de l’état des identifiants. Il n’existe pas de réglage `minimumPlan`, faute d’ordre total des forfaits. ## Restauration de Codex natif `ocx stop` arrête le proxy et le service d'arrière-plan installé, puis tente de restaurer Codex natif. OpenCodex retire les éléments de routage dont il peut vérifier la propriété et signale une restauration incomplète si les fichiers de configuration ne peuvent pas être récupérés en toute sécurité. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index ed495de3be..ef237a3be2 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -820,8 +820,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' This is a selection policy, not a block. An excluded account keeps its credential, quota history, and thread affinity, stays visible on the account surface, and is still reachable by explicit account selection such as `work/gpt-5.5`. What changes is that automatic rotation stops choosing it, including when it is already the active account or already bound to a thread — which is the state a lapsed subscription leaves behind. -Two deliberate limits. The main Codex account is never excluded by plan, because selection-only routing withholds its plan rather than reading the fenced native credential, so a rule covering it would disagree with itself. And when no unexcluded account remains, the excluded one still answers rather than failing closed; pausing every account is still the way to stop serving entirely. There is no `minimumPlan` counterpart, because ranking ChatGPT plans against each other needs a total ordering that does not exist here. - +The main Codex account remains exempt from plan exclusion; selection-only routing does not read its fenced native credential. If every eligible pool account is excluded, automatic selection returns no account. Explicit account-qualified routes remain available and still enforce pause, authentication and model entitlement. The account card and CLI show the excluded routing plan separately from credential health. There is no `minimumPlan` setting because the plan names do not define a total order. ## Restoring native Codex `ocx stop` stops the proxy and any installed background service, then attempts to restore native Codex. OpenCodex removes verified routing artifacts and reports an incomplete restore when it cannot safely recover configuration files. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 5425e9e193..e621f1fd40 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -265,8 +265,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' これはブロックではなく選択ポリシーです。除外されたアカウントも資格情報・使用量履歴・スレッドアフィニティを保持し、アカウント一覧に表示され、`work/gpt-5.5` のような明示的な指定では引き続き利用できます。変わるのは自動ローテーションが選ばなくなる点で、すでにアクティブなアカウントやスレッドに紐づいている場合も含みます。サブスクリプションが失効した直後は、まさにその状態です。 -意図的な制限が2つあります。メインの Codex アカウントはプランによって除外されません。選択のみのルーティングは保護されたネイティブ資格情報を読まずにプランを伏せるため、メインを対象にすると挙動が食い違うからです。また、除外されていないアカウントが1つも残らない場合は、失敗させずに除外済みのアカウントが応答します。完全に停止したい場合は従来どおり全アカウントを一時停止してください。`minimumPlan` に相当する設定はありません。ChatGPT のプランを順位付けするには、ここに存在しない全順序が必要になるためです。 - +メイン Codex アカウントはプラン除外の対象外です。選択のみのルーティングは保護されたネイティブ資格情報を読みません。利用可能なプールアカウントがすべて除外されると、自動選択はアカウントを返しません。アカウントを明示したルートは引き続き利用でき、一時停止・認証・モデル権限の検査は維持されます。カードと CLI は資格情報の状態とは別に、除外されたルーティングプランを表示します。プランに全順序がないため `minimumPlan` 設定はありません。 ## ネイティブ Codexの復元 `ocx stop` はプロキシとインストール済みのバックグラウンドサービスを停止し、ネイティブ Codex の復元を試みます。OpenCodex は所有を確認できるルーティング設定を削除し、設定ファイルを安全に復元できない場合は未完了として報告します。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index f0c9d87848..88675c21fc 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -361,8 +361,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 차단이 아니라 선택 정책입니다. 제외된 계정도 자격 증명과 사용량 기록, 스레드 어피니티를 그대로 유지하고 계정 목록에도 계속 보이며 `work/gpt-5.5` 같은 명시적 지정으로는 여전히 쓸 수 있습니다. 달라지는 것은 자동 로테이션이 그 계정을 고르지 않는다는 점이고, 이미 활성 계정이거나 스레드에 묶여 있는 경우도 포함합니다. 구독이 만료된 계정이 바로 그 상태입니다. -의도한 제한이 두 가지 있습니다. 메인 Codex 계정은 플랜으로 제외하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않고 플랜을 감추기 때문에, 메인까지 적용하면 상황에 따라 판정이 어긋납니다. 그리고 제외되지 않은 계정이 하나도 남지 않으면 실패시키지 않고 제외된 계정이 그대로 응답합니다. 완전히 멈추려면 지금처럼 모든 계정을 일시 중지하면 됩니다. `minimumPlan`에 해당하는 설정은 없습니다. ChatGPT 플랜에 순위를 매기려면 여기 존재하지 않는 전순서가 필요합니다. - +메인 Codex 계정에는 플랜 제외 정책을 적용하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않습니다. 풀의 모든 사용 가능한 계정이 제외되면 자동으로 계정을 선택하지 않습니다. 계정을 직접 지정한 경로는 계속 사용할 수 있으며 일시 중지·인증·모델 사용 권한 검사는 그대로 적용됩니다. 계정 카드와 CLI에는 자격 증명 상태와 별도로 제외된 플랜이 표시됩니다. 플랜 사이에 정해진 순위가 없으므로 `minimumPlan` 설정은 없습니다. ## 네이티브 Codex 복원 `ocx stop`은 proxy와 설치된 background service를 중지한 뒤 네이티브 Codex 복원을 시도합니다. OpenCodex 소유로 확인된 라우팅 항목을 제거하며, 설정 파일을 안전하게 복구할 수 없으면 미완료로 보고합니다. 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..bbed36cb4b 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,11 @@ 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을 뜻하지 않습니다. 이 명령은 쿼터를 소비하지 않습니다. 관측을 바탕으로 한 용량 추정에는 아래 한계가 적용됩니다. + +같은 초기화 구간의 관측과 계정별 사용 기록이 있으면 보고된 토큰 기준 용량 추정도 표시합니다. 표본 수와 낮은 신뢰도를 함께 표시하며, 쿼터 반올림·외부 사용량·로그 라벨 유지 여부 때문에 제공자의 실제 토큰 한도와 다를 수 있습니다. 기록이 없거나 잘렸으면 근거 부족으로 표시합니다. `--limit`은 표시할 기록 수만 제한하며 추정 입력은 전체 보관 범위입니다. 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..30f289551d 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,11 @@ 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 spend quota. Effective estimates, when supported by observations, carry the limitations below. + +The history output also includes effective reported-token estimates when same-window observations and attributable usage support them. Each estimate includes a sample count and low confidence. Quota rounding, external usage and assumed log-label continuity limit the inference; it is not your provider’s token allowance. Missing or truncated ledger evidence returns insufficient evidence. `--limit` controls displayed history, not the bounded estimate input. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index d2f135be2f..2a2087c994 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -392,8 +392,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' Это политика выбора, а не блокировка. Исключённый аккаунт сохраняет учётные данные, историю квот и привязку к треду, остаётся видимым в списке и по-прежнему доступен при явном выборе вроде `work/gpt-5.5`. Меняется только то, что автоматическая ротация перестаёт его выбирать — в том числе когда он уже активен или уже привязан к треду, а именно это состояние остаётся после истёкшей подписки. -Два намеренных ограничения. Основной аккаунт Codex никогда не исключается по тарифу: маршрутизация в режиме «только выбор» скрывает его тариф, чтобы не читать защищённые нативные учётные данные, и правило для него противоречило бы само себе. А если не осталось ни одного неисключённого аккаунта, исключённый всё равно отвечает вместо отказа; чтобы остановить обслуживание полностью, по-прежнему нужно поставить на паузу все аккаунты. Аналога `minimumPlan` нет: чтобы ранжировать тарифы ChatGPT, нужен полный порядок, которого здесь не существует. - +Основной аккаунт Codex не исключается по тарифу: маршрутизация только для выбора не читает защищённые нативные учётные данные. Если все доступные аккаунты пула исключены, автоматический выбор не возвращает аккаунт. Явные маршруты к аккаунту доступны, но проверки паузы, аутентификации и прав на модель сохраняются. Карточка и CLI показывают исключённый тариф отдельно от состояния учётных данных. Настройки `minimumPlan` нет, поскольку тарифы не имеют полного порядка. ## Восстановление нативного Codex `ocx stop` останавливает прокси и установленную фоновую службу, затем пытается восстановить нативный Codex. OpenCodex удаляет настройки маршрутизации, принадлежность которых может подтвердить, и сообщает о неполном восстановлении, если файлы конфигурации нельзя безопасно восстановить. diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index a601a524c6..a36594e577 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -449,8 +449,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' Bu bir engelleme değil, seçim politikasıdır. Dışarıda bırakılan hesap kimlik bilgisini, kota geçmişini ve iş parçacığı bağını korur, hesap listesinde görünmeye devam eder ve `work/gpt-5.5` gibi açık bir seçimle hâlâ erişilebilir. Değişen tek şey, otomatik rotasyonun onu artık seçmemesidir; hesap zaten etkin olsa ya da bir iş parçacığına bağlı olsa bile. Süresi dolan bir abonelik tam olarak bu durumu bırakır. -İki kasıtlı sınır var. Ana Codex hesabı plana göre hiçbir zaman dışarıda bırakılmaz: yalnızca-seçim yönlendirmesi korunan yerel kimlik bilgisini okumamak için planını saklar, dolayısıyla ana hesabı kapsayan bir kural kendisiyle çelişirdi. Ayrıca dışarıda bırakılmamış hiçbir hesap kalmadığında, dışarıda bırakılan hesap başarısız olmak yerine yine yanıt verir; hizmeti tamamen durdurmak için hâlâ tüm hesapları duraklatmak gerekir. `minimumPlan` karşılığı yoktur, çünkü ChatGPT planlarını sıralamak burada bulunmayan bir tam sıralama gerektirir. - +Ana Codex hesabı plan hariç tutma politikasından muaftır; yalnızca seçim yapan yönlendirme korunan yerel kimlik bilgilerini okumaz. Kullanılabilir tüm havuz hesapları hariç tutulursa otomatik seçim hesap döndürmez. Açıkça hesap belirten yollar kullanılabilir; duraklatma, kimlik doğrulama ve model yetkisi denetimleri korunur. Hesap kartı ve CLI, hariç tutulan yönlendirme planını kimlik bilgisi durumundan ayrı gösterir. Planların tam sıralaması olmadığından `minimumPlan` ayarı yoktur. ## Yerel Codex'i geri yükleme `ocx stop`, proxy'yi ve kurulu arka plan servisini durdurur, ardından yerel Codex'i geri yüklemeyi dener. OpenCodex yalnızca sahipliğini doğrulayabildiği yönlendirme öğelerini kaldırır; yapılandırma dosyaları güvenle geri yüklenemiyorsa işlemin tamamlanmadığını bildirir. diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 52c32ba2da..b19d35b00c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -336,8 +336,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 这是选择策略,不是封禁。被排除的账号保留凭据、用量历史和线程亲和性,仍显示在账号列表中,也仍可通过 `work/gpt-5.5` 这类显式选择使用。改变的只是自动轮换不再选它,包括它已经是活跃账号或已绑定线程的情况——订阅到期后留下的正是这种状态。 -有两处刻意的限制。主 Codex 账号不会因套餐被排除:仅选择模式的路由不读取受保护的原生凭据而隐去其套餐,覆盖主账号的规则会自相矛盾。另外,当没有未被排除的账号时,被排除的账号仍会应答而不是失败;要彻底停止服务,仍然是暂停全部账号。没有对应的 `minimumPlan`,因为给 ChatGPT 套餐排序需要一个这里并不存在的全序。 - +主 Codex 账号不受套餐排除策略影响;仅选择模式不会读取受保护的原生凭据。如果所有可用的池账号都被排除,自动选择不返回账号。明确指定账号的路由仍可使用,并继续检查暂停、认证和模型权限。账号卡片与 CLI 将被排除的路由套餐与凭据健康状态分开显示。套餐没有全序关系,因此不提供 `minimumPlan` 设置。 ## 恢复原生 Codex `ocx stop` 会停止 proxy 和已安装的后台服务,然后尝试恢复原生 Codex。OpenCodex 只移除能够确认归属的路由配置;如果无法安全恢复配置文件,会报告恢复未完成。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 0bf6a37426..38744c8e13 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -343,8 +343,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 這是選擇策略,不是封鎖。被排除的帳號保留憑證、用量紀錄與執行緒親和性,仍顯示在帳號清單中,也仍可透過 `work/gpt-5.5` 這類明確選擇使用。改變的只是自動輪換不再挑它,包括它已經是使用中帳號或已綁定執行緒的情況——訂閱到期後留下的正是這種狀態。 -有兩處刻意的限制。主 Codex 帳號不會因方案被排除:僅選擇模式的路由不讀取受保護的原生憑證而隱去其方案,涵蓋主帳號的規則會自相矛盾。此外,當沒有未被排除的帳號時,被排除的帳號仍會回應而不是失敗;要完全停止服務,仍然是暫停所有帳號。沒有對應的 `minimumPlan`,因為為 ChatGPT 方案排序需要一個這裡並不存在的全序。 - +主 Codex 帳號不受方案排除策略影響;僅選擇模式不會讀取受保護的原生憑證。如果所有可用的池帳號都被排除,自動選取不會回傳帳號。明確指定帳號的路由仍可使用,並繼續檢查暫停、認證及模型權限。帳號卡片與 CLI 將被排除的路由方案與憑證健康狀態分開顯示。方案沒有全序關係,因此不提供 `minimumPlan` 設定。 ## 恢復原生 Codex `ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。 diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 619ec2ecf2..72da4b774c 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -78,6 +78,7 @@ export function CodexAccountPoolCards({ <> {pool.map(a => { const healthStatus = a.health?.status; + const planExcluded = a.selectionExcludedReason === "plan_excluded"; const showReauth = Boolean(a.needsReauth) || oauthHealthShowsReauth(healthStatus); const inCooldown = oauthHealthIsCooldown(healthStatus); const validationPending = a.health?.reason === "validation_pending"; @@ -90,6 +91,11 @@ export function CodexAccountPoolCards({ {a.alias ?? a.email} {a.plan && {a.plan}} + {planExcluded && ( + + {t("codexAuth.planExcluded")} + + )} {a.paused && ( {t("codexAuth.paused")} @@ -102,13 +108,13 @@ export function CodexAccountPoolCards({ {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {isNext(a) && !showReauth && !inCooldown && !validationPending && ( + {isNext(a) && !planExcluded && !showReauth && !inCooldown && !validationPending && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!a.paused && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && ( + {!a.paused && !planExcluded && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && ( diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 2d909b29bd..87aca93743 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -54,6 +54,8 @@ export interface CodexAccountEntry { }; mainAccountHardLock?: MainAccountHardLockStatus; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string }; healthLabel?: string; healthSummary?: string; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4a7833a0b4..eda9dca6c1 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1361,6 +1361,8 @@ export const de: Record = { "codexAuth.pause": "Pausieren", "codexAuth.resume": "Fortsetzen", "codexAuth.paused": "PAUSIERT", + "codexAuth.planExcluded": "Nicht automatisch gewählt", + "codexAuth.planExcludedHint": "Tarif {plan} ist von der automatischen Auswahl ausgeschlossen. Explizite Kontorouten bleiben verfügbar.", "codexAuth.pauseSucceeded": "{email} ist pausiert", "codexAuth.resumeSucceeded": "{email} ist wieder im Pool verfügbar", "codexAuth.pauseFailed": "{email} konnte nicht pausiert werden. Es wurde nichts geändert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 96e30de557..2ee4fa915d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1937,6 +1937,8 @@ export const en = { "codexAuth.pause": "Pause", "codexAuth.resume": "Resume", "codexAuth.paused": "PAUSED", + "codexAuth.planExcluded": "Not auto-selected", + "codexAuth.planExcludedHint": "Plan {plan} is excluded from automatic selection. Explicit account routes remain available.", "codexAuth.pauseSucceeded": "{email} is paused", "codexAuth.resumeSucceeded": "{email} is available to the pool again", "codexAuth.pauseFailed": "Could not pause {email}. Nothing was changed.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 0dc86dbaae..7ae74589b3 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1869,6 +1869,8 @@ export const fr: Record = { "codexAuth.pause": "Suspendre", "codexAuth.resume": "Reprendre", "codexAuth.paused": "SUSPENDU", + "codexAuth.planExcluded": "Exclu du choix automatique", + "codexAuth.planExcludedHint": "Le forfait {plan} est exclu de la sélection automatique. Les routes explicites vers ce compte restent disponibles.", "codexAuth.pauseSucceeded": "{email} est suspendu", "codexAuth.resumeSucceeded": "{email} est de nouveau disponible dans le groupe", "codexAuth.pauseFailed": "Impossible de suspendre {email}. Aucune modification apportée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 97b5dff281..bc9454f660 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1794,6 +1794,8 @@ export const ja: Record = { "codexAuth.pause": "一時停止", "codexAuth.resume": "再開", "codexAuth.paused": "一時停止中", + "codexAuth.planExcluded": "自動選択の対象外", + "codexAuth.planExcludedHint": "プラン {plan} は自動選択の対象外です。アカウントを明示的に指定すると利用できます。", "codexAuth.pauseSucceeded": "{email} を一時停止しました", "codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました", "codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 0957274d6b..266a9441ff 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1397,6 +1397,8 @@ export const ko: Record = { "codexAuth.pause": "일시 중지", "codexAuth.resume": "재개", "codexAuth.paused": "일시 중지됨", + "codexAuth.planExcluded": "자동 선택 제외", + "codexAuth.planExcludedHint": "{plan} 플랜은 자동 선택에서 제외됩니다. 계정을 직접 지정하면 사용할 수 있습니다.", "codexAuth.pauseSucceeded": "{email} 계정을 일시 중지했습니다", "codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다", "codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 464c71d7ac..6fe8cc8105 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1864,6 +1864,8 @@ export const ru: Record = { "codexAuth.pause": "Приостановить", "codexAuth.resume": "Возобновить", "codexAuth.paused": "ПРИОСТАНОВЛЕН", + "codexAuth.planExcluded": "Не выбирается автоматически", + "codexAuth.planExcludedHint": "Тариф {plan} исключён из автоматического выбора. Явная маршрутизация на аккаунт доступна.", "codexAuth.pauseSucceeded": "Аккаунт {email} приостановлен", "codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле", "codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 3e3c12358f..a9b6db53aa 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1894,6 +1894,8 @@ export const tr: Record = { "codexAuth.pause": "Duraklat", "codexAuth.resume": "Devam Ettir", "codexAuth.paused": "DURAKLATILDI", + "codexAuth.planExcluded": "Otomatik seçilmez", + "codexAuth.planExcludedHint": "{plan} planı otomatik seçimden hariç tutulur. Açık hesap yönlendirmeleri kullanılabilir.", "codexAuth.pauseSucceeded": "{email} duraklatıldı", "codexAuth.resumeSucceeded": "{email} tekrar havuza alındı", "codexAuth.pauseFailed": "{email} duraklatılamadı.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a0d0767dc3..13795edd07 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1482,6 +1482,8 @@ export const zhTW: Record = { "codexAuth.pause": "暫停", "codexAuth.resume": "恢復", "codexAuth.paused": "已暫停", + "codexAuth.planExcluded": "不自動選取", + "codexAuth.planExcludedHint": "方案 {plan} 已排除自動選取。仍可明確指定此帳號。", "codexAuth.pauseSucceeded": "已暫停 {email}", "codexAuth.resumeSucceeded": "{email} 已重新加入帳號池", "codexAuth.pauseFailed": "無法暫停 {email},未做任何變更。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index da66e4f8da..2fc2fd504c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1378,6 +1378,8 @@ export const zh: Record = { "codexAuth.pause": "暂停", "codexAuth.resume": "恢复", "codexAuth.paused": "已暂停", + "codexAuth.planExcluded": "不自动选择", + "codexAuth.planExcludedHint": "套餐 {plan} 已从自动选择中排除。仍可明确指定此账号。", "codexAuth.pauseSucceeded": "已暂停 {email}", "codexAuth.resumeSucceeded": "{email} 已重新加入账号池", "codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。", diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx index 9702d7aff9..c622a5f793 100644 --- a/gui/tests/codex-account-pool-pinned-badge.test.tsx +++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx @@ -280,3 +280,40 @@ test("healthy account cards omit log-label and 30-day usage copy", async () => { expect(main.textContent).not.toContain("Log label: main"); expect(hasPinnedHint(main)).toBe(false); }); + + +test("plan exclusion is visible without presenting the account as the next automatic selection", async () => { + await mountPool(makeController({ + accounts: [mainAccount, { ...account, plan: "plus", selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free" }], + activeId: account.id, + })); + const card = cardFor(account.email); + const excluded = [...card.querySelectorAll(".badge")].find(el => el.textContent === en["codexAuth.planExcluded"]); + expect(excluded).toBeTruthy(); + expect(excluded!.getAttribute("title")).toContain("free"); + expect([...card.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(false); + expect(card.textContent).not.toContain(en["codexAuth.paused"]); + expect(switchAction(card)).toBeNull(); + await act(async () => { + root!.render(); + }); + expect(cardFor(account.email).textContent).not.toContain(en["codexAuth.planExcluded"]); +}); + + +test("eligible next-session badge coexists with reset tickets while plan exclusion only removes selection", async () => { + const eligible = { ...account, plan: "plus", quota: { weeklyPercent: 10, resetCredits: 2, updatedAt: Date.now() } }; + await mountPool(makeController({ accounts: [mainAccount, eligible], activeId: eligible.id })); + const current = cardFor(account.email); + expect([...current.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(true); + expect(current.querySelector(".badge-clickable")).not.toBeNull(); + await act(async () => { + root!.render(); + }); + const excluded = cardFor(account.email); + expect([...excluded.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(false); + expect(excluded.querySelector(".badge-clickable")).not.toBeNull(); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4f890cba99..82e06531e1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -488,6 +488,8 @@ "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-capacity.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-api.ts b/src/cli/account-api.ts index e0e573e251..c734f7eb85 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -26,6 +26,8 @@ export interface AccountRow { masked?: string; active: boolean; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; /** Registered credential that is still excluded from routing until validation completes. */ validationPending?: boolean; /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */ @@ -243,6 +245,8 @@ interface CodexAccountDto { plan?: string; isMain?: boolean; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; health?: { reason?: string }; priority?: number; quota?: CodexQuotaDto | null; @@ -309,6 +313,10 @@ export async function fetchCodexRows( plan: a.plan, active: a.id === activeId, needsReauth: a.needsReauth, + ...(a.selectionExcludedReason === "plan_excluded" ? { + selectionExcludedReason: "plan_excluded" as const, + ...(typeof a.selectionExcludedPlan === "string" ? { selectionExcludedPlan: a.selectionExcludedPlan } : {}), + } : {}), ...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}), priority: typeof a.priority === "number" ? a.priority : 0, paused: a.paused === true, diff --git a/src/cli/account-history.ts b/src/cli/account-history.ts new file mode 100644 index 0000000000..9534cf30fd --- /dev/null +++ b/src/cli/account-history.ts @@ -0,0 +1,60 @@ +import { parseCapacityReason } from "../codex/quota-capacity"; +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; + 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(`${historyDate(observation.observedAt)}\t${observation.source}\t${window.family}/${window.window}\t${window.usedPercent}%\t${historyDate(window.resetAtMs)}`); + } + } + const capacity = result.json.capacity; + if (capacity && typeof capacity === "object" && "status" in capacity && capacity.status === "estimated" + && "estimates" in capacity && Array.isArray(capacity.estimates)) { + console.log("Effective capacity estimate (low confidence; not a provider token limit):"); + for (const estimate of capacity.estimates) { + if (estimate && Number.isFinite(estimate.estimatedTokens) && Number.isSafeInteger(estimate.sampleCount)) { + console.log(`${estimate.window}\t~${estimate.estimatedTokens} reported tokens / 100%\t${estimate.sampleCount} samples`); + } + } + } + if (capacity && typeof capacity === "object" && "status" in capacity && capacity.status === "insufficient-evidence") { + const reason = "reason" in capacity ? parseCapacityReason(capacity.reason) : undefined; + console.log(`Effective capacity: insufficient evidence${reason ? ` (${reason})` : ""}.`); + } + return 0; +} diff --git a/src/cli/account.ts b/src/cli/account.ts index 4a8c6a0427..7336944aa7 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] @@ -101,6 +102,9 @@ function statusText(row: AccountRow): string { if (row.active) parts.push(row.type === "codex" ? "selected" : "active"); if (row.needsReauth) parts.push("needs-reauth"); if (row.validationPending) parts.push("validation-pending"); + if (row.selectionExcludedReason === "plan_excluded") { + parts.push(`not-auto-selected(plan=${row.selectionExcludedPlan ?? row.plan ?? "unknown"})`); + } return parts.join(" "); } @@ -335,6 +339,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/account-store.ts b/src/codex/account-store.ts index 285db1708c..4b151707a1 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -13,6 +13,8 @@ import { import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types"; import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; +import { isValidCodexAccountId } from "./account-id"; +import type { PoolQuotaWriter } from "./quota-types"; import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing"; type LegacyCodexAccountStore = Record; @@ -163,6 +165,7 @@ export function saveCodexAccountCredential( generation: (current?.generation ?? 0) + 1, refreshGrantFingerprint, replacedAt: current ? Date.now() : undefined, + quotaHistoryIdentity: crypto.randomUUID(), ...preservedValidationMetadata(current), ...(options.validationPending ? { codexValidationPending: true, @@ -257,6 +260,75 @@ export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord return loadCodexAccountRecordStore()[id] ?? null; } +const QUOTA_HISTORY_IDENTITY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function validQuotaHistoryIdentity(value: unknown): value is string { + return typeof value === "string" && QUOTA_HISTORY_IDENTITY_RE.test(value); +} + +type DispatchedPoolCredential = Pick & { generation: number }; + +function matchesDispatchedPoolCredential(record: CodexAccountCredentialRecord | undefined | null, dispatched: DispatchedPoolCredential): record is CodexAccountCredentialRecord & { credential: CodexAccountCredentials } { + return !!record?.credential && record.deletedAt == null + && dispatched.accessToken.length > 0 && dispatched.chatgptAccountId.length > 0 + && Number.isSafeInteger(dispatched.generation) && dispatched.generation >= 0 + && record.generation === dispatched.generation + && record.credential.accessToken === dispatched.accessToken + && record.credential.chatgptAccountId === dispatched.chatgptAccountId; +} + +/** Optional evidence capture; a stale credential or unavailable store never gains a new writer. */ +export function capturePoolQuotaWriter(accountId: string, dispatched: DispatchedPoolCredential): PoolQuotaWriter | undefined { + if (!isValidCodexAccountId(accountId)) return undefined; + try { + const current = readCodexAccountRecord(accountId); + if (!matchesDispatchedPoolCredential(current, dispatched)) return undefined; + if (validQuotaHistoryIdentity(current.quotaHistoryIdentity)) { + return { accountId, credentialGeneration: dispatched.generation, historyIdentity: current.quotaHistoryIdentity }; + } + return withCredentialMutationLockSync(() => { + const store = loadCodexAccountRecordStore(); + const locked = store[accountId]; + if (!matchesDispatchedPoolCredential(locked, dispatched)) return undefined; + if (!validQuotaHistoryIdentity(locked.quotaHistoryIdentity)) { + locked.quotaHistoryIdentity = crypto.randomUUID(); + // Identity metadata is not a new credential; preserve generation and mutation epoch. + persist(store); + } + return { accountId, credentialGeneration: dispatched.generation, historyIdentity: locked.quotaHistoryIdentity }; + }); + } catch { + // History is optional evidence. Permission, lock and disk errors cannot fail inference. + return undefined; + } +} + +/** Read-only retention identity; unlike capture this never initializes legacy metadata. */ +export function poolQuotaHistoryIdentity(accountId: string): string | undefined { + if (!isValidCodexAccountId(accountId)) return undefined; + try { + const record = readCodexAccountRecord(accountId); + return record?.credential && record.deletedAt == null && validQuotaHistoryIdentity(record.quotaHistoryIdentity) + ? record.quotaHistoryIdentity : undefined; + } catch { + return undefined; + } +} + +/** Recheck append admission after upstream I/O; refresh may retire a writer without erasing history. */ +export function isPoolQuotaWriterLive(writer: PoolQuotaWriter): boolean { + if (!isValidCodexAccountId(writer.accountId)) return false; + try { + const record = readCodexAccountRecord(writer.accountId); + return !!record?.credential && record.deletedAt == null + && record.generation === writer.credentialGeneration + && validQuotaHistoryIdentity(writer.historyIdentity) + && record.quotaHistoryIdentity === writer.historyIdentity; + } catch { + return false; + } +} + export function isCodexAccountGenerationLive(id: string, generation: number): boolean { const record = readCodexAccountRecord(id); return !!record?.credential && record.deletedAt == null && record.generation === generation; @@ -281,6 +353,8 @@ export function saveCodexAccountCredentialIfGeneration( generation: generation + 1, refreshGrantFingerprint, replacedAt: current.replacedAt, + quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId + ? current.quotaHistoryIdentity : crypto.randomUUID(), ...preservedValidationMetadata(current), }; persistCredentialMutation(store); @@ -340,6 +414,8 @@ export function commitRefreshedCodexCredentialWithAliases( generation: generation + 1, refreshGrantFingerprint, replacedAt: current.replacedAt, + quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId + ? current.quotaHistoryIdentity : crypto.randomUUID(), ...preservedValidationMetadata(current), }; @@ -354,6 +430,7 @@ export function commitRefreshedCodexCredentialWithAliases( priorFingerprint !== undefined && priorCredential.refreshToken !== cred.refreshToken && !!priorCredential.chatgptAccountId + && priorCredential.chatgptAccountId === cred.chatgptAccountId ) { for (const [aliasId, alias] of Object.entries(store)) { if (aliasId === id || alias.deletedAt != null || !alias.credential) continue; @@ -369,6 +446,7 @@ export function commitRefreshedCodexCredentialWithAliases( generation: aliasGeneration, refreshGrantFingerprint, replacedAt: alias.replacedAt, + quotaHistoryIdentity: alias.quotaHistoryIdentity, ...preservedValidationMetadata(alias), }; propagatedAliases.push({ id: aliasId, generation: aliasGeneration }); diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 09becf51ea..8c041b473e 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -1,3 +1,10 @@ +import { CODEX_ACCOUNT_LOG_LABEL_RE } from "./account-label"; +import { poolQuotaHistoryIdentity } from "./account-store"; +import { estimateCodexQuotaCapacity, insufficientCodexCapacity, type CodexCapacityResult } from "./quota-capacity"; +import { readUsageSnapshotForManagement } from "../usage/log"; +import { capturePoolQuotaWriter } from "./account-store"; +import type { PoolQuotaWriter } from "./quota-types"; +import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "./quota"; import { ConfigMutationLockError, loadConfig, @@ -41,6 +48,7 @@ import { } from "./account-priority"; import { claimDueCodexQuotaRecoveryProbes, + codexQuotaScopeForModel, claimManualResetCooldowns, settleManualResetCooldown, type ManualResetCooldownClaim, @@ -49,6 +57,7 @@ import { clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, + isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion, resetCodexRoutingForManualSelection, settleCodexQuotaRecoveryProbe, @@ -379,6 +388,7 @@ export type CodexAccountReauthReason = | "forbidden"; function poolAccountDto( + config: OcxConfig, account: CodexAccount, quotaResult: PoolQuotaResult, hasCredential: boolean, @@ -413,6 +423,10 @@ function poolAccountDto( quota: quota ? { ...quota } : null, needsReauth: needsReauth || health.status === "reauth_required", ...(reauthReason !== undefined ? { reauthReason } : {}), + ...(isCodexAccountPlanExcluded(config, account.id) ? { + selectionExcludedReason: "plan_excluded" as const, + selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan), + } : {}), hasCredential, ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}), ...oauthAccountHealthFields("codex", account.id, health), @@ -1192,6 +1206,9 @@ export interface CodexAuthAccountDto { * needs the operator; `/api/oauth/accounts` already carries the same field name. */ reauthReason?: CodexAccountReauthReason; + /** Automatic selection policy only; explicit routes retain their usual auth checks. */ + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; hasCredential: boolean; health: OAuthAccountHealth; healthLabel: OAuthHealthLabel; @@ -1358,6 +1375,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 +1394,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 +1436,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 +1460,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 +1485,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 +1516,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); @@ -2006,6 +2028,7 @@ export async function listCodexAuthAccountsSnapshot( const currentCredential = getCodexAccountCredential(accountId); if (!currentCredential) { return [poolAccountDto( + runtimeConfig, currentAccount, { quota: null, needsReauth: true }, false, @@ -2026,6 +2049,7 @@ export async function listCodexAuthAccountsSnapshot( ? { ...currentAccount, plan: quotaResult.freshPlan } : currentAccount; return [poolAccountDto( + runtimeConfig, dtoAccount, effectiveQuotaResult, true, @@ -2526,6 +2550,46 @@ 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); + } + const runtimeConfig = getRuntimeConfig(config); + const account = configuredPoolAccount(runtimeConfig, accountId); + if (!account) return jsonResponse({ error: "Unknown pool account" }, 404); + const identity = poolQuotaHistoryIdentity(accountId); + const allHistory = getAccountQuotaHistory(accountId); + const limit = rawLimit === null ? 200 : Number(rawLimit); + const history = { ...allHistory, observations: allHistory.observations.slice(-limit), truncated: allHistory.observations.length > limit }; + const label = account.logLabel; + const labelStillUnique = () => { + const current = getRuntimeConfig(config); + return configuredPoolAccount(current, accountId)?.logLabel === label + && current.codexAccounts?.filter(row => codexAccountLogLabel(row) === label).length === 1; + }; + let capacity: CodexCapacityResult = insufficientCodexCapacity("identity_unavailable"); + if (identity && identity === poolQuotaHistoryIdentity(accountId) && label && CODEX_ACCOUNT_LOG_LABEL_RE.test(label) && labelStillUnique()) { + try { + const usage = await readUsageSnapshotForManagement(); + if (poolQuotaHistoryIdentity(accountId) !== identity || !labelStillUnique()) capacity = insufficientCodexCapacity("identity_changed"); + else if (!usage.revision) capacity = insufficientCodexCapacity("ledger_unavailable"); + else if (usage.truncatedPrefixBytes > 0 || usage.entriesTruncated || usage.entriesDropped > 0) capacity = insufficientCodexCapacity("ledger_truncated"); + else capacity = estimateCodexQuotaCapacity(allHistory.observations, usage.entries, label, + model => codexQuotaScopeForModel(model) === "shared"); + } catch { capacity = insufficientCodexCapacity("ledger_unavailable"); } + } + if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404); + if (identity !== poolQuotaHistoryIdentity(accountId) || (identity && label && !labelStillUnique())) { + return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, limit), capacity: insufficientCodexCapacity("identity_changed") }); + } + return jsonResponse({ accountId, ...history, capacity }); + } + 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 f362a2893a..5bf023565e 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-capacity.ts b/src/codex/quota-capacity.ts new file mode 100644 index 0000000000..d22d53b511 --- /dev/null +++ b/src/codex/quota-capacity.ts @@ -0,0 +1,98 @@ +import type { QuotaHistorySample, QuotaHistoryWindow } from "./quota-history"; +import type { PersistedUsageAttempt, PersistedUsageEntry } from "../usage/log"; + +export const CAPACITY_ASSUMPTIONS = [ + "Quota percentages can be rounded or delayed.", + "Only retained valid proxy usage rows are observed; external usage is unknown.", + "Account log labels are assumed stable within each observation interval.", + "This low-confidence effective-token estimate is not a provider token limit or lower bound.", +] as const; +export const CAPACITY_REASONS = ["insufficient_intervals", "ledger_unavailable", "ledger_truncated", "identity_unavailable", "identity_changed", "ambiguous_usage"] as const; +export type CapacityReason = typeof CAPACITY_REASONS[number]; +export function parseCapacityReason(value: unknown): CapacityReason | undefined { + return CAPACITY_REASONS.find(reason => reason === value); +} +export interface CodexCapacityResult { + status: "estimated" | "insufficient-evidence"; + estimates: Array<{ window: QuotaHistoryWindow["window"]; estimatedTokens: number; sampleCount: number; confidence: "low" }>; + reason?: CapacityReason; + assumptions: readonly string[]; +} +export function insufficientCodexCapacity(reason: CapacityReason): CodexCapacityResult { + return { status: "insufficient-evidence", estimates: [], reason, assumptions: [...CAPACITY_ASSUMPTIONS] }; +} +const nonnegative = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0; + +function reportedTokens(attempt: PersistedUsageAttempt): number | undefined { + if (attempt.sendCount !== 1 || attempt.usageStatus !== "reported" || attempt.locallyAnswered === true + || !attempt.usage || attempt.usage.estimated === true + || !nonnegative(attempt.usage.inputTokens) || !nonnegative(attempt.usage.outputTokens)) return undefined; + const total = attempt.usage.totalTokens ?? attempt.usage.inputTokens + attempt.usage.outputTokens; + return nonnegative(total) ? total : undefined; +} + +/** Informational inference over raw same-window observations, never an account-selection input. */ +export function estimateCodexQuotaCapacity( + observations: ReadonlyArray>, + entries: readonly PersistedUsageEntry[], + label: string, + sharedQuotaModel: (model: string) => boolean, +): CodexCapacityResult { + if (entries.length > 10_000) return insufficientCodexCapacity("ledger_truncated"); + const requests = new Map(); + for (const entry of entries) { + const previous = requests.get(entry.requestId); + if (previous && JSON.stringify(previous) !== JSON.stringify(entry)) return insufficientCodexCapacity("ambiguous_usage"); + requests.set(entry.requestId, entry); + } + const sorted = [...observations].sort((a, b) => a.observedAt - b.observedAt); + const estimates: CodexCapacityResult["estimates"] = []; + for (const windowName of ["short", "weekly", "monthly"] as const) { + const points = sorted.flatMap(row => { + const window = row.windows.find(candidate => candidate.family === "account" && candidate.window === windowName); + return window ? [{ ...window, at: row.observedAt, source: row.source }] : []; + }); + const samples: number[] = []; + for (let index = 1; index < points.length; index++) { + const left = points[index - 1], right = points[index]; + const delta = right.usedPercent - left.usedPercent; + if (left.source !== right.source || !nonnegative(left.resetAtMs) || left.resetAtMs !== right.resetAtMs + || left.resetAtMs <= right.at || left.windowSeconds !== right.windowSeconds + || left.monthlyIsPrimaryWindow !== right.monthlyIsPrimaryWindow + || right.at <= left.at || delta < 1 || delta > 100 || !Number.isFinite(delta)) continue; + let tokens = 0; + let valid = true; + for (const entry of requests.values()) { + if (!nonnegative(entry.timestamp) || !nonnegative(entry.durationMs)) continue; + const end = entry.timestamp + entry.durationMs; + if (!Number.isFinite(end) || entry.timestamp <= left.at || end > right.at) continue; + // Untimed or absent physical-attempt evidence cannot be reconstructed from parent totals. + if (!entry.attempts?.length) continue; + const attempts = new Map(); + for (const attempt of entry.attempts) { + const prior = attempts.get(attempt.ordinal); + if (prior && JSON.stringify(prior) !== JSON.stringify(attempt)) { valid = false; break; } + attempts.set(attempt.ordinal, attempt); + } + if (!valid) break; + for (const attempt of attempts.values()) { + if (attempt.accountLogLabel !== label || attempt.adapter !== "openai-responses" || !sharedQuotaModel(attempt.model)) continue; + const reported = reportedTokens(attempt); + if (reported === undefined) continue; + tokens += reported; + } + } + const inferred = tokens * 100 / delta; + if (valid && tokens > 0 && Number.isFinite(inferred) && inferred > 0) samples.push(inferred); + } + if (samples.length) { + samples.sort((a, b) => a - b); + const middle = Math.floor(samples.length / 2); + const median = samples.length % 2 ? samples[middle] : samples[middle - 1] / 2 + samples[middle] / 2; + const estimatedTokens = Math.round(median); + if (estimatedTokens > 0) estimates.push({ window: windowName, estimatedTokens, sampleCount: samples.length, confidence: "low" }); + } + } + return estimates.length ? { status: "estimated", estimates, assumptions: [...CAPACITY_ASSUMPTIONS] } + : insufficientCodexCapacity("insufficient_intervals"); +} 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-types.ts b/src/codex/quota-types.ts index 6c06de6ae9..cc73a7d5e3 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -49,3 +49,11 @@ export type WhamUsageResponse = { rate_limit_reset_credits?: { available_count: number } | null; additional_rate_limits?: WhamAdditionalRateLimit[] | null; }; + + +/** Captured from the exact dispatched pool credential; never a management API field. */ +export interface PoolQuotaWriter { + accountId: string; + credentialGeneration: number; + historyIdentity: string; +} 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/codex/routing.ts b/src/codex/routing.ts index 12c3e5e951..f3a990369b 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1169,7 +1169,7 @@ function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet | und * selection-only drain so routing never reads the fenced native credential for it, so a rule that * covered main would disagree with itself between drain and ordinary routing. */ -function isCodexAccountPlanExcluded( +export function isCodexAccountPlanExcluded( config: OcxConfig, accountId: string, precomputed?: ReadonlySet, @@ -2306,6 +2306,7 @@ export function previewCodexAccountForRequest( else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) + && !isCodexAccountPlanExcluded(config, active) ) return active; else return null; } @@ -2548,6 +2549,7 @@ export function resolveCodexAccountForThreadDetailed( } else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) + && !isCodexAccountPlanExcluded(config, active) ) { return { status: "selected", accountId: active }; } else { 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 cb201153a1..0069690175 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 { recordContextSessionOwner } from "../../codex/context-owner"; import { contextRelayActivated } from "../../codex/context-compat"; @@ -1081,7 +1082,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 }); }; } @@ -1486,7 +1487,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( @@ -2460,6 +2461,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), @@ -6030,7 +6032,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/src/types/accounts.ts b/src/types/accounts.ts index 6b60c76f68..3d2ba914ec 100644 --- a/src/types/accounts.ts +++ b/src/types/accounts.ts @@ -29,6 +29,8 @@ export interface CodexAccountCredentialRecord { credential?: CodexAccountCredentials; generation: number; refreshGrantFingerprint?: string; + /** Private non-secret publication identity, stable across same-account token refresh. */ + quotaHistoryIdentity?: string; deletedAt?: number; replacedAt?: number; lastCodexValidatedAt?: number; diff --git a/src/usage/log.ts b/src/usage/log.ts index 953195b7ca..2e81ebd681 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -457,6 +457,7 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null { durationMs: attempt.durationMs, // Absent by default; only the literal `true` marker survives the round trip. ...(attempt.streamAborted === true ? { streamAborted: true } : {}), + ...(attempt.locallyAnswered === true ? { locallyAnswered: true } : {}), ...(isNonNegativeFiniteNumber(attempt.firstOutputMs) ? { firstOutputMs: attempt.firstOutputMs } : {}), diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index d44c3b99ac..c5ba8dbd0d 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -88,11 +88,11 @@ Adapter events distinguish raw reasoning content from summary-channel thinking; 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. + Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. @@ -104,3 +104,5 @@ Translated Chat request construction uses the [inline-image budget](../transport medium/high/max UID before accepting a suffix already present in the model id. Both Devin provider rows share this resolver. Omitted effort preserves an explicit variant; unrelated model families retain their existing suffix precedence. + +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 68fb9b9bb3..38d90b1dfd 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -283,6 +283,8 @@ Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. +Account-qualified catalog routes bypass automatic plan exclusions while retaining credential and entitlement checks; see [automatic pool plan exclusions](providers/openai-tiers.md#automatic-pool-plan-exclusions). + The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). @@ -299,3 +301,9 @@ 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. Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +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. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 95abdfeb29..b7a3166a5e 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -87,6 +87,8 @@ Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. +Desktop requests routed to the Codex pool use the shared [automatic plan exclusion contract](../providers/openai-tiers.md#automatic-pool-plan-exclusions); explicit account-qualified targets retain their selection semantics. + The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). @@ -113,3 +115,7 @@ Combo child requests normalize effort and thinking controls against the selected Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. OpenCode is a separate launcher: its management catalog read retains local admin authority in the parent, while generated provider blocks reference only the child admission environment. It does not change Desktop configuration ownership. + +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. + +The account history response can include a [low-confidence effective capacity estimate](../providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/codex-home.md b/structure/codex-home.md index a25eb8d326..d90b5b16d7 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -227,6 +227,8 @@ a deliberate user choice: Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). +Plan-based automatic exclusions leave native credential files untouched and preserve the native-main exemption in the [selection policy](providers/openai-tiers.md#automatic-pool-plan-exclusions). + ## Paginated history writer boundary `src/codex/history-provider.ts` rejects provider-history changes with `history_paginated_requires_native_writer` when a target begins with an ordinal-bearing record or declares `history_mode=paginated`. Apply, manifest-backed restore, and explicit legacy recovery preflight all selected targets before changing database rows or manifests. The append boundary checks again. Codex owns ordinal allocation and the live projection cursor; reading the last ordinal and appending N+1 is not safe concurrent coordination. Legacy unnumbered rollouts retain their existing behavior. This guard prevents the observed stable-format corruption; it does not implement native-writer integration or guarantee a concurrent legacy-to-paginated conversion is excluded. @@ -241,3 +243,9 @@ A config restoration that was attempted and failed retains its failed artifact i result; unattempted catalog and history artifacts remain skipped. Successful preimage compensation preserves config/profile/journal bytes without relabeling the failure as a skipped operation. Incomplete compensation still raises the explicit partial-write error. + +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. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/config.md b/structure/config.md index abafd78404..6b6c3c843a 100644 --- a/structure/config.md +++ b/structure/config.md @@ -212,10 +212,13 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol malformed persisted values stay disabled. It controls only the allowlisted client-output hints described in [Responses transport](transports/responses.md), not upstream policy or model selection. +`codexPool.excludedPlans` is interpreted only by automatic selection; its all-excluded and explicit-route behavior follows the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +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 Cline client keeps connection settings and models in a separate native file pair; client path overrides and reversible writes follow [Cline paired files](clients/integrations.md#cline-paired-files). `claudeCode.stabilizePromptCache` is a default-off operator setting for @@ -225,3 +228,7 @@ Config JSON preserves the boolean; only literal true activates the role-changing The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. The OpenCode launcher resolves the existing local management origin from the live bind and configured hub ingress. Its admin credential comes from the existing admin environment/file policy; an absent credential fails without substituting a data key. + +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. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 730080408a..d5b90a0bc6 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -91,9 +91,11 @@ CCA image-capable requests do not acquire the text-summary includeThoughts opt-i 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. + Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +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 bf457f3839..9334070a84 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -205,10 +205,12 @@ 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. -Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. +Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +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/design-methodology.md b/structure/design-methodology.md index 9af35d91fc..5d00a4b1f5 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -37,6 +37,8 @@ surfaces, run through all 3 stages in order. - 6 design dials: mood, lightness, density, shape, typography, motion - 7 axes total: design → domain → feature/data/security/ops/cost (derived) +The Codex account card separates automatic plan-policy exclusion from credential health and suppresses an unavailable next-session action; see the [account selection contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). + The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 47bf09b643..ede17ac94b 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -546,6 +546,8 @@ Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. +Codex account DTOs and cards expose the routing-plan exclusion separately from credential health; the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions) also governs CLI projection. + ## Combo editor routing quota `src/server/management/provider-routes.ts` projects `routingQuota` after each quota read using the @@ -575,17 +577,22 @@ privately to final dispatch; preliminary route selection does not inject Go-only The connected browser shell reuses `SESSION_UNAVAILABLE_EVENT` and its shared-session readiness state. Terminal 401 recovery failure exposes pairing without a restart instruction; a newer session or aborted request cannot publish an unavailable notice. Successful pairing changes dashboard resource revalidation dependencies, so retained failed stores are explicitly refreshed. Dashboard reads distinguish authentication, permission denial, request failure, invalid payload and transport failure; protected data is hidden for authentication/denial, while other failed refreshes label retained data as stale. +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. Cline journal Undo eligibility reads both native configuration files through the paired integration IO adapter. Its snapshot fingerprint cannot be checked against providers.json alone; [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. + Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. Dashboard overview polling observes authorization failures independently of stalled or rejected peer requests, cancels remaining child requests after a decisive result, and exposes resource-level deadline failures without rewriting them as authentication failures. + +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. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a4275374cd..fc8bb6151d 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -311,6 +311,7 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). +The account CLI and translated Codex integration guides follow the [automatic plan exclusion contract](../providers/openai-tiers.md#automatic-pool-plan-exclusions), including all-excluded pools and explicit routes. The Combo guides describe the distinction between display quota and single-credential inference evidence used by routing. See [scoped provider quota](../runtime.md#scoped-provider-quota-for-combo-selection). The management quota DTO keeps Combo editing aligned with scoped inference evidence; @@ -329,6 +330,7 @@ Provider configuration documents distinguish actual summaries from raw reasoning Hub/browser pairing instructions distinguish machine enrollment, session authentication, permission denial and network failure. The hosted dashboard preview is the render artifact used to review these states. +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. The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. @@ -340,3 +342,7 @@ Combo child requests normalize effort and thinking controls against the selected Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. OpenCode launcher verification distinguishes the local management catalog request from the inference child. Its transport regressions cover proxy environment, redirects, endpoint validation, credential precedence and child-env separation on hosted CI. + +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. + +The account history response can include a [low-confidence effective capacity estimate](../providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 93806c89ed..2eb727a1c9 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -154,9 +154,11 @@ Provider summary defaults are evaluated per routed Responses request without cha 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. + Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +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 9d0273b86d..611844c13f 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -109,6 +109,7 @@ would pass while the rule was violated. Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). +Codex plan exclusions constrain automatic pool selection without deleting credentials; [account-policy reasons](providers/openai-tiers.md#automatic-pool-plan-exclusions) remain distinct from health and pause. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). @@ -122,3 +123,5 @@ Connected-browser pairing and dashboard failure meanings follow the [management 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. Translated Chat request construction uses the [inline-image budget](transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +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 9bac0dc9b5..188684483b 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -438,6 +438,11 @@ API-key and custom forward destinations preserve their metadata. See [Responses Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). +## Automatic pool plan exclusions + +`src/codex/routing.ts` applies optional `codexPool.excludedPlans` to both candidate selection and existing active/affined accounts. An all-excluded pool returns no automatic candidate, including preview and configured-account fallback. Native main remains exempt and unknown plans remain eligible. Explicit account-qualified routes retain pause, credential and entitlement checks while bypassing only this automatic policy. + +`src/codex/auth-api.ts` projects `selectionExcludedReason: "plan_excluded"` and `selectionExcludedPlan` from the routing config, even when a newer display-only WHAM plan could not be persisted. The dashboard and account CLI show the policy reason separately from credential health; renewal clears the derived fields. The automatic next-session action and badge are omitted for excluded rows. ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. @@ -474,3 +479,23 @@ separately, nothing is dispatched upstream after either, and notes writes are ne Context relay dispatch rechecks the native experimental opt-in after body and credential waits. A disabled gate prevents upstream dispatch even when the request entered while enabled. Final materialized headers pass the proxy-credential exclusion check before owner matching. + +## Quota history publication identity + +`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. + +## Observed effective token capacity + +`src/codex/quota-capacity.ts` joins raw account-family observations with reported single-send usage attempts wholly contained within matching, unexpired reset intervals. Source, window duration and monthly-primary provenance must match; percentage delta must be at least one point. Duplicate request/attempt identities never multiply usage. Local, estimated, multi-send, independent-model and absent-attempt evidence does not supply a capacity sample. + +The history read API reports a median effective token estimate and interval sample count with low confidence and explicit coverage/rounding/label-continuity assumptions. It is not a provider token limit or mathematical lower bound and never affects account selection. Truncated, unavailable or excessive usage-ledger reads produce insufficient evidence while retaining history. Publication UUID and explicit unique account label are checked around the asynchronous read; identity changes discard the estimate and refresh the returned history. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index f67fa76e9c..d11106e6aa 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -110,3 +110,5 @@ what the Responses lane already relayed for responses-wire upstreams; the respon assembly for chat-wire upstreams tracks the echo in attempt telemetry only. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +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 6cec43426f..3b035fbd97 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -221,6 +221,7 @@ claims stored main, after terminal vision, routed vision and search exclusions. `src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. +Automatic Codex pool selection and account status share the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). ## Scoped provider quota for Combo selection `src/providers/quota.ts` publishes routing evidence only when a producer explicitly supplies its @@ -257,6 +258,8 @@ Responses route normalization resolves provider summary defaults from the origin 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. +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. + Cline CLI joins the existing export/client integration registries. Explicit CLI sync and POST /api/sync refresh its owned pair; unattended catalog refresh excludes it. See [Cline paired files](clients/integrations.md#cline-paired-files). `claudeCode.stabilizePromptCache` is a default-off operator setting for @@ -272,3 +275,7 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Translated Chat request construction uses the [inline-image budget](transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. OpenCode catalog discovery in `src/cli/opencode.ts` uses the local admin credential and a validated numeric-loopback management origin. It dials through `src/server/direct-local-http.ts`, rejects redirects and preserves the request/body deadline. Hub ingress selection stays separate from exported inference settings. + +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. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/subagents.md b/structure/subagents.md index cea31404e5..7c058fbf05 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -245,6 +245,8 @@ Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. +Subagent automatic pool preview returns no candidate when all pool plans are excluded; explicit account-qualified models retain the [selection-policy distinction](providers/openai-tiers.md#automatic-pool-plan-exclusions). + Provider-level Combo eligibility uses explicit inference evidence for the current single credential; account-specific admission remains separate. See [scoped provider quota](runtime.md#scoped-provider-quota-for-combo-selection). The management quota DTO keeps Combo editing aligned with scoped inference evidence; @@ -265,5 +267,10 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +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. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 61e9c2ca75..444e3c3af9 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -93,3 +93,5 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. Translated Chat request construction uses the [inline-image budget](streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +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 f56be38160..1f7f65671c 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -587,10 +587,12 @@ WebSocket metadata and compact are excluded. This does not disable upstream safe 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. -Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. +Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. Translated Chat request construction uses the [inline-image budget](streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +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 18f603ae5a..1b613a7c46 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -211,14 +211,16 @@ Raw reasoning and provider-authored summary deltas both remain real upstream act 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. + Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. ## Translated Chat inline-image budget `src/adapters/openai-chat-images.ts` reuses the shared image normalization ladder for translated Chat bodies above a 3.5 MiB base64-image budget. This is best effort, not a whole-request ceiling. Remote URLs are not fetched; unprocessable and terminal images remain attached, and retained bytes continue to count during demotion. Under-budget construction stays synchronous; delegating MiMo awaits conditional asynchronous construction. Native Chat passthrough and Anthropic-only 413 retry policy retain their existing behavior. + +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..871cfdab8b 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,57 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("plan exclusions survive the API projection and use the policy plan", async () => { + codexAccounts = [{ id: "policy", plan: "plus", selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free", paused: false }]; + const human = await run(["list", "openai"]); + expect(human.code).toBe(0); + expect(human.stdout).toContain("not-auto-selected(plan=free)"); + const machine = await run(["list", "openai", "--json"]); + expect(JSON.parse(machine.stdout).accounts[0]).toMatchObject({ selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free" }); + codexAccounts = [{ id: "policy", plan: "plus", selectionExcludedReason: "unrecognized", selectionExcludedPlan: "free" }]; + expect((await run(["list", "openai"])).stdout).not.toContain("not-auto-selected"); + expect(JSON.parse((await run(["list", "openai", "--json"])).stdout).accounts[0]).not.toHaveProperty("selectionExcludedReason"); + }); + + test.each(["estimated", "insufficient-evidence"] as const)("human and JSON history preserve capacity status %s", async status => { + const capacity = status === "estimated" ? { status, estimates: [{ window: "weekly", estimatedTokens: 10000, sampleCount: 2, confidence: "low" }] } + : { status, reason: "ledger_truncated", estimates: [] }; + const deps: AccountDeps = { 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 }], + }], capacity })) as typeof fetch }; + const human = await run(["history", "openai", "pool-a"], deps); + expect(human.code).toBe(0); + expect(human.stdout).toContain(status === "estimated" ? "~10000 reported tokens / 100%\t2 samples" : "insufficient evidence (ledger_truncated)"); + const json = await run(["history", "openai", "pool-a", "--json"], deps); + expect(JSON.parse(json.stdout).capacity).toEqual(capacity); + }); + + 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 => { + 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-account-store.test.ts b/tests/codex-integration/codex-account-store.test.ts index 77a7750d14..e44ce355e0 100644 --- a/tests/codex-integration/codex-account-store.test.ts +++ b/tests/codex-integration/codex-account-store.test.ts @@ -80,6 +80,90 @@ describe("codex-account-store CRUD", () => { expect(store.readCodexAccountRecord("pending")?.lastCodexValidationStatus).toBe("ok"); }); + test("quota history identity survives refresh but explicit publication retires the writer", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "history-access", refreshToken: "history-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "history-account" }; + const generation = store.saveCodexAccountCredential("history", credential); + const writer = store.capturePoolQuotaWriter("history", { ...credential, generation })!; + expect(writer.historyIdentity).toMatch(/^[a-f0-9-]{36}$/); + expect(store.isPoolQuotaWriterLive(writer)).toBe(true); + const refreshed = { ...credential, accessToken: "history-refreshed-access", refreshToken: "history-refreshed-grant" }; + expect(store.saveCodexAccountCredentialIfGeneration("history", generation, refreshed)).toBe(true); + expect(store.poolQuotaHistoryIdentity("history")).toBe(writer.historyIdentity); + expect(store.isPoolQuotaWriterLive(writer)).toBe(false); + const refreshedWriter = store.capturePoolQuotaWriter("history", { ...refreshed, generation: generation + 1 })!; + expect(refreshedWriter.historyIdentity).toBe(writer.historyIdentity); + expect(store.getCodexAccountCredential("history")).toEqual(refreshed); + const clock = spyOn(Date, "now").mockReturnValue(1_800_000_000_000); + try { + store.saveCodexAccountCredential("history", refreshed); + const replaced = store.poolQuotaHistoryIdentity("history"); + expect(replaced).not.toBe(writer.historyIdentity); + store.saveCodexAccountCredential("history", refreshed); + expect(store.poolQuotaHistoryIdentity("history")).not.toBe(replaced); + } finally { clock.mockRestore(); } + expect(store.isPoolQuotaWriterLive(refreshedWriter)).toBe(false); + }); + + test("quota history aliases retain distinct publication identities through refresh", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "alias-access", refreshToken: "alias-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "alias-account" }; + const generation = store.saveCodexAccountCredential("owner", credential); + store.saveCodexAccountCredential("alias", credential); + const ownerIdentity = store.poolQuotaHistoryIdentity("owner"); + const aliasIdentity = store.poolQuotaHistoryIdentity("alias"); + expect(ownerIdentity).not.toBe(aliasIdentity); + const refreshed = { ...credential, accessToken: "alias-refreshed", refreshToken: "alias-new-refresh" }; + expect(store.commitRefreshedCodexCredentialWithAliases("owner", generation, refreshed).committed).toBe(true); + expect(store.poolQuotaHistoryIdentity("owner")).toBe(ownerIdentity); + expect(store.poolQuotaHistoryIdentity("alias")).toBe(aliasIdentity); + store.removeCodexAccountCredential("alias"); + expect(store.poolQuotaHistoryIdentity("alias")).toBeUndefined(); + store.saveCodexAccountCredential("alias", refreshed); + expect(store.poolQuotaHistoryIdentity("alias")).not.toBe(aliasIdentity); + }); + + test("legacy history identity initializes once without advancing credential generation or epoch", async () => { + const store = await import("../../src/codex/account-store"); + const { codexCredentialMutationEpoch } = await import("../../src/codex/credential-mutation-epoch"); + const credential = { accessToken: "legacy-history-access", refreshToken: "legacy-history-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "legacy-history-account" }; + writeFileSync(ACCOUNTS_PATH, JSON.stringify({ legacy: credential })); + const epoch = codexCredentialMutationEpoch(); + expect(store.poolQuotaHistoryIdentity("legacy")).toBeUndefined(); + expect(store.capturePoolQuotaWriter("legacy", { ...credential, generation: 1 })).toBeUndefined(); + expect(store.capturePoolQuotaWriter("legacy", { ...credential, accessToken: "wrong", generation: 0 })).toBeUndefined(); + expect(store.capturePoolQuotaWriter("legacy", { ...credential, chatgptAccountId: "wrong", generation: 0 })).toBeUndefined(); + const writer = store.capturePoolQuotaWriter("legacy", { ...credential, generation: 0 })!; + expect(store.capturePoolQuotaWriter("legacy", { ...credential, generation: 0 })).toEqual(writer); + expect(store.readCodexAccountRecord("legacy")?.generation).toBe(0); + expect(codexCredentialMutationEpoch()).toBe(epoch); + expect(store.loadCodexAccountStore()).toEqual({ legacy: credential }); + expect(JSON.stringify(writer)).not.toContain(credential.accessToken); + expect(JSON.stringify(writer)).not.toContain(credential.refreshToken); + expect(store.capturePoolQuotaWriter("__main__", { ...credential, generation: 0 })).toBeUndefined(); + }); + + test("malformed optional history metadata cannot discard an otherwise usable credential", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "metadata-access", refreshToken: "metadata-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "metadata-account" }; + writeFileSync(ACCOUNTS_PATH, JSON.stringify({ metadata: { credential, generation: 3, quotaHistoryIdentity: 42 } })); + expect(store.getCodexAccountCredential("metadata")).toEqual(credential); + expect(store.poolQuotaHistoryIdentity("metadata")).toBeUndefined(); + expect(store.capturePoolQuotaWriter("metadata", { ...credential, generation: 3 })?.historyIdentity).toMatch(/^[a-f0-9-]{36}$/); + }); + + test("an identity-changing CAS does not retain history or propagate credentials to old aliases", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "old-account-access", refreshToken: "shared-old-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "old-account" }; + const generation = store.saveCodexAccountCredential("owner", credential); + store.saveCodexAccountCredential("alias", credential); + const identity = store.poolQuotaHistoryIdentity("owner"); + const result = store.commitRefreshedCodexCredentialWithAliases("owner", generation, { ...credential, accessToken: "new-account-access", refreshToken: "new-refresh", chatgptAccountId: "new-account" }); + expect(result).toMatchObject({ committed: true, propagatedAliases: [] }); + expect(store.poolQuotaHistoryIdentity("owner")).not.toBe(identity); + expect(store.getCodexAccountCredential("alias")).toEqual(credential); + }); + test("save and load credential round-trip", async () => { const { saveCodexAccountCredential, getCodexAccountCredential } = await import("../../src/codex/account-store"); const cred = { accessToken: "tk_a", refreshToken: "rf_a", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acc_a" }; diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 89813bb058..75362a1e5c 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1,3 +1,5 @@ +import * as usageHistoryModule from "../../src/usage/log"; +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"; @@ -1051,6 +1053,65 @@ describe("codex-auth API", () => { } }); + test("account DTO exposes the routing plan exclusion and clears it on renewal", async () => { + const cfg = makeConfig({ codexPool: { excludedPlans: ["free"] } }); + seedPoolAccount(cfg, { id: "plan-row", email: "plan@example.test", plan: "free" }); + const read = async () => { + const request = new Request("http://localhost/api/codex-auth/accounts"); + const response = await handleCodexAuthAPI(request, new URL(request.url), cfg); + const body = await response!.json() as { accounts: CodexAuthAccountDto[] }; + return body.accounts.find(account => account.id === "plan-row")!; + }; + expect(await read()).toMatchObject({ selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free", paused: false }); + cfg.codexAccounts![0].plan = "plus"; + const renewed = await read(); + expect(renewed).not.toHaveProperty("selectionExcludedReason"); + expect(renewed).not.toHaveProperty("selectionExcludedPlan"); + }); + + test("history capacity uses reported intervals and invalidates after identity changes during the ledger read", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "capacity-a", email: "capacity@example.test", plan: "plus" }); + config.codexAccounts![0].logLabel = "pabcdef"; + const { capturePoolQuotaWriter } = await import("../../src/codex/account-store"); + const record = readCodexAccountRecord("capacity-a")!; + const writer = capturePoolQuotaWriter("capacity-a", { ...record.credential!, generation: record.generation })!; + const now = Date.now(); + for (const [observedAt, weeklyPercent] of [[now - 2000, 10], [now, 20]]) { + const raw = { weeklyPercent, weeklyResetAt: now + 100_000 }; + setAccountQuotaFromParsed("capacity-a", raw, undefined, undefined, raw, { writer, observedAt, source: "wham", raw }); + } + usageHistoryModule.appendUsageEntry({ requestId: "capacity-request", timestamp: now - 1000, durationMs: 100, provider: "openai", model: "gpt-5.5", status: 200, usageStatus: "reported", attempts: [{ + ordinal: 1, provider: "openai", model: "gpt-5.5", adapter: "openai-responses", status: 200, durationMs: 100, sendCount: 1, + recoveryKinds: [], usageStatus: "reported", accountLogLabel: "pabcdef", usage: { inputTokens: 800, outputTokens: 200, totalTokens: 1000 }, + }] }); + const request = () => new Request("http://localhost/api/codex-auth/quota/history?accountId=capacity-a&limit=1"); + const req = request(); + const result = await handleCodexAuthAPI(req, new URL(req.url), config); + const body = await result!.json() as { observations: unknown[]; capacity: { status: string; estimates: unknown[] } }; + expect(body.observations).toHaveLength(1); + expect(body.capacity.estimates).toEqual([{ window: "weekly", estimatedTokens: 10000, sampleCount: 1, confidence: "low" }]); + const stored = usageHistoryModule.readUsageEntries(); + expect(stored).toHaveLength(1); + stored[0].attempts![0].model = " "; + writeFileSync(usageHistoryModule.usageLogPath(), JSON.stringify(stored[0]) + "\n"); + const blankModel = request(); + const blankResult = await handleCodexAuthAPI(blankModel, new URL(blankModel.url), config); + expect((await blankResult!.json()).capacity).toMatchObject({ status: "insufficient-evidence", estimates: [] }); + const originalRead = usageHistoryModule.readUsageSnapshotForManagement; + const read = spyOn(usageHistoryModule, "readUsageSnapshotForManagement").mockImplementation(async () => { + const snapshot = await originalRead(); + saveCodexAccountCredential("capacity-a", record.credential!); + return snapshot; + }); + try { + const next = request(); + const response = await handleCodexAuthAPI(next, new URL(next.url), config); + expect(read).toHaveBeenCalledTimes(1); + expect(await response!.json()).toMatchObject({ observations: [], capacity: { status: "insufficient-evidence", reason: "identity_changed", estimates: [] } }); + } finally { read.mockRestore(); } + }); + test("GET /api/codex-auth/accounts returns array with main", async () => { const req = new Request("http://localhost/api/codex-auth/accounts", { method: "GET" }); const url = new URL(req.url); @@ -1796,6 +1857,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 +6160,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-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 39e3c45329..428a9b682a 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -1527,6 +1527,28 @@ describe("Codex auth context", () => { .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); + test("explicit account routing bypasses plan policy while retaining pause and reauth checks", async () => { + const cfg = config(); + cfg.codexAccounts!.find(account => account.id === "pool-a")!.plan = "free"; + cfg.codexPool = { excludedPlans: ["free"] }; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, chatgptAccountId: "pool_a_acc", + }); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + cfg.pausedCodexAccountIds = ["pool-a"]; + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).rejects.toThrow("Selected Codex account is unavailable"); + cfg.pausedCodexAccountIds = []; + markAccountNeedsReauth("pool-a"); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).rejects.toThrow("Selected Codex account needs reauthentication"); + }); + test("exact selection reports reauthentication without falling back to the active Pool account", async () => { const cfg = config(); cfg.activeCodexAccountId = "pool-b"; diff --git a/tests/codex-integration/codex-pool-plan-exclusion.test.ts b/tests/codex-integration/codex-pool-plan-exclusion.test.ts index 52a76fb57a..36fe8aed2a 100644 --- a/tests/codex-integration/codex-pool-plan-exclusion.test.ts +++ b/tests/codex-integration/codex-pool-plan-exclusion.test.ts @@ -6,6 +6,7 @@ import { clearCodexUpstreamHealth, clearThreadAccountMap, pickLowestUsageCodexAccount, + isCodexAccountPlanExcluded, previewCodexAccountForRequest, resolveCodexAccountForThread, } from "../../src/codex/routing"; @@ -164,15 +165,24 @@ describe("codex pool plan exclusion", () => { expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); }); - test("the last remaining account still serves rather than stranding the operator", () => { - // Deliberately unlike pause. #4211 asks for a selection policy, not a hard block, so with no - // unexcluded candidate left the excluded account keeps answering instead of failing closed. + test("automatic routing refuses the last excluded account", () => { const config = makeConfig({ codexAccounts: [{ id: "downgraded", email: "downgraded@test", isMain: false, plan: "free" }], codexPool: { excludedPlans: ["free"] }, } as Partial); recordUsage("downgraded", 10); expect(pickLowestUsageCodexAccount(config)).toBeNull(); - expect(resolveCodexAccountForThread("last-account", config)).toBe("downgraded"); + expect(resolveCodexAccountForThread("last-account", config)).toBeNull(); + expect(previewCodexAccountForRequest("last-account", config)).toBeNull(); }); + test("renewal clears the policy reason without pausing or deleting the account", () => { + const config = makeConfig({ codexPool: { excludedPlans: ["free"] } }); + expect(isCodexAccountPlanExcluded(config, "downgraded")).toBe(true); + config.codexAccounts![0].plan = "plus"; + expect(isCodexAccountPlanExcluded(config, "downgraded")).toBe(false); + expect(isCodexAccountPlanExcluded(config, "__main__")).toBe(false); + expect(config.codexAccounts).toHaveLength(2); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + }); 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-capacity.test.ts b/tests/codex-integration/codex-quota-capacity.test.ts new file mode 100644 index 0000000000..3bca1e8391 --- /dev/null +++ b/tests/codex-integration/codex-quota-capacity.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { estimateCodexQuotaCapacity } from "../../src/codex/quota-capacity"; +import type { QuotaHistorySample } from "../../src/codex/quota-history"; +import type { PersistedUsageEntry, PersistedUsageAttempt } from "../../src/usage/log"; + +const label = "pabcdef"; +const shared = (model: string) => model !== "independent"; +const point = (at: number, percent: number): Omit => ({ + observedAt: at, source: "wham", windows: [{ family: "account", window: "weekly", usedPercent: percent, resetAtMs: 10_000 }], +}); +const attempt = (overrides: Partial = {}): PersistedUsageAttempt => ({ + ordinal: 1, provider: "openai", model: "gpt-test", adapter: "openai-responses", status: 200, durationMs: 10, + sendCount: 1, recoveryKinds: [], usageStatus: "reported", accountLogLabel: label, + usage: { inputTokens: 800, outputTokens: 200, totalTokens: 1000 }, ...overrides, +}); +const entry = (overrides: Partial = {}): PersistedUsageEntry => ({ + requestId: "r1", timestamp: 1100, durationMs: 100, provider: "openai", model: "gpt-test", status: 200, usageStatus: "reported", + attempts: [attempt()], ...overrides, +}); +const points = [point(1000, 10), point(2000, 20)]; + +describe("observed effective quota capacity", () => { + test("hand-calculated 1000 reported tokens over ten percentage points estimates 10000", () => { + const result = estimateCodexQuotaCapacity(points, [entry()], label, shared); + expect(result.status).toBe("estimated"); + expect(result.estimates).toEqual([{ window: "weekly", estimatedTokens: 10000, sampleCount: 1, confidence: "low" }]); + expect(result.assumptions.length).toBeGreaterThan(0); + }); + + test("duplicate rows and ordinals count once while conflicts refuse estimation", () => { + expect(estimateCodexQuotaCapacity(points, [entry(), entry()], label, shared).estimates[0].estimatedTokens).toBe(10000); + expect(estimateCodexQuotaCapacity(points, [entry({ attempts: [attempt(), attempt()] })], label, shared).estimates[0].estimatedTokens).toBe(10000); + expect(estimateCodexQuotaCapacity(points, [entry(), entry({ durationMs: 101 })], label, shared).status).toBe("insufficient-evidence"); + expect(estimateCodexQuotaCapacity(points, [entry({ attempts: [attempt(), attempt({ sendCount: 2 })] })], label, shared).status).toBe("insufficient-evidence"); + }); + + test.each([ + entry({ timestamp: 1000 }), entry({ timestamp: 1999, durationMs: 2 }), entry({ attempts: [] }), entry({ attempts: undefined }), + entry({ attempts: [attempt({ sendCount: 2 })] }), entry({ attempts: [attempt({ locallyAnswered: true })] }), + entry({ attempts: [attempt({ usage: { inputTokens: 1, outputTokens: 1, estimated: true } })] }), + entry({ attempts: [attempt({ usageStatus: "unreported" })] }), entry({ attempts: [attempt({ accountLogLabel: "p123456" })] }), + entry({ attempts: [attempt({ model: "independent" })] }), + ])("unknown or outside-interval usage supplies no sample", row => { + expect(estimateCodexQuotaCapacity(points, [row], label, shared).status).toBe("insufficient-evidence"); + }); + + test("window/provenance/reset changes, refunds and tiny deltas are not capacity intervals", () => { + for (const right of [point(2000, 9), point(2000, 10), point(2000, 10.1), { ...point(2000, 20), source: "response-header" as const }, + { ...point(2000, 20), windows: [{ ...point(2000, 20).windows[0], resetAtMs: undefined }] }, + { ...point(2000, 20), windows: [{ ...point(2000, 20).windows[0], resetAtMs: 20_000 }] }, + { ...point(2000, 20), windows: [{ ...point(2000, 20).windows[0], family: "spark" as const }] }, + ]) expect(estimateCodexQuotaCapacity([points[0], right], [entry()], label, shared).status).toBe("insufficient-evidence"); + }); + + test("overflow and bounded scan cannot produce a finite-looking false result", () => { + const oversized = entry({ attempts: [attempt({ usage: { inputTokens: Number.MAX_VALUE, outputTokens: Number.MAX_VALUE } })] }); + expect(estimateCodexQuotaCapacity(points, [oversized], label, shared).status).toBe("insufficient-evidence"); + expect(estimateCodexQuotaCapacity(points, Array.from({ length: 10001 }, () => entry()), label, shared).reason).toBe("ledger_truncated"); + }); +}); + + +test("a positive fractional inference never publishes zero capacity after rounding", () => { + const small = entry({ attempts: [attempt({ usage: { inputTokens: 0.1, outputTokens: 0 } })] }); + expect(estimateCodexQuotaCapacity([point(1000, 0), point(2000, 90)], [small], label, shared).status).toBe("insufficient-evidence"); +}); 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..feb1ea8cc1 --- /dev/null +++ b/tests/codex-integration/codex-quota-history.test.ts @@ -0,0 +1,100 @@ +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: { "pool-a": { identity, samples: [sample()] } }, 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); + }); +}); + + +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 72d596e75f..a1e8b85418 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,57 @@ 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(); + 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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3e8ebbde5c..35413d8adc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -321,6 +321,8 @@ "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-capacity.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/gui/rate-limit-reset-credits.test.ts b/tests/gui/rate-limit-reset-credits.test.ts index a65445e3cc..a34de6dc8e 100644 --- a/tests/gui/rate-limit-reset-credits.test.ts +++ b/tests/gui/rate-limit-reset-credits.test.ts @@ -302,8 +302,8 @@ describe("rate-limit reset credits", () => { expect(source).toContain("className=\"card-badges\""); expect(source).toContain(" onOpenReset(a)} />"); // Next-session still renders BESIDE the ticket; health projection also suppresses - // it for projected reauth/cooldown and pending validation. - expect(source).toContain("{isNext(a) && !showReauth && !inCooldown && !validationPending && ("); + // it for plan exclusion, projected reauth/cooldown and pending validation. + expect(source).toContain("{isNext(a) && !planExcluded && !showReauth && !inCooldown && !validationPending && ("); expect(source).toContain("{t(accountModeState === \"direct\" ? \"codexAuth.poolPrepared\" : \"codexAuth.nextSession\")}"); const styles = await Bun.file("gui/src/styles.css").text(); expect(styles).toContain(".card-badges { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }"); 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 f703a1899e..d7b14cc1ce 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,43 @@ 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; + 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..90805e538b 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -667,6 +667,44 @@ 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, capacity: { status: "insufficient-evidence", reason: "identity_unavailable", estimates: [], assumptions: expect.any(Array) } }); + 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); } + }); + test("every kind answers with the same keys and declares what it supports", async () => { const server = startServer(0); try { diff --git a/tests/usage/usage-log.test.ts b/tests/usage/usage-log.test.ts index f39c778d2b..9cd97855a6 100644 --- a/tests/usage/usage-log.test.ts +++ b/tests/usage/usage-log.test.ts @@ -579,6 +579,14 @@ describe("usage log", () => { expect(valid.attempts?.[0]?.reasoningWireValue).toBe(false); }); + test("local-answer provenance survives attempt normalization for capacity exclusion", () => { + const value = normalizeUsageEntryForTest({ requestId: "local-capacity", timestamp: Date.now(), provider: "openai", model: "m", status: 200, durationMs: 1, usageStatus: "reported", attempts: [{ + ordinal: 1, provider: "openai", model: "m", adapter: "openai-responses", status: 200, durationMs: 1, sendCount: 1, + recoveryKinds: [], usageStatus: "reported", locallyAnswered: true, accountLogLabel: "pabcdef", usage: { inputTokens: 1, outputTokens: 1 }, + }] }); + expect(value.attempts?.[0]?.locallyAnswered).toBe(true); + }); + test("drops only malformed persisted attempts while preserving valid siblings", () => { const valid = (ordinal: number) => ({ ordinal,