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/030_reset.md b/devlog/_plan/260912_accounts/030_reset.md index 875a6dccc5..be34c48373 100644 --- a/devlog/_plan/260912_accounts/030_reset.md +++ b/devlog/_plan/260912_accounts/030_reset.md @@ -14,3 +14,11 @@ Additional MODIFY `src/oauth/pool-settings-capability.ts` and `src/server/manage Field chain: CLI/GUI strategy creation → canonical PUT parser → config.accountPoolStrategy write → config load + canonical GET parser → pool rotation/preview/failover, CLI and GUI display. Audit every existing strategy comparison/default, not just the union. No schema migration or new dependency. Exact contributor diff remains `.tmp/accounts-20260912/pr4080.diff` during planning; changes are adapted to current callers before B. Extend regression sources for canonical PUT/GET/save/reload, legacy endpoint, non-Codex rejection, tied/missing/elapsed resets, threshold zero, priorities, affinity and failover. Existing #4080 test cases are retained/adapted. Update all source ownership docs; screenshot of final rendered strategy control is included with PR. Local suites/build/typecheck/install NOT RUN; final head hosted CI supplies proof. #3376 remains partial until history/capacity; monthly/Anthropic/latest-first scope is reported separately. + +P revalidation: #4080 head unchanged. Current pool-rotation.ts is a compatibility facade, so Codex parser/normalizer live in existing src/oauth/pool-kernel.ts leaf and are reexported. Canonical GET DTO and PUT parser use Codex-specific parser only for kind=codex. Use existing resetAtToMs for both seconds/milliseconds before comparing future deadlines. Existing manualPreferenceBlocks remains at promotion; reset-first affinity calls mayRebindAffinityForQuota so pool.cacheAffinity retains a healthy bound account until genuine exhaustion. Current config parser preserves accountPoolStrategy through passthrough, so canonical save/reload regression is required. User limits unchanged; previous eligibility D delivered PR4361 with hosted/render pending, reset-first remains independent. + +A1 accepted: independent spark/reserve quota scopes use the existing quota strategy consistently for initial selection, preview, affinity and alternates; shared 5h/weekly reset timestamps are not their evidence. Add private `accountPoolStrategyForScope(config, quotaScope)` in routing.ts: normalize the configured Codex strategy, then return quota when reset-first and isIndependentCodexQuotaScope(scope), otherwise the normalized strategy. Use it in pickUnboundStrategyAccount, pickAlternateCodexAccount, previewReusableAffinityAccount and reevaluateAffinityQuota. Shared promotion remains scope-guarded and uses configured normalized strategy. Config remains reset-first, DTO shows configured value and docs explain effective independent-scope fallback. Tests oppose shared reset versus usage order, include scoped cooldown and unchanged shared cursor. + +Config decision: retain existing passthrough compatibility rather than add an unrelated disk-validation policy in this carry. Canonical/legacy management writes validate through Codex parser, and all runtime consumers normalize malformed direct config values to quota as before. Explicit invalid parser/API and save/reload tests verify this boundary; no whole-config reset is introduced. + +C source audit found threshold=0/cacheAffinity=true could still rebind at100%. Accepted and fixed with early disabled-threshold return before reset-first affinity evaluation; new preview/resolve/all100 fixtures cover both cache settings. Failure recovery stays separate. Local suites NOT RUN; source re-audit and hosted CI pending. diff --git a/devlog/_plan/260912_accounts/031_reset_delivery.md b/devlog/_plan/260912_accounts/031_reset_delivery.md new file mode 100644 index 0000000000..4b20ad2950 --- /dev/null +++ b/devlog/_plan/260912_accounts/031_reset_delivery.md @@ -0,0 +1,13 @@ +# Reset-first carry follows the current pool contract + +Adapts #4080 ecf6b4e48a4c2992c296fada2caf6a8132313eaa by Terry Tan. The Codex parser now lives in the existing shared kernel leaf, canonical and legacy settings round-trip the configured strategy, and the GUI offers it only on Codex. Existing runtime priority, manual preference and cache-affinity behavior is preserved. Mixed reset units are normalized before ordering; independent model quota scopes retain existing quota selection. + +Regression sources include original reset-first cases plus mixed units, cacheAffinity on/off, scoped fallback/health/shared cursor, canonical and legacy persistence, non-Codex rejection and GUI empty-response normalization. UI hints reflect current cache-affinity and scope semantics. Local tests/build/typecheck/install: NOT RUN. git diff --check is whitespace evidence only; independent source review and hosted final-tip CI/render evidence follow. + +Source search: accountPoolStrategy, normalizeAccountPoolStrategy, resetAtToMs, pool/settings, mayRebindAffinityForQuota, manualPreferenceBlocks and all strategy consumers. Existing pool-kernel and routing owners extended; no new dependency or separate pool implementation. Config passthrough behavior preserved deliberately; write routes validate through the Codex-specific parser. + +Co-authored-by: Terry Tan + +C source audit found threshold=0/cacheAffinity=true could still rebind at100%. Accepted and fixed with early disabled-threshold return before reset-first affinity evaluation; new preview/resolve/all100 fixtures cover both cache settings. Failure recovery stays separate. Local suites NOT RUN; source re-audit and hosted CI pending. + +Independent C re-audit PASS at eddc8c7b08; nonblocking oracle improvement accepted: assert actual alternate b, excluding null as a false recovery result. Hosted/runtime acceptance remains pending. 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/devlog/_plan/260912_accounts/070_tun.md b/devlog/_plan/260912_accounts/070_tun.md index d37cd09632..77f46a02cc 100644 --- a/devlog/_plan/260912_accounts/070_tun.md +++ b/devlog/_plan/260912_accounts/070_tun.md @@ -21,3 +21,9 @@ MODIFY GUI `components/provider-workspace/types.ts`, `hooks/useProviderAccountPo Field chain: private probe→transient cache→account results→authenticated API JSON→enum-normalized client/CLI→current/all-account quota text. Ranking/health/history do not consume it. Tests: each enum trigger, summary failure/fallback success, final-attempt precedence, stale bars, recovery, cross-account isolation, stale-config, late response, unknown wire code, and secret-free projection. Existing provider account quota fixtures supply transport injection; new files require both layout entries. Fix inventory's stale IPv6 proxy-only sentence and update every touched area owner. Local suites/build NOT RUN; hosted backend/GUI checks and rendered final-tip artifact. Authenticated TUN observation remains unmet until an authorized operator supplies exact SHA, proxy/TUN mode and sanitized successful refresh; no network/account changes here. Reflection TUN-R01/R02 accepted. HTTP 300–399→redirect_blocked, 401/403→access_denied, 429→rate_limited, other non-2xx→upstream_error; success with unusable quota→response_unusable. Keep providerRedirectError cancellation and discard its message. Neither status establishes plan or reauth. fetchAntigravityQuota may reuse the private probe preserving null/rejection and success source; ProviderQuota/ProviderQuotaReport gain no diagnostic field, report-only views remain generic. getCachedProviderAccountQuota returns last-good quota only. + +P revalidation on489af939: parent added explicit account readers and Combo quota evidence. Classification stays limited to Antigravity account probe/cache; provider report remains its existing report(...) projection with no inference authority or diagnosticfield. Current functions moved but contract unchanged. Active tun cursor honored after history A mismatch; history source implementation remains pending on its own branch. Local tests/build/typecheck/install still NOT RUN; authenticated field acceptance untouched. + +A1 credential-currentness accepted. Reuse the existing private explicitQuotaIdentity hash recipe via a pure quotaCredentialIdentity(provider,id,capturedCredential,target) helper; existing explicit readers keep byte-identical hashes. Antigravity diagnostic capture uses its fixed canonical target and the credential whose access token matches the resolved probe token, captured before I/O. Preparation failure may use a pre-resolution identity only if it still matches. Record an epoch-bound private isQuotaFailureCurrent callback; recheck before cache publication, cached reads, result assembly and API projection. A changed/missing/unreadable identity omits the diagnostic only, preserving current last-good quota/unavailable semantics. No private digest or callback serialized. Add optional nonenumerable quotaFailureIsCurrent to the internal ProviderAccountQuota result instead of using isCurrent, whose existing API branch invalidates the entire quota row. Cache/result quotaFailure appears only while unavailable and current. Tests reauth during delayed probe and cached failure after same-id replacement; stale diagnosis is omitted, not attached to replacement credentials. + +TUN-D source proposal labels refer to the read-only Faraday design outputs recorded in this task. The executable source of truth is this document's full type/flow contract, not an absent external file. No inference-wide authority is added. diff --git a/devlog/_plan/260912_accounts/071_tun_delivery.md b/devlog/_plan/260912_accounts/071_tun_delivery.md new file mode 100644 index 0000000000..cc5de963c4 --- /dev/null +++ b/devlog/_plan/260912_accounts/071_tun_delivery.md @@ -0,0 +1,9 @@ +# Safe Antigravity quota diagnostics + +The existing canonical transport stays intact. A private probe result preserves exported null/rejection compatibility and classifies the final attempted endpoint. Account failures carry only an allowlisted code, bound to the probed credential/project and invalidated independently from last-good bars. API, CLI and both dashboard quota views consume it; unknown values and local management failures stay generic. All nine locale dictionaries updated. + +Regression sources exercise status/typed transport categories, fallback recovery, old bars, same-id replacement during and after a probe, private-value serialization, API projection, GUI normalization/recovery and CLI code filtering. Local suites/build/typecheck/install: NOT RUN. Text whitespace checks only. Backend source reviewer Rawls found no material scoped defect; full consumer/security review and final hosted CI/render proof remain pending. Authenticated TUN field acceptance remains open for #3781; no live credential or network configuration was changed. + +Resume binding verified: session01a093dc-e20d-79a1-8d46-231ced9ee05b, currentaccounts-tun branch489af939 baseline, actual FSMB. Host goal is blocked and has not been edited or described as active. Existing successful callback CI34673984380/fa4226a9ba is retained and not rerun. Other original PRs have changed heads and require current evidence; no merged PR is recreated. + +Full source review found a diagnostic-specific merge regression gap. Added initial-roster-failure clearing, roster-only same-mode retention/mode-change clearing, and delayed failure/recovery after newer selection with surviving membership. No product behavior changed in this correction. Local suites NOT RUN. 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/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 576b13c1bb..e642700298 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -39,7 +39,7 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `codexAccountPriorities?` | `Record` | — | Ordre de sélection par compte pour le pool Codex : identifiant de compte → entier de `-100` à `100`, **les valeurs élevées sont prioritaires**, une valeur absente équivaut à `0`. Cette limite porte sur le classement, et non sur l'admissibilité : la sélection retient, parmi les comptes déjà admissibles, le niveau prioritaire le plus élevé qui dispose encore d'une marge de quota, puis `accountPoolStrategy` choisit un compte dans ce niveau. Un niveau est ignoré uniquement lorsque chacun de ses membres dépasse `autoSwitchThreshold`, est en temporisation, est temporairement évité, est suspendu ou doit être réauthentifié ; un quota inconnu ne suffit jamais à considérer un niveau comme épuisé. L'ordre ne rend jamais admissible un compte qui ne l'est pas et ne réaffecte jamais une tâche déjà liée à un compte. Le compte principal `__main__` participe selon les mêmes règles ; la connexion Codex Desktop peut ainsi être configurée pour être utilisée en dernier. Sans entrée, le pool se comporte exactement comme auparavant. Un mappage mal formé est ignoré avec un avertissement dans la console : l'ordre est désactivé et la configuration n'est pas réparée. Ce champ est géré par `ocx account priority` et la page Codex Auth. | | `activeCodexAccountPinned?` | `string` | — | Identifiant du compte du dernier opérateur sélectionné manuellement. Lorsqu'il est défini, un niveau `codexAccountPriorities` supérieur ne peut pas le préempter jusqu'à ce que la broche soit libérée par drainage, exclusion, suppression ou un failover/promotion explicite. Un mouvement circulaire ordinaire à l’intérieur du niveau plafonné ne le libère pas. L'écriture d'une entrée `codexAccountPriorities` libère également le pin, donc un pin créé avant qu'un ordre n'existe ne peut pas surpasser un ensemble par la suite. `GET /api/codex-auth/active` indique à la fois si le compte effectif est épinglé (`pinned`) et le compte portant le plafond (`pinnedAccountId`). | | `autoSwitchThreshold?` | `number` | `80` | Seuil d'utilisation pour la commutation proactive. `quota` peut réévaluer les requêtes non liées lors de leur prochaine requête et, par défaut, réévalue aussi les tâches liées une fois ce seuil franchi. Avec `pool.cacheAffinity` activé, une tâche liée conserve son compte au-delà du seuil jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir. `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. `reset-first`: Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation. Les resets mensuels ne déterminent pas cet ordre. | | `pool.cacheAffinity?` | `boolean` | `false` | Ordre d'affinité de cache optionnel pour les threads Codex liés, indépendant de `pool.kernel`. Désactivé par défaut ; une valeur mal formée est lue comme désactivée. Une fois activé, une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a franchi `autoSwitchThreshold`. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, ou réellement épuisé (utilisation connue à 100 %) — l'affinité est donc un réordonnancement, pas un verrouillage. | | `accountPoolStickyLimit?` | `number` | `1` | Nombre d'affectations de tâches nouvelles ou non liées conservées sur une même sélection tournante avant de passer à la suivante ; le compteur avance lorsqu'une tâche est liée, et non après une réponse réussie en amont. Plage : 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Nombre d'échecs transitoires consécutifs avant le basculement des futures nouvelles sessions. Réglez `0` pour désactiver ce mécanisme. Pour les requêtes Responses ordinaires et les envois compacts natifs, les échecs avérés d'accessibilité DNS/TCP avant connexion sont suivis au niveau du couple fournisseur-hôte : ils n'affectent jamais l'état ni la temporisation du compte, l'affinité de tâche ou de session, la sélection du compte actif ou le routage du pool, et ne sont jamais comptabilisés dans ce seuil. | 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/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 24bc7c5f3d..f14a038f88 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1042,3 +1042,9 @@ A provider using a non-canonical `baseUrl` is never sent the key for this probe. ## Large inline images on Chat providers Translated OpenAI-compatible Chat requests shrink inline images when their combined base64 data exceeds 3.5 MiB. Older images lose detail first. This is a best-effort image budget, so large text, schemas, or images that cannot be processed may still exceed an upstream request limit. Remote image URLs are not downloaded, and images that cannot be shrunk remain attached. Native Chat passthrough keeps its original image bytes. + +### Diagnosing an Antigravity quota refresh + +The account quota view and `ocx account list google-antigravity --quota --refresh` distinguish access denial, rate limiting, blocked destinations or redirects, DNS/connection/timeouts, and unusable quota data. Last-known bars remain visible with their observation time when a refresh fails. Reauthentication retires diagnoses from the previous credential; a successful refresh clears the failure. + +An access-denied result does not by itself prove an expired login or an ineligible plan. A blocked destination is a network-policy decision, not proof of a Fake-IP defect. Canonical Google quota destinations retain TLS verification and redirect/private-address restrictions. Authenticated TUN behavior must be checked in the affected environment; injected transport fixtures alone do not establish that field result. 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/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 5523430c80..31451fa18c 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -37,7 +37,7 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | | `codexAccountPriorities?` | `Record` | — | Codex pool のアカウント別選択順。アカウント ID → `-100` から `100` の整数で、**大きいほど先に使われ**、未設定は `0` です。これは eligibility ではなく順序の境界です。選択は適格なアカウントを、まだ quota に余裕がある最上位 tier に絞り込み、その tier の中を `accountPoolStrategy` が選びます。tier が飛ばされるのは、そのメンバー全員が `autoSwitchThreshold` 超過、cooldown 中、soft-avoid、一時停止、または再認証待ちのときだけで、usage 不明が tier を drain させることはありません。順序付けが不適格なアカウントを選択可能にすることはなく、すでにアカウントが結び付いた thread を再 bind することもありません。メインの `__main__` も同じ条件で参加するため、Codex Desktop ログインを最後に使わせられます。エントリが 1 つもなければ挙動は従来どおりです。map が不正な場合は警告を出して順序付けを無効にします(config の修復処理は走りません)。`ocx account priority` と Codex Auth ページで管理します。 | | `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価でき、既定では使用量がこのしきい値を超えると紐付け済みタスクも再評価します。`pool.cacheAffinity` がオンなら、紐付け済みタスクはアカウントが使い切られるか処理できなくなるまでしきい値超過後も同じアカウントを維持します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 `reset-first`: 使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。 月次リセットはこの順序に使用しません。 | | `pool.cacheAffinity?` | `boolean` | `false` | 紐付け済み Codex スレッド向けのオプトイン cache-affinity 順序。`pool.kernel` とは独立で、既定はオフです。不正な値はオフとして読みます。オンにすると live な紐付けが quota 余裕より優先されます。`quota` は使用量が `autoSwitchThreshold` を超えたという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れるので、affinity は固定ではなく並べ替えです。 | | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 | 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/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 887fdc8b0f..2c285b3cf7 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -446,3 +446,10 @@ opencodex를 로컬 OpenAI 호환 서버로 향하게 하세요 — 보통은 **Custom**을 선택하거나 `ocx init`에서 `custom`을 선택한 뒤 베이스 URL을 입력하세요. 모든 프로바이더 필드 (`headers`, `noReasoningModels`, `noVisionModels`, `models`, …)는 [설정 레퍼런스](/ko/reference/configuration/)를 참고하세요. + + +### Antigravity 쿼터 조회 실패 확인 + +계정 쿼터 화면과 `ocx account list google-antigravity --quota --refresh`는 접근 거부, 요청 한도, 목적지·리디렉션 차단, DNS·연결·시간 초과, 읽을 수 없는 응답을 구분합니다. 조회가 실패해도 마지막 관측 막대와 시각은 유지합니다. 재로그인하면 이전 자격 증명의 진단을 버리고, 조회에 성공하면 오류 표시를 지웁니다. + +접근 거부만으로 로그인 만료나 플랜 사용 불가를 단정하지 않습니다. 목적지 차단도 Fake-IP 결함의 증거는 아닙니다. Google의 고정 쿼터 주소에는 TLS 인증서 확인과 리디렉션·사설 주소 제한이 유지됩니다. 인증된 TUN 환경의 동작은 해당 환경에서 별도로 확인해야 합니다. 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/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index c63dfc9bc7..dae4836490 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -37,7 +37,7 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | | `codexAccountPriorities?` | `Record` | — | Codex pool의 계정별 선택 순서. 계정 ID → `-100`부터 `100`까지의 정수이며 **값이 클수록 먼저** 쓰이고, 항목이 없으면 `0`입니다. 이는 eligibility 경계가 아니라 순서 경계입니다. 선택은 이미 적격한 계정들을 quota 여유가 남은 최상위 tier로 좁히고, 그 tier 안에서 `accountPoolStrategy`가 계정을 고릅니다. tier를 건너뛰는 경우는 그 구성원 전부가 `autoSwitchThreshold` 초과, cooldown, soft-avoid, 일시 중지 또는 재인증 대기일 때뿐이며, usage를 알 수 없다고 해서 tier가 소진되지는 않습니다. 순서는 부적격 계정을 선택 가능하게 만들지 않고, 이미 계정에 묶인 thread를 다시 bind하지도 않습니다. 메인 `__main__` 계정도 동일한 조건으로 참여하므로 Codex Desktop 로그인을 마지막에 쓰도록 둘 수 있습니다. 항목이 하나도 없으면 동작은 이전과 같습니다. map이 잘못된 경우 경고를 출력하고 순서 지정을 끕니다(config 복구는 하지 않습니다). `ocx account priority`와 Codex Auth 페이지에서 관리합니다. | | `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있고, 기본값에서는 사용량이 이 임계값을 넘으면 바인딩된 작업도 재평가합니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 임계값을 넘어도 계정을 유지합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. `reset-first`: 사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다. 월간 초기화는 이 순서에 사용하지 않습니다. | | `pool.cacheAffinity?` | `boolean` | `false` | 바인딩된 Codex 스레드의 선택적 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 꺼짐입니다. 잘못된 값은 꺼진 것으로 읽습니다. 켜면 live 바인딩이 quota 여유보다 우선합니다. `quota`는 사용량이 `autoSwitchThreshold`를 넘었다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 여전히 떠나므로, affinity는 고정이 아니라 재정렬입니다. | | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. | 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/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index eb61194c94..8cb484a58e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -52,7 +52,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `codexAccountPriorities?` | `Record` | — | Per-account selection order for the Codex pool: account id → integer from `-100` to `100`, **higher is used earlier**, absent means `0`. This is an ordering boundary, not an eligibility one: selection narrows the already-eligible accounts to the highest tier that still has quota headroom, and `accountPoolStrategy` then picks within that tier. A tier is skipped only when every member is over `autoSwitchThreshold`, cooling down, soft-avoided, paused, or needs reauthentication — unknown quota never drains a tier. Ordering never makes an ineligible account selectable and never re-binds a thread that already has an account. The main `__main__` account participates on equal terms, which is how the Codex Desktop login can be set to drain last. With no entries the pool behaves exactly as before. A malformed map is ignored with a console warning (ordering off, no config repair). Managed by `ocx account priority` and the Codex Auth page. | | `activeCodexAccountPinned?` | `string` | — | Account id the operator last selected by hand. While set, a higher `codexAccountPriorities` tier cannot preempt it until the pin is released by drain, exclusion, deletion, or an explicit failover/promotion away. Ordinary round-robin movement inside the capped tier does not release it. Writing any `codexAccountPriorities` entry also releases the pin, so a pin made before an order existed cannot outrank one set afterward. `GET /api/codex-auth/active` reports both whether the effective account is pinned (`pinned`) and the account carrying the ceiling (`pinnedAccountId`). | | `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate unbound tasks on their next request, and by default also re-evaluates bound tasks once usage crosses this threshold. With `pool.cacheAffinity` on, a bound task keeps its account past the threshold until that account is exhausted or otherwise cannot serve. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. | | `pool.cacheAffinity?` | `boolean` | `false` | Opt-in cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. Off by default; a malformed value reads as off. With it on, a live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — so affinity is a reordering, not a pin. | | `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. | 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/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 9d132b0e94..d471f31c25 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -38,7 +38,7 @@ ocx models provider openrouter on | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | | `codexAccountPriorities?` | `Record` | — | Порядок выбора для каждого аккаунта пула Codex: id аккаунта → целое число от `-100` до `100`, **больше — используется раньше**, отсутствие означает `0`. Это граница порядка, а не пригодности: выбор сужает уже подходящие аккаунты до самого высокого уровня, у которого ещё есть запас квоты, а внутри этого уровня аккаунт выбирает `accountPoolStrategy`. Уровень пропускается, только когда все его аккаунты превысили `autoSwitchThreshold`, находятся в cooldown, под soft-avoid, на паузе или требуют повторной аутентификации; неизвестный usage никогда не исчерпывает уровень. Порядок не делает выбираемым непригодный аккаунт и не перепривязывает поток, у которого аккаунт уже есть. Основной аккаунт `__main__` участвует на равных — именно так логин Codex Desktop можно оставить на самый конец. Без записей поведение остаётся прежним. Некорректная map игнорируется с предупреждением в консоли (порядок отключается, восстановление config не запускается). Управляется через `ocx account priority` и страницу Codex Auth. | | `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос, а по умолчанию — и привязанную задачу, когда usage пересекает этот порог. При включённом `pool.cacheAffinity` привязанная задача сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. `reset-first`: Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию. Месячный сброс не определяет этот порядок. | | `pool.cacheAffinity?` | `boolean` | `false` | Опциональный порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию выключен; некорректное значение читается как выключенное. Когда флаг включён, живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage пересёк `autoSwitchThreshold`. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден или реально исчерпан (известный usage 100%). Affinity меняет порядок, а не закрепляет учётные данные. | | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. | 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/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 3d72f129a7..05cb47c56e 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -39,7 +39,7 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `codexAccountPriorities?` | `Record` | — | Codex havuzu için hesap başına seçim sırası: hesap kimliği → `-100` ile `100` arası tam sayı, **daha yüksek olan daha önce kullanılır**, yoksa `0` anlamına gelir. Bu bir öncelik sırası sınırıdır, bir uygunluk sınırı değildir: seçim, zaten uygun olan hesapları hala kota payı bulunan en yüksek katmana daraltır ve `accountPoolStrategy` daha sonra bu katman içinde seçim yapar. Bir katman, yalnızca her üye `autoSwitchThreshold` üzerinde olduğunda, soğumada olduğunda, yumuşak kaçınıldığında, duraklatıldığında veya yeniden kimlik doğrulama gerektiğinde atlanır — bilinmeyen kota asla bir katmanı boşaltmaz. Sıralama asla uygun olmayan bir hesabı seçilebilir yapmaz ve zaten bir hesabı olan bir iş parçacığını asla yeniden bağlamaz. Ana `__main__` hesap eşit şartlarda katılır, bu sayede Codex Desktop girişi en son tükenecek şekilde ayarlanabilir. Hiçbir girdi olmadığında havuz tam olarak eskisi gibi davranır. Hatalı biçimlendirilmiş bir harita bir konsol uyarısıyla yok sayılır (sıralama kapalı, yapılandırma onarımı yok). `ocx account priority` ve Codex Auth sayfası tarafından yönetilir. | | `activeCodexAccountPinned?` | `string` | — | Operatörün en son elle seçtiği hesap kimliği. Ayarlandığı sürece, pin tükenme, hariç tutma, silme veya açık bir yük devretme/yükseltme ile serbest bırakılana kadar daha yüksek bir `codexAccountPriorities` katmanı onu öncelikleyemez. Sınırlı katman içindeki sıradan round-robin hareketi onu serbest bırakmaz. Herhangi bir `codexAccountPriorities` girdisi yazmak da pini serbest bırakır, böylece bir sıra var olmadan önce yapılan bir pin daha sonra ayarlanan bir pinin önüne geçemez. `GET /api/codex-auth/active`, hem geçerli hesabın sabitlenip sabitlenmediğini (`pinned`) hem de tavanı taşıyan hesabı (`pinnedAccountId`) bildirir. | | `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için kullanım eşiği. `quota`, bağımsız görevlerin bir sonraki isteğini yeniden değerlendirebilir ve varsayılan olarak kullanım bu eşiği geçince bağlı görevleri de yeniden değerlendirir. `pool.cacheAffinity` açıkken bağlı bir görev, hesap tükenene veya hizmet veremez hale gelene kadar eşiğin ötesinde hesabını korur. `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. `reset-first`: Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır. Aylık sıfırlamalar bu sıralamayı belirlemez. | | `pool.cacheAffinity?` | `boolean` | `false` | Bağlı Codex iş parçacıkları için isteğe bağlı önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak kapalıdır; hatalı bir değer kapalı okunur. Açıkken canlı bağlama kota payından öndedir: `quota`, kullanımın `autoSwitchThreshold`'u geçmesi nedeniyle iş parçacığını taşımaz. Hesap duraklatılmış, kullanılamaz veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır; bağlılık bir sabitleme değil yeniden sıralamadır. | | `accountPoolStickyLimit?` | `number` | `1` | İlerlemeden önce bir round-robin seçiminde tutulan yeni/bağımsız görev atamaları; sayaç yukarı akış başarısından sonra değil, bir görev bağlandığında ilerler. Aralık 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Gelecekteki yeni oturumların yük devretmesinden önceki ardışık geçici arızalar. Devre dışı bırakmak için `0` ayarlayın. Düzenli Responses ve yerel sıkıştırma gönderimleri için kanıtlanmış bağlantı öncesi DNS/TCP erişilebilirlik arızaları sağlayıcı-ana bilgisayar düzeyinde izlenir: hesap sağlığını, hesap soğuma sürelerini, iş parçacığı/oturum bağlılığını, aktif hesap seçimini veya Havuz yönlendirmesini asla etkilemez ve bu eşiğe asla sayılmaz. | 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-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 9d0ed2dd76..62e8d4447c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -37,7 +37,7 @@ ocx models provider openrouter on | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | | `codexAccountPriorities?` | `Record` | — | Codex pool 各账号的选择顺序:账号 ID → `-100` 到 `100` 的整数,**数值越大越先使用**,未设置即为 `0`。这是顺序边界而非资格边界:选择会把已经合格的账号收窄到仍有 quota 余量的最高 tier,再由 `accountPoolStrategy` 在该 tier 内挑选。只有当某个 tier 的所有成员都超过 `autoSwitchThreshold`、处于 cooldown、被 soft-avoid、已暂停或需要重新认证时,该 tier 才会被跳过;usage 未知不会让 tier 耗尽。顺序不会让不合格的账号变得可选,也不会重新绑定已经绑定账号的 thread。主账号 `__main__` 同样参与排序,因此可以让 Codex Desktop 登录账号最后才被用到。没有任何条目时,行为与以往完全一致。映射格式非法时会打印警告并关闭排序(不会触发 config 修复)。可通过 `ocx account priority` 和 Codex Auth 页面管理。 | | `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务;默认在用量越过该阈值时也会重新评估已绑定任务。开启 `pool.cacheAffinity` 后,已绑定任务在越过阈值后仍会保留账号,直到该账号耗尽或无法继续服务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 `reset-first`: 在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。 此排序不使用月额度重置时间。 | | `pool.cacheAffinity?` | `boolean` | `false` | 已绑定 Codex 线程的可选 cache-affinity 排序,独立于 `pool.kernel`。默认关闭;非法值视为关闭。开启后,live 绑定优先于 quota 余量:`quota` 不会仅因用量越过 `autoSwitchThreshold` 就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,因此 affinity 是重排而非钉死。 | | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 | 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/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index ff67793990..a5ca056c18 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -35,7 +35,7 @@ ocx models provider openrouter on | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | | `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務,且預設在用量越過此閾值時也會重新評估綁定任務。開啟 `pool.cacheAffinity` 後,綁定任務在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 `reset-first`: 在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。 此排序不使用月額度重設時間。 | | `pool.cacheAffinity?` | `boolean` | `false` | 綁定 Codex 執行緒的選擇性 cache-affinity 排序,獨立於 `pool.kernel`。預設關閉;格式錯誤視為關閉。開啟後,即時綁定優先於配額餘裕:`quota` 不會只因用量越過 `autoSwitchThreshold` 就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,因此親和性是重排而非釘死。 | | `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 | | `upstreamFailoverThreshold?` | `number` | `3` | 未來新 session 容錯移轉前的連續暫時性失敗。設 `0` 停用。 | diff --git a/gui/src/account-pool-strategy.ts b/gui/src/account-pool-strategy.ts index b2532b0fc7..ef2de0a670 100644 --- a/gui/src/account-pool-strategy.ts +++ b/gui/src/account-pool-strategy.ts @@ -1,9 +1,10 @@ -export type AccountPoolStrategy = "quota" | "round-robin" | "fill-first"; +export type AccountPoolStrategy = "quota" | "round-robin" | "fill-first" | "reset-first"; export const ACCOUNT_POOL_STRATEGIES: readonly AccountPoolStrategy[] = [ "quota", "round-robin", "fill-first", + "reset-first", ] as const; /** Which cached usage bar the `quota` strategy scores. Mirrors `OcxAccountPoolQuotaWindow`. */ diff --git a/gui/src/components/AccountPoolStrategyControls.tsx b/gui/src/components/AccountPoolStrategyControls.tsx index d5023ca43f..2a813bcc4e 100644 --- a/gui/src/components/AccountPoolStrategyControls.tsx +++ b/gui/src/components/AccountPoolStrategyControls.tsx @@ -8,12 +8,14 @@ import { NumberStepper } from "./NumberStepper"; import { Select } from "../ui"; const STRATEGY_LABEL_KEYS = { + "reset-first": "accountPool.strategyResetFirst", quota: "accountPool.strategyQuota", "round-robin": "accountPool.strategyRoundRobin", "fill-first": "accountPool.strategyFillFirst", } as const; const STRATEGY_HINT_KEYS = { + "reset-first": "accountPool.strategyHintResetFirst", quota: "accountPool.strategyHintQuota", "round-robin": "accountPool.strategyHintRoundRobin", "fill-first": "accountPool.strategyHintFillFirst", @@ -21,6 +23,7 @@ const STRATEGY_HINT_KEYS = { export interface AccountPoolStrategyControlsProps { strategy: AccountPoolStrategy; + codex?: boolean; stickyDraft: string; disabled?: boolean; strategySelectId?: string; @@ -41,6 +44,7 @@ export interface AccountPoolStrategyControlsProps { */ export default function AccountPoolStrategyControls({ strategy, + codex = false, stickyDraft, disabled = false, strategySelectId = "account-pool-strategy", @@ -50,7 +54,7 @@ export default function AccountPoolStrategyControls({ onStickyCommit, }: AccountPoolStrategyControlsProps) { const t = useT(); - const strategyOptions = ACCOUNT_POOL_STRATEGIES.map((value) => ({ + const strategyOptions = ACCOUNT_POOL_STRATEGIES.filter(value => codex || value !== "reset-first").map((value) => ({ value, label: t(STRATEGY_LABEL_KEYS[value]), })); diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index f211f1c689..f2503c71c6 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -63,7 +63,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban invalid: t("codexAuth.autoSwitchThresholdInvalid"), }); const [poolStrategy, setPoolStrategy] = useState< - typeof DEFAULT_ACCOUNT_POOL_STRATEGY | "round-robin" | "fill-first" | null + typeof DEFAULT_ACCOUNT_POOL_STRATEGY | "round-robin" | "fill-first" | "reset-first" | null >(null); const { beginServerRead, acceptServerRead, rejectServerRead, hydrateServerValue } = autoSwitch; // A hook cannot be called conditionally, so the fallback instance is always created diff --git a/gui/src/components/CodexAutoSwitchSetting.tsx b/gui/src/components/CodexAutoSwitchSetting.tsx index 76d10825d5..cf1bcfcdf2 100644 --- a/gui/src/components/CodexAutoSwitchSetting.tsx +++ b/gui/src/components/CodexAutoSwitchSetting.tsx @@ -7,6 +7,10 @@ import { NumberStepper } from "./NumberStepper"; export type AutoSwitchFeedback = { tone: "ok" | "err"; message: string } | null; const AUTO_SWITCH_DESCRIPTION_KEYS = { + "reset-first": { + on: "accountPool.strategyHintResetFirst", + off: "codexAuth.autoSwitchQuotaOffDesc", + }, quota: { on: "codexAuth.autoSwitchQuotaDesc", off: "codexAuth.autoSwitchQuotaOffDesc", diff --git a/gui/src/components/CodexPoolStrategySetting.tsx b/gui/src/components/CodexPoolStrategySetting.tsx index e575baed0c..6e44d793ce 100644 --- a/gui/src/components/CodexPoolStrategySetting.tsx +++ b/gui/src/components/CodexPoolStrategySetting.tsx @@ -213,6 +213,7 @@ export default function CodexPoolStrategySetting({ )} {!loadError && ( {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/components/provider-workspace/ProviderAccountQuota.tsx b/gui/src/components/provider-workspace/ProviderAccountQuota.tsx index 42f4d12af2..dc1f7f9a7b 100644 --- a/gui/src/components/provider-workspace/ProviderAccountQuota.tsx +++ b/gui/src/components/provider-workspace/ProviderAccountQuota.tsx @@ -1,3 +1,4 @@ +import { parseQuotaFailureCode } from "../../../../src/providers/quota-types"; import { useT } from "../../i18n/shared"; import { accountQuotaFromReport } from "../../provider-workspace/report"; import { formatRelativeTime, relativeTimeLabelsFromT } from "../../provider-workspace/usage"; @@ -5,16 +6,17 @@ import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; import type { AccountQuotaReading } from "./types"; /** The same reading states and credit/window renderer for current and all-account views. */ -export default function ProviderAccountQuota({ quota: rawQuota, quotaMode, quotaUnavailable, quotaPending }: AccountQuotaReading) { +export default function ProviderAccountQuota({ quota: rawQuota, quotaMode, quotaUnavailable, quotaPending, quotaFailure }: AccountQuotaReading) { const t = useT(); const quota = accountQuotaFromReport({ quota: rawQuota }); if (quotaMode === "unsupported") { return

{t("pws.quotaUnsupported")}

; } + const failure = quotaMode === "probe" && quotaUnavailable ? parseQuotaFailureCode(quotaFailure) : undefined; const pending = quotaMode === "probe" && quotaPending === true; const state = quotaUnavailable ? "unavailable" : pending ? "pending" : quota ? "ready" : quotaMode === "passive" ? "unobserved" : "unknown"; return
- {quotaUnavailable &&

{t("pws.accountQuotaUnavailable")}

} + {quotaUnavailable &&

{t(failure ? `pws.quotaFailure.${failure}` : "pws.accountQuotaUnavailable")}

} {quota || pending ? (
+ quotaUnavailable={account.quotaUnavailable} quotaPending={account.quotaPending} quotaFailure={account.quotaFailure} />
); @@ -657,7 +657,7 @@ export default function ProviderAuthPanel({
+ quotaUnavailable={entry.quotaUnavailable} quotaPending={entry.quotaPending} quotaFailure={entry.quotaFailure} />
))} diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index e722d10dd7..6f913e1bcf 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -1,3 +1,4 @@ +import type { QuotaFailureCode } from "../../../../src/providers/quota-types"; /** * provider-workspace/types.ts — shared view-model types for the Providers * workspace shell/rail/detail (WP080a). Data shapes only; no React. @@ -46,6 +47,7 @@ export interface AccountQuotaReading { quotaMode?: AccountQuotaMode; quota?: AccountQuota | null; quotaUnavailable?: boolean; + quotaFailure?: QuotaFailureCode; /** Client-owned enrichment state, never inferred from missing quota data. */ quotaPending?: boolean; } 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/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index 0197a547d5..393727d094 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -1,3 +1,4 @@ +import { parseQuotaFailureCode } from "../../../src/providers/quota-types"; import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import type { AccountLoadState, AccountQuotaReading } from "../components/provider-workspace/types"; import { createBoundedFetch } from "../bounded-fetch"; @@ -36,7 +37,9 @@ function mergeRosterRows(rows: T[], previous: T[]): T[] { return mergeQuotaRows(rows, previous, false).map(row => supportsQuotaRead(row) ? { ...row, quotaPending: prior.get(row.id)?.quotaPending ?? false, - quotaUnavailable: prior.get(row.id)?.quotaUnavailable ?? false, + quotaUnavailable: prior.get(row.id)?.quotaMode === row.quotaMode ? prior.get(row.id)?.quotaUnavailable ?? false : false, + quotaFailure: row.quotaMode === "probe" && prior.get(row.id)?.quotaMode === row.quotaMode && prior.get(row.id)?.quotaUnavailable + ? parseQuotaFailureCode(prior.get(row.id)?.quotaFailure) : undefined, } : row); } @@ -47,7 +50,7 @@ function mergeLateQuotaRows(rows: T[], enriched: T[]): T[] { const incoming = byId.get(row.id); if (!incoming || incoming.quotaMode !== row.quotaMode) return row; const quota = mergeQuotaRows([incoming], [row], true)[0]; - return { ...row, quota: quota.quota, quotaPending: quota.quotaPending, quotaUnavailable: quota.quotaUnavailable }; + return { ...row, quota: quota.quota, quotaPending: quota.quotaPending, quotaUnavailable: quota.quotaUnavailable, quotaFailure: quota.quotaFailure }; }); } @@ -60,7 +63,7 @@ function mergeQuotaRows(rows: T[], previous: T[], enriched: const supported = supportsQuotaRead(row); // Legacy/unknown mode must not acquire synthetic flags that would override // a provider report or imply that a quota probe is supported. - if (!supported && row.quotaMode !== "unsupported") return { ...row, quotaMode: undefined, quotaPending: undefined }; + if (!supported && row.quotaMode !== "unsupported") return { ...row, quotaMode: undefined, quotaPending: undefined, quotaFailure: undefined }; // Only surviving credential IDs can retain omitted data. Explicit null is an // authoritative invalidation, including failed/expired credential readings. const retain = supported && (!enriched || row.quotaUnavailable === true); @@ -69,6 +72,8 @@ function mergeQuotaRows(rows: T[], previous: T[], enriched: quota: row.quotaMode === "unsupported" ? null : row.quota !== undefined ? row.quota : retain ? prior.get(row.id)?.quota : undefined, quotaPending: !enriched && row.quotaMode === "probe", quotaUnavailable: enriched ? row.quotaUnavailable === true : false, + quotaFailure: enriched && row.quotaMode === "probe" && row.quotaUnavailable === true + ? parseQuotaFailureCode(row.quotaFailure) : undefined, }; }); } @@ -76,7 +81,7 @@ function mergeQuotaRows(rows: T[], previous: T[], enriched: function unavailableQuotaRows(rows: T[], attempted?: T[]): T[] { const attemptedModes = attempted && new Map(attempted.map(row => [row.id, row.quotaMode])); return rows.map(row => supportsQuotaRead(row) && (!attemptedModes || attemptedModes.get(row.id) === row.quotaMode) - ? { ...row, quotaUnavailable: true, quotaPending: false } + ? { ...row, quotaUnavailable: true, quotaPending: false, quotaFailure: undefined } : row); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4a7833a0b4..b406634007 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.", @@ -1441,6 +1443,8 @@ export const de: Record = { "accountPool.strategy": "Rotationsstrategie", "accountPool.strategyDesc": "Wie OpenCodex einer neuen/ungebundenen Aufgabe ein Konto zuweist.", + "accountPool.strategyResetFirst": "Nächste Rücksetzung zuerst", + "accountPool.strategyHintResetFirst": "Unterhalb der Nutzungsschwelle wird die nächste 5-Stunden- oder Wochenrücksetzung bevorzugt. Gebundene Aufgaben folgen der konfigurierten Affinitätsregel. Unabhängige Modellkontingente werden nach Nutzung geordnet.", "accountPool.strategyQuota": "Kontingent", "accountPool.strategyRoundRobin": "Round-Robin", "accountPool.strategyFillFirst": "Fill-first", @@ -2102,6 +2106,16 @@ export const de: Record = { "pws.rateLimits": "Limits", "pws.quotaUnavailable": "Keine Kontingentdaten für diesen Provider.", "pws.accountQuotaUnavailable": "Ratenlimit-Daten vorübergehend nicht verfügbar; falls vorhanden, werden zuletzt bekannte Werte angezeigt.", + "pws.quotaFailure.account_unavailable": "Kontodaten für die Kontingentprüfung sind nicht verfügbar.", + "pws.quotaFailure.access_denied": "Der Anbieter verweigert den Zugriff auf Kontingentdaten.", + "pws.quotaFailure.rate_limited": "Die Kontingentprüfung wurde durch ein Anfragelimit begrenzt.", + "pws.quotaFailure.upstream_error": "Der Anbieter konnte die Kontingentprüfung nicht abschließen.", + "pws.quotaFailure.redirect_blocked": "Eine Weiterleitung des Kontingentendpunkts wurde blockiert.", + "pws.quotaFailure.destination_blocked": "Die Netzwerkrichtlinie blockiert das Kontingentziel.", + "pws.quotaFailure.dns_failed": "Der Hostname des Kontingentservers konnte nicht aufgelöst werden.", + "pws.quotaFailure.timeout": "Die Kontingentanfrage hat das Zeitlimit überschritten.", + "pws.quotaFailure.transport_error": "Die Verbindung zum Kontingentserver ist fehlgeschlagen.", + "pws.quotaFailure.response_unusable": "Der Anbieter lieferte unbrauchbare Kontingentdaten.", "pws.selected": "Ausgewählt", "pws.copyModelId": "ID kopieren", "pws.modelCopied": "Kopiert!", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 96e30de557..0b73d40745 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1314,6 +1314,16 @@ export const en = { "pws.rateLimits": "Rate limits", "pws.quotaUnavailable": "No quota data for this provider.", "pws.accountQuotaUnavailable": "Rate-limit data temporarily unavailable; showing last known values when present.", + "pws.quotaFailure.account_unavailable": "Account details are unavailable for this quota check.", + "pws.quotaFailure.access_denied": "The provider denied access to quota data.", + "pws.quotaFailure.rate_limited": "The provider rate-limited the quota check.", + "pws.quotaFailure.upstream_error": "The provider could not complete the quota check.", + "pws.quotaFailure.redirect_blocked": "The quota endpoint returned a blocked redirect.", + "pws.quotaFailure.destination_blocked": "The quota destination was blocked by network policy.", + "pws.quotaFailure.dns_failed": "The quota hostname could not be resolved.", + "pws.quotaFailure.timeout": "The quota request timed out.", + "pws.quotaFailure.transport_error": "The quota connection failed.", + "pws.quotaFailure.response_unusable": "The provider returned unusable quota data.", "pws.selected": "Selected", "pws.copyModelId": "Copy ID", "pws.modelCopied": "Copied!", @@ -1937,6 +1947,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.", @@ -2017,6 +2029,8 @@ export const en = { "accountPool.strategy": "Rotation strategy", "accountPool.strategyDesc": "How OpenCodex assigns an account to a new/unbound task.", + "accountPool.strategyResetFirst": "Soonest reset first", + "accountPool.strategyHintResetFirst": "Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering.", "accountPool.strategyQuota": "Quota", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "Fill-first", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 0dc86dbaae..c9cedcce64 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1287,6 +1287,16 @@ export const fr: Record = { "pws.rateLimits": "Limites de débit", "pws.quotaUnavailable": "Aucune donnée de quota pour ce fournisseur.", "pws.accountQuotaUnavailable": "Données de limite de débit temporairement indisponibles ; affichage des dernières valeurs connues, le cas échéant.", + "pws.quotaFailure.account_unavailable": "Les données du compte sont indisponibles pour cette vérification.", + "pws.quotaFailure.access_denied": "Le fournisseur a refusé l’accès aux données de quota.", + "pws.quotaFailure.rate_limited": "Le fournisseur a limité la fréquence des vérifications de quota.", + "pws.quotaFailure.upstream_error": "Le fournisseur n’a pas pu vérifier le quota.", + "pws.quotaFailure.redirect_blocked": "La redirection du service de quota a été bloquée.", + "pws.quotaFailure.destination_blocked": "La politique réseau a bloqué la destination du quota.", + "pws.quotaFailure.dns_failed": "Le nom du serveur de quota n’a pas pu être résolu.", + "pws.quotaFailure.timeout": "La requête de quota a expiré.", + "pws.quotaFailure.transport_error": "La connexion au service de quota a échoué.", + "pws.quotaFailure.response_unusable": "Les données de quota reçues sont inutilisables.", "pws.selected": "Sélectionné", "pws.copyModelId": "Copier l’ID", "pws.modelCopied": "Copié !", @@ -1869,6 +1879,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.", @@ -1947,6 +1959,8 @@ export const fr: Record = { "anthropicPool.off": "Désactivé", "accountPool.strategy": "Stratégie de rotation", "accountPool.strategyDesc": "Méthode utilisée par OpenCodex pour affecter un compte à une tâche nouvelle/non liée.", + "accountPool.strategyResetFirst": "Réinitialisation la plus proche", + "accountPool.strategyHintResetFirst": "Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation.", "accountPool.strategyQuota": "Quota", "accountPool.strategyRoundRobin": "Rotation", "accountPool.strategyFillFirst": "Remplissage prioritaire", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 97b5dff281..e4ad74c8d6 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1212,6 +1212,16 @@ export const ja: Record = { "pws.rateLimits": "レート制限", "pws.quotaUnavailable": "このプロバイダーのクォータデータがありません。", "pws.accountQuotaUnavailable": "レート制限データを一時的に取得できません。前回の値がある場合はそれを表示します。", + "pws.quotaFailure.account_unavailable": "割り当て確認に必要なアカウント情報を利用できません。", + "pws.quotaFailure.access_denied": "プロバイダーが割り当てデータへのアクセスを拒否しました。", + "pws.quotaFailure.rate_limited": "割り当て確認のレート制限に達しました。", + "pws.quotaFailure.upstream_error": "プロバイダーが割り当て確認を完了できませんでした。", + "pws.quotaFailure.redirect_blocked": "割り当てエンドポイントのリダイレクトをブロックしました。", + "pws.quotaFailure.destination_blocked": "ネットワークポリシーが割り当ての接続先をブロックしました。", + "pws.quotaFailure.dns_failed": "割り当てサーバーのホスト名を解決できませんでした。", + "pws.quotaFailure.timeout": "割り当てリクエストがタイムアウトしました。", + "pws.quotaFailure.transport_error": "割り当てサーバーへの接続に失敗しました。", + "pws.quotaFailure.response_unusable": "割り当てデータを読み取れませんでした。", "pws.selected": "選択中", "pws.copyModelId": "ID をコピー", "pws.modelCopied": "コピーしました!", @@ -1794,6 +1804,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} を一時停止できませんでした。変更はありません。", @@ -1874,6 +1886,8 @@ export const ja: Record = { "accountPool.strategy": "ローテーション戦略", "accountPool.strategyDesc": "OpenCodex が新規/未紐付けタスクへアカウントを割り当てる方法です。", + "accountPool.strategyResetFirst": "リセットが近い順", + "accountPool.strategyHintResetFirst": "使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。", "accountPool.strategyQuota": "クォータ", "accountPool.strategyRoundRobin": "ラウンドロビン", "accountPool.strategyFillFirst": "フィルファースト", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 0957274d6b..cd42677bd9 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} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", @@ -1477,6 +1479,8 @@ export const ko: Record = { "accountPool.strategy": "로테이션 전략", "accountPool.strategyDesc": "OpenCodex가 새 작업/바인딩 없는 작업에 계정을 배정하는 방식입니다.", + "accountPool.strategyResetFirst": "가장 가까운 초기화 우선", + "accountPool.strategyHintResetFirst": "사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다.", "accountPool.strategyQuota": "할당량", "accountPool.strategyRoundRobin": "라운드로빈", "accountPool.strategyFillFirst": "필 퍼스트", @@ -2141,6 +2145,16 @@ export const ko: Record = { "pws.rateLimits": "요청 한도", "pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.", "pws.accountQuotaUnavailable": "요금 한도 데이터를 일시적으로 가져올 수 없습니다. 이전 값이 있으면 그대로 표시합니다.", + "pws.quotaFailure.account_unavailable": "쿼터 조회에 필요한 계정 정보를 사용할 수 없습니다.", + "pws.quotaFailure.access_denied": "제공자가 쿼터 데이터 접근을 거부했습니다.", + "pws.quotaFailure.rate_limited": "제공자의 쿼터 조회 요청 한도에 도달했습니다.", + "pws.quotaFailure.upstream_error": "제공자가 쿼터 조회를 처리하지 못했습니다.", + "pws.quotaFailure.redirect_blocked": "쿼터 주소의 리디렉션이 차단됐습니다.", + "pws.quotaFailure.destination_blocked": "네트워크 정책이 쿼터 조회 목적지를 차단했습니다.", + "pws.quotaFailure.dns_failed": "쿼터 서버의 호스트 이름을 확인하지 못했습니다.", + "pws.quotaFailure.timeout": "쿼터 조회 시간이 초과됐습니다.", + "pws.quotaFailure.transport_error": "쿼터 서버에 연결하지 못했습니다.", + "pws.quotaFailure.response_unusable": "제공자가 반환한 쿼터 데이터를 읽을 수 없습니다.", "pws.selected": "선택됨", "pws.copyModelId": "ID 복사", "pws.modelCopied": "복사됨!", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 464c71d7ac..8cafaa9d9e 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1282,6 +1282,16 @@ export const ru: Record = { "pws.rateLimits": "Лимиты запросов", "pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.", "pws.accountQuotaUnavailable": "Данные о лимитах временно недоступны; при наличии показываются последние известные значения.", + "pws.quotaFailure.account_unavailable": "Данные аккаунта для проверки квоты недоступны.", + "pws.quotaFailure.access_denied": "Провайдер запретил доступ к данным квоты.", + "pws.quotaFailure.rate_limited": "Превышен лимит запросов квоты.", + "pws.quotaFailure.upstream_error": "Провайдер не смог проверить квоту.", + "pws.quotaFailure.redirect_blocked": "Перенаправление сервиса квоты заблокировано.", + "pws.quotaFailure.destination_blocked": "Сетевая политика заблокировала адрес сервиса квоты.", + "pws.quotaFailure.dns_failed": "Не удалось разрешить имя сервера квоты.", + "pws.quotaFailure.timeout": "Время ожидания запроса квоты истекло.", + "pws.quotaFailure.transport_error": "Не удалось подключиться к сервису квоты.", + "pws.quotaFailure.response_unusable": "Провайдер вернул непригодные данные квоты.", "pws.selected": "Выбрана", "pws.copyModelId": "Копировать ID", "pws.modelCopied": "Скопировано!", @@ -1864,6 +1874,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}. Изменений нет.", @@ -1944,6 +1956,8 @@ export const ru: Record = { "accountPool.strategy": "Стратегия ротации", "accountPool.strategyDesc": "Как OpenCodex назначает аккаунт новой/непривязанной задаче.", + "accountPool.strategyResetFirst": "Ближайший сброс первым", + "accountPool.strategyHintResetFirst": "Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию.", "accountPool.strategyQuota": "Квота", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "Fill-first", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 3e3c12358f..61b7be81bf 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1301,6 +1301,16 @@ export const tr: Record = { "pws.rateLimits": "Oran limitleri", "pws.quotaUnavailable": "Bu sağlayıcı için kota verisi yok.", "pws.accountQuotaUnavailable": "Oran limiti verisi geçici olarak kullanılamıyor.", + "pws.quotaFailure.account_unavailable": "Kota denetimi için hesap bilgileri kullanılamıyor.", + "pws.quotaFailure.access_denied": "Sağlayıcı kota verilerine erişimi reddetti.", + "pws.quotaFailure.rate_limited": "Kota denetimi istek sınırına ulaştı.", + "pws.quotaFailure.upstream_error": "Sağlayıcı kota denetimini tamamlayamadı.", + "pws.quotaFailure.redirect_blocked": "Kota uç noktasının yönlendirmesi engellendi.", + "pws.quotaFailure.destination_blocked": "Ağ ilkesi kota hedefini engelledi.", + "pws.quotaFailure.dns_failed": "Kota sunucusunun ana bilgisayar adı çözümlenemedi.", + "pws.quotaFailure.timeout": "Kota isteği zaman aşımına uğradı.", + "pws.quotaFailure.transport_error": "Kota bağlantısı başarısız oldu.", + "pws.quotaFailure.response_unusable": "Sağlayıcı kullanılamayan kota verileri döndürdü.", "pws.selected": "Seçilen", "pws.copyModelId": "ID Kopyala", "pws.modelCopied": "Kopyalandı!", @@ -1894,6 +1904,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ı.", @@ -1963,6 +1975,8 @@ export const tr: Record = { "accountPool.strategy": "Rotasyon stratejisi", "accountPool.strategyDesc": "OpenCodex'in yeni bir göreve nasıl hesap atayacağı.", + "accountPool.strategyResetFirst": "En yakın sıfırlama önce", + "accountPool.strategyHintResetFirst": "Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır.", "accountPool.strategyQuota": "Kota", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "İlk doldurma", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a0d0767dc3..ec7cc36ff0 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1079,6 +1079,16 @@ export const zhTW: Record = { "pws.rateLimits": "速率限制", "pws.quotaUnavailable": "此供應商暫無配額資料。", "pws.accountQuotaUnavailable": "速率限制資料暫時不可用;若有上次已知值則繼續顯示。", + "pws.quotaFailure.account_unavailable": "無法取得配額查詢所需的帳號資訊。", + "pws.quotaFailure.access_denied": "供應商拒絕存取配額資料。", + "pws.quotaFailure.rate_limited": "配額查詢觸發了請求頻率限制。", + "pws.quotaFailure.upstream_error": "供應商未能完成配額查詢。", + "pws.quotaFailure.redirect_blocked": "配額端點的重新導向已被封鎖。", + "pws.quotaFailure.destination_blocked": "網路政策封鎖了配額查詢目的地。", + "pws.quotaFailure.dns_failed": "無法解析配額伺服器的主機名稱。", + "pws.quotaFailure.timeout": "配額請求逾時。", + "pws.quotaFailure.transport_error": "配額連線失敗。", + "pws.quotaFailure.response_unusable": "供應商回傳的配額資料無法使用。", "pws.selected": "已選擇", "pws.copyModelId": "複製 ID", "pws.modelCopied": "已複製!", @@ -1482,6 +1492,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},未做任何變更。", @@ -1503,6 +1515,8 @@ export const zhTW: Record = { "anthropicPool.off": "關", "accountPool.strategy": "輪換策略", "accountPool.strategyDesc": "新會話如何從帳號池中選擇帳號。", + "accountPool.strategyResetFirst": "額度即將重設優先", + "accountPool.strategyHintResetFirst": "在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。", "accountPool.strategyQuota": "配額", "accountPool.strategyRoundRobin": "輪詢", "accountPool.strategyFillFirst": "填滿優先", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index da66e4f8da..6c029ed61e 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},未做任何更改。", @@ -1458,6 +1460,8 @@ export const zh: Record = { "accountPool.strategy": "轮换策略", "accountPool.strategyDesc": "OpenCodex 如何为新建/未绑定任务分配账号。", + "accountPool.strategyResetFirst": "额度即将刷新优先", + "accountPool.strategyHintResetFirst": "在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。", "accountPool.strategyQuota": "配额", "accountPool.strategyRoundRobin": "轮询", "accountPool.strategyFillFirst": "填满优先", @@ -2122,6 +2126,16 @@ export const zh: Record = { "pws.rateLimits": "速率限制", "pws.quotaUnavailable": "此提供商暂无配额数据。", "pws.accountQuotaUnavailable": "速率限制数据暂时不可用;若有上次已知值则继续显示。", + "pws.quotaFailure.account_unavailable": "无法获取配额查询所需的账号信息。", + "pws.quotaFailure.access_denied": "提供商拒绝访问配额数据。", + "pws.quotaFailure.rate_limited": "配额查询触发了请求频率限制。", + "pws.quotaFailure.upstream_error": "提供商未能完成配额查询。", + "pws.quotaFailure.redirect_blocked": "配额端点的重定向已被阻止。", + "pws.quotaFailure.destination_blocked": "网络策略阻止了配额查询目标。", + "pws.quotaFailure.dns_failed": "无法解析配额服务器的主机名。", + "pws.quotaFailure.timeout": "配额请求超时。", + "pws.quotaFailure.transport_error": "配额连接失败。", + "pws.quotaFailure.response_unusable": "提供商返回的配额数据不可用。", "pws.selected": "已选择", "pws.copyModelId": "复制 ID", "pws.modelCopied": "已复制!", diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx index 5f98969f99..1eb9ebcb5d 100644 --- a/gui/tests/account-pool-strategy.test.tsx +++ b/gui/tests/account-pool-strategy.test.tsx @@ -1,4 +1,4 @@ -import { putCodexPoolStrategy } from "../src/pool-settings"; +import { getPoolSettings, putPoolSettings, putCodexPoolStrategy } from "../src/pool-settings"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; @@ -91,6 +91,7 @@ describe("account pool strategy helpers", () => { expect(normalizeAccountPoolStrategy("quota")).toBe("quota"); expect(normalizeAccountPoolStrategy("round-robin")).toBe("round-robin"); expect(normalizeAccountPoolStrategy("fill-first")).toBe("fill-first"); + expect(normalizeAccountPoolStrategy("reset-first")).toBe("reset-first"); expect(normalizeAccountPoolStrategy("weighted")).toBe(DEFAULT_ACCOUNT_POOL_STRATEGY); expect(normalizeAccountPoolStrategy(undefined)).toBe("quota"); }); @@ -180,6 +181,19 @@ describe("AccountPoolStrategyControls", () => { expect(rr).toContain('value="2"'); }); + test("reset-first renders the dual-window threshold explanation", () => { + const markup = renderToStaticMarkup( + + {}} onStickyDraftChange={() => {}} onStickyCommit={() => {}} /> + , + ); + expect(markup).toContain("Soonest reset first"); + expect(markup).toContain("nearest future 5-hour or weekly reset"); + expect(markup).toContain("Bound tasks follow the configured affinity policy"); + expect(markup).not.toContain("New/unbound assignments before rotate"); + }); + test("renders a canonical setting row: visible name, control beside it, no sr-only label", () => { const markup = renderToStaticMarkup( @@ -518,3 +532,14 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { expect(select?.getAttribute("aria-label")).toBe("Rotation strategy"); }); }); + + +test("canonical reset-first settings survive a read and an empty successful write", async () => { + const read = await getPoolSettings("", "openai", async () => Response.json({ provider: "openai", kind: "codex", strategy: "reset-first", stickyLimit: 1 })); + expect(read?.strategy).toBe("reset-first"); + const written = await putPoolSettings("", "openai", { strategy: "reset-first" }, async (_url, init) => { + expect(JSON.parse(String(init?.body))).toMatchObject({ provider: "openai", strategy: "reset-first" }); + return new Response(null, { status: 204 }); + }); + expect(written?.strategy).toBe("reset-first"); +}); 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/gui/tests/provider-account-quota-loading.test.tsx b/gui/tests/provider-account-quota-loading.test.tsx index bfd811ae7f..d13fc9040a 100644 --- a/gui/tests/provider-account-quota-loading.test.tsx +++ b/gui/tests/provider-account-quota-loading.test.tsx @@ -417,3 +417,93 @@ test("a hanging fetch reaches its deadline, preserves last-good and clears probe else Reflect.deleteProperty(AbortSignal, "timeout"); } }); + + +test("quota diagnostics normalize and clear on recovery or management failure", async () => { + let failure: string | undefined = "dns_failed"; + let localFailure = false; + respond = async url => url.includes("quota=1") && localFailure ? new Response(null, { status: 503 }) : Response.json({ + accounts: [{ id: "account", active: true, quotaMode: "probe", quota: reading, + ...(url.includes("quota=1") ? { quotaUnavailable: failure !== undefined, quotaFailure: failure } : {}), + }], + }); + await act(async () => { await pools.fetchAccountSets(["oauth"], true); }); + expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBe("dns_failed"); + failure = "private-unrecognized-error"; + await act(async () => { await pools.fetchAccountSets(["oauth"], true); }); + expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined(); + failure = "rate_limited"; + await act(async () => { await pools.fetchAccountSets(["oauth"], true); }); + expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBe("rate_limited"); + localFailure = true; + await act(async () => { await pools.fetchAccountSets(["oauth"], true); }); + expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quotaUnavailable: true, quota: reading }); + expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined(); + localFailure = false; + failure = undefined; + await act(async () => { expect(await pools.fetchAccountSets(["oauth"], true)).toBe(true); }); + expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quotaUnavailable: false, quota: reading }); + expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined(); +}); + +test("initial roster failure clears a settled diagnosis while retaining bars", async () => { + await act(async () => { + pools.setAccountSets({ fixture: { activeAccountId: "a", accounts: [ + { id: "a", active: true, quotaMode: "probe", quota: reading, quotaUnavailable: true, quotaFailure: "dns_failed" }, + ] } }); + }); + respond = async () => new Response(null, { status: 503 }); + await act(async () => { expect(await pools.fetchAccountSets(["fixture"], true)).toBe(false); }); + expect(pools.accountSets.fixture.accounts[0]).toMatchObject({ quota: reading, quotaUnavailable: true }); + expect(pools.accountSets.fixture.accounts[0].quotaFailure).toBeUndefined(); +}); + +test("roster-only refresh retains a matching diagnosis and clears it after mode change", async () => { + await act(async () => { + pools.setAccountSets({ fixture: { activeAccountId: "a", accounts: [ + { id: "a", active: true, quotaMode: "probe", quota: reading, quotaUnavailable: true, quotaFailure: "rate_limited" }, + ] } }); + }); + let mode = "probe"; + respond = async () => Response.json({ activeAccountId: "a", accounts: [{ id: "a", active: true, quotaMode: mode }] }); + await act(async () => { await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); }); + expect(pools.accountSets.fixture.accounts[0].quotaFailure).toBe("rate_limited"); + mode = "passive"; + await act(async () => { await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); }); + expect(pools.accountSets.fixture.accounts[0].quotaFailure).toBeUndefined(); + expect(requests.every(request => !request.url.includes("quota=1"))).toBe(true); +}); + +test.each([true, false])("late quota failure=%s preserves newer selection and matching membership", async failure => { + const response = deferred(); + const started = deferred(); + const original = ["a", "b", "removed"].map(id => ({ id, active: id === "a", quotaMode: "probe" })); + respond = async url => { + if (url.includes("quota=1")) { started.resolve(); return response.promise; } + return Response.json({ activeAccountId: "a", accounts: original }); + }; + let full!: Promise; + await act(async () => { full = pools.fetchAccountSets(["fixture"], true); await started.promise; }); + const latest = original.filter(row => row.id !== "removed").map(row => ({ ...row, active: row.id === "b" })); + respond = async () => Response.json({ activeAccountId: "b", accounts: latest }); + await act(async () => { await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); }); + if (!failure) { + await act(async () => { + pools.setAccountSets(current => ({ ...current, fixture: { ...current.fixture, accounts: current.fixture.accounts.map(row => ({ + ...row, quotaUnavailable: true, quotaFailure: "dns_failed", + })) } })); + }); + } + await act(async () => { + response.resolve(Response.json({ activeAccountId: "a", accounts: original.map(row => ({ + ...row, quota: reading, quotaUnavailable: failure, ...(failure ? { quotaFailure: "access_denied" } : {}), + })) })); + await full; + }); + expect(pools.accountSets.fixture.accounts.map(row => row.id)).toEqual(["a", "b"]); + expect(pools.accountSets.fixture.accounts.find(row => row.active)?.id).toBe("b"); + for (const row of pools.accountSets.fixture.accounts) { + expect(row.quotaFailure).toBe(failure ? "access_denied" : undefined); + expect(row.quotaUnavailable).toBe(failure); + } +}); diff --git a/gui/tests/provider-current-quota.test.tsx b/gui/tests/provider-current-quota.test.tsx index 4acb16fc5f..b36c7fa33f 100644 --- a/gui/tests/provider-current-quota.test.tsx +++ b/gui/tests/provider-current-quota.test.tsx @@ -100,3 +100,19 @@ test("unsupported, passive unobserved, explicit loading and failed last-good are expect(failed).toContain("12% used"); expect(failed).toContain("Quota updated"); }); + + +test("safe account failure categories reach both current and all-account quota views", () => { + const quota = { weeklyPercent: 12, updatedAt: observedAt }; + const reading = { quotaMode: "probe" as const, quota, quotaUnavailable: true, quotaFailure: "dns_failed" as const }; + for (const view of [, ]) { + const markup = render(view); + expect(markup).toContain("The quota hostname could not be resolved."); + expect(markup).toContain("12% used"); + } + const unknown = render(); + expect(unknown).not.toContain("private-error"); + expect(unknown).not.toContain("hostname"); + const recovered = render(); + expect(recovered).not.toContain("hostname"); +}); 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..7e7cda8e3b 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -1,3 +1,4 @@ +import { parseQuotaFailureCode, type QuotaFailureCode } from "../providers/quota-types"; /** * Data-access layer for `ocx account` (issue #180) — live-proxy HTTP client and * per-family account readers. Kept separate from account.ts (command handlers) @@ -26,12 +27,16 @@ 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. */ priority?: number; quota?: CodexQuotaDto | null; quotaRefresh?: CodexQuotaRefreshOutcome; + quotaUnavailable?: boolean; + quotaFailure?: QuotaFailureCode; /** * Whether the pool is holding this account out of rotation. * @@ -243,6 +248,8 @@ interface CodexAccountDto { plan?: string; isMain?: boolean; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; health?: { reason?: string }; priority?: number; quota?: CodexQuotaDto | null; @@ -309,6 +316,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, @@ -330,6 +341,7 @@ interface OAuthAccountDto { plan?: string | null; quota?: CodexQuotaDto | null; quotaUnavailable?: boolean; + quotaFailure?: unknown; } async function fetchOAuthRows( @@ -363,6 +375,8 @@ async function fetchOAuthRows( plan: a.plan ?? null, ...(a.quota !== undefined ? { quota: a.quota } : {}), ...(a.quotaUnavailable !== undefined ? { quotaUnavailable: a.quotaUnavailable } : {}), + ...(a.quotaUnavailable === true && parseQuotaFailureCode(a.quotaFailure) + ? { quotaFailure: parseQuotaFailureCode(a.quotaFailure) } : {}), })); return { rows, activeId, status: 200 }; } diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 18fca00fb7..1f22e41283 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -44,7 +44,7 @@ const EXTENDED_USAGE = `Usage: ocx account pause [--json] ocx account resume [--json] ocx account pause-exhausted [--json] - ocx account strategy [] [--json] + ocx account strategy [] [--json] ocx account sticky [<1-100>] [--json] ocx account remove --yes [--json] ocx account clear-cooldown [--json] 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..57a62831b4 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] @@ -50,7 +51,7 @@ const ACCOUNT_USAGE = `Usage: ocx account pause [--json] ocx account resume [--json] ocx account pause-exhausted [--json] - ocx account strategy [] [--json] + ocx account strategy [] [--json] ocx account sticky [<1-100>] [--json] ocx account remove --yes [--json] ocx account clear-cooldown [--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(" "); } @@ -115,7 +119,7 @@ function priorityText(row: AccountRow): string { * decides on before a long session. The full breakdown stays in `--json`. */ function quotaText(row: AccountRow): string { - if ((row as { quotaUnavailable?: boolean }).quotaUnavailable) return "unavailable"; + if (row.quotaUnavailable) return row.quotaFailure ? `unavailable (${row.quotaFailure})` : "unavailable"; const quota = row.quota; if (!quota) return "-"; const parts: string[] = []; @@ -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..70c7273c5d 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, @@ -58,9 +67,9 @@ import { MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, - normalizeAccountPoolStrategy, + normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, - parseAccountPoolStrategy, + parseCodexAccountPoolStrategy, parseAccountPriority, } from "./pool-rotation"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; @@ -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, @@ -2457,7 +2481,7 @@ export async function handleCodexAuthAPI( pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null, autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80, upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3, - accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy), + accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy), accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), }); } @@ -2488,12 +2512,12 @@ export async function handleCodexAuthAPI( return jsonResponse({ error: "strategy or stickyLimit required" }, 400); } const runtimeConfig = getRuntimeConfig(config); - let nextStrategy: NonNullable> | undefined; + let nextStrategy: NonNullable> | undefined; let nextSticky: NonNullable> | undefined; if (body.strategy !== undefined) { - const parsed = parseAccountPoolStrategy(body.strategy); + const parsed = parseCodexAccountPoolStrategy(body.strategy); if (parsed === null) { - return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400); + return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first, reset-first' }, 400); } nextStrategy = parsed; } @@ -2509,7 +2533,7 @@ export async function handleCodexAuthAPI( saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, - accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy), + accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy), accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), }); } @@ -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..0ec207f8a6 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -10,7 +10,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { POOL_KEY_CODEX, normalizeAccountPoolStickyLimit, - normalizeAccountPoolStrategy, + normalizeCodexAccountPoolStrategy, notePoolRotationFailure, notePoolRotationSuccess, peekRoundRobinAccount, @@ -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, @@ -1474,6 +1474,12 @@ function listEligibleCodexAccountIds( return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions); } +/** Shared reset timestamps are not evidence for independent model-quota groups. */ +function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) { + const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy); + return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy; +} + function stickyLimitForConfig(config: OcxConfig): number { return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit); } @@ -1505,6 +1511,32 @@ function hasCodexQuotaHeadroom( return usage < threshold; } +/** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */ +function pickResetFirstCodexAccount( + config: OcxConfig, + ids: readonly string[], + now: number, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); + if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now); + let earliest = Number.POSITIVE_INFINITY; + let candidates: string[] = []; + for (const id of available) { + const quota = getAccountQuota(id); + const resets = [quota?.shortResetAt, quota?.weeklyResetAt] + .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset)) + .map(resetAtToMs) + .filter(reset => reset > now); + const next = Math.min(...resets); + if (next < earliest) { + earliest = next; + candidates = [id]; + } else if (next === earliest) candidates.push(id); + } + return pickLowestUsageAmong(config, candidates, selectionOptions, now); +} + /** * Fill-first: keep selectable active under threshold; otherwise advance to the next * eligible id in stable sorted order after the current active (wrapping). @@ -1595,7 +1627,7 @@ function pickUnboundStrategyAccount( commitSharedActive = commit, commitAffinity = commit, ): string | null { - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + const strategy = accountPoolStrategyForScope(config, quotaScope); if (strategy === "quota") return null; const poolKey = codexPoolKeyForScope(quotaScope); @@ -1619,8 +1651,10 @@ function pickUnboundStrategyAccount( return picked; } - if (strategy === "fill-first") { - picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); + if (strategy === "fill-first" || strategy === "reset-first") { + picked = strategy === "reset-first" + ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions) + : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); if (!picked) return null; if (commitSharedActive) { if (!isIndependentCodexQuotaScope(quotaScope) @@ -1753,7 +1787,7 @@ export function pickAlternateCodexAccount( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + const strategy = accountPoolStrategyForScope(config, quotaScope); // The exclusion is passed into eligibility rather than post-filtered off its // result: when the excluded account is the only healthy member of the top // tier, the tier walk must be free to descend instead of selecting that tier @@ -1766,6 +1800,9 @@ export function pickAlternateCodexAccount( const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions); return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions); } + if (strategy === "reset-first") { + return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions); + } return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions); } @@ -1862,7 +1899,7 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void { /** Quota strategy persists; RR/fill-first keep a process-local cursor only. */ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") { setActiveCodexAccount(config, accountId); return; } @@ -2147,9 +2184,12 @@ function previewReusableAffinityAccount( ) { return null; } + if (accountPoolStrategyForScope(config, quotaScope) === "reset-first") { + return resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions) ?? entry.accountId; + } // Quota strategy only: non-quota strategies keep affinity for ongoing threads // (new-session-only rotation — docs / affinity policy A). - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + if (accountPoolStrategyForScope(config, quotaScope) === "quota") { const threshold = config.autoSwitchThreshold ?? 80; if (threshold > 0) { const usage = computeCodexUsageScore( @@ -2202,6 +2242,23 @@ function mayRebindAffinityForQuota( || (!isUnknownUsage(usage) && usage >= 100); } +/** Reset ordering may move a binding only under the existing cache-affinity release policy. */ +function resetFirstAffinityReplacement( + entry: ThreadAffinityEntry, + config: OcxConfig, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const threshold = config.autoSwitchThreshold ?? 80; + if (threshold <= 0) return null; + const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now); + if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null; + const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true) + .filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); + return pickResetFirstCodexAccount(config, candidates, now, selectionOptions); +} + /** * Re-evaluate an affined account under the quota strategy. Returns a strictly * cooler replacement, or null when the current binding should remain. @@ -2213,7 +2270,13 @@ function reevaluateAffinityQuota( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota") return null; + const strategy = accountPoolStrategyForScope(config, quotaScope); + if (strategy === "reset-first") { + const replacement = resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions); + if (replacement || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) entry.lastReevalAt = now; + return replacement; + } + if (strategy !== "quota") return null; const threshold = config.autoSwitchThreshold ?? 80; const usage = threshold > 0 ? computeCodexUsageScore( @@ -2306,6 +2369,7 @@ export function previewCodexAccountForRequest( else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) + && !isCodexAccountPlanExcluded(config, active) ) return active; else return null; } @@ -2440,7 +2504,7 @@ export function resolveCodexAccountForThreadDetailed( const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions); if (cooler) { if (!isIndependentCodexQuotaScope(quotaScope)) { - setActiveCodexAccount(config, cooler); + promoteActiveCodexAccount(config, cooler); } bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks return { status: "selected", accountId: cooler }; @@ -2548,6 +2612,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/oauth/pool-kernel.ts b/src/oauth/pool-kernel.ts index b36ac88e03..ffd535fe60 100644 --- a/src/oauth/pool-kernel.ts +++ b/src/oauth/pool-kernel.ts @@ -41,6 +41,15 @@ export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationSt return null; } +/** Codex alone supports ordering by the next shared quota reset. */ +export function parseCodexAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | "reset-first" | null { + return raw === "reset-first" ? raw : parseAccountPoolStrategy(raw); +} + +export function normalizeCodexAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | "reset-first" { + return parseCodexAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY; +} + /** Strict parse for management APIs — returns null instead of defaulting. */ export function parseAccountPoolStickyLimit(raw: unknown): number | null { if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) { diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts index 946a92d357..cf17309807 100644 --- a/src/oauth/pool-settings-capability.ts +++ b/src/oauth/pool-settings-capability.ts @@ -1,5 +1,5 @@ import { isGenericFailoverProvider } from "./generic-account-failover"; -import { parseAccountPoolStickyLimit, parseAccountPoolStrategy } from "./pool-kernel"; +import { parseAccountPoolStickyLimit, parseAccountPoolStrategy, parseCodexAccountPoolStrategy } from "./pool-kernel"; import type { OcxConfig, OcxProviderConfig } from "../types"; /** @@ -147,7 +147,7 @@ export function unifiedPoolSettingsDto( // honest answer is "not a field here" rather than a fabricated true. enabled: null, enabledEffective: true, - strategy: parseGenericPoolStrategy(config.accountPoolStrategy) ?? "quota", + strategy: parseCodexAccountPoolStrategy(config.accountPoolStrategy) ?? "quota", stickyLimit: parseGenericStickyLimit(config.accountPoolStickyLimit) ?? 1, autoSwitchThreshold: parseGenericAutoSwitchThreshold(config.autoSwitchThreshold) ?? 80, quotaWindow: null, diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts index e0bdf9cb4f..3b7b2e236a 100644 --- a/src/providers/quota-types.ts +++ b/src/providers/quota-types.ts @@ -49,4 +49,16 @@ export interface AccountQuotaFields { quotaMode?: AccountQuotaMode; quota?: ProviderQuota | null; quotaUnavailable?: boolean; + quotaFailure?: QuotaFailureCode; +} + + +/** Closed account-probe diagnoses; never upstream text, URLs, credentials or routing policy. */ +export const QUOTA_FAILURE_CODES = [ + "account_unavailable", "access_denied", "rate_limited", "upstream_error", "redirect_blocked", + "destination_blocked", "dns_failed", "timeout", "transport_error", "response_unusable", +] as const; +export type QuotaFailureCode = typeof QUOTA_FAILURE_CODES[number]; +export function parseQuotaFailureCode(value: unknown): QuotaFailureCode | undefined { + return QUOTA_FAILURE_CODES.find(code => code === value); } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index da5e988c93..dfe887b5e2 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -15,7 +15,9 @@ import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url"; -import { providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound"; +import { DestinationDnsResolutionError } from "../lib/destination-policy"; +import { PinnedHttpError } from "../lib/pinned-http"; +import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound"; import { apiKeyPoolEntryId } from "./api-keys"; import { fetchMuseKeyQuotaSnapshot } from "./muse-key-quota"; import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport"; @@ -52,6 +54,7 @@ import { } from "./codex-capacity"; import type { AccountQuotaMode, + QuotaFailureCode, ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow, @@ -1673,6 +1676,8 @@ type AccountQuotaCacheEntry = { quota: ProviderQuota | null; /** Last probe failed (429 / network / expired login); still may hold last-good quota. */ unavailable?: true; + quotaFailure?: QuotaFailureCode; + quotaFailureIsCurrent?: () => boolean; /** Private new-reader identity; never persisted or serialized. */ identity?: string; isCurrent?: () => boolean; @@ -1784,6 +1789,8 @@ export interface ProviderAccountQuota { quota: ProviderQuota | null; /** Set when the probe could not reach upstream (expired login, 429, network). */ unavailable?: true; + quotaFailure?: QuotaFailureCode; + quotaFailureIsCurrent?: () => boolean; isCurrent?: () => boolean; } @@ -2061,6 +2068,10 @@ function explicitQuotaIdentity(provider: string, accountId: string, configured?: const credential = getAccountCredential(provider, accountId); const target = explicitQuotaConfig(provider, configured); if (!credential || !target) return undefined; + return quotaCredentialIdentity(provider, accountId, credential, target); +} + +function quotaCredentialIdentity(provider: string, accountId: string, credential: NonNullable>, target: OcxProviderConfig): string { return createHash("sha256").update(JSON.stringify([ provider, accountId, credential.access, credential.refresh, credential.expires, credential.accountId, credential.projectId, credential.source, @@ -2152,6 +2163,12 @@ async function fetchExplicitCurrentQuota(provider: string, config: OcxProviderCo return read.result; } +function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined { + return credential ? quotaCredentialIdentity("google-antigravity", accountId, credential, { + adapter: "google", baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE, authMode: "oauth", + }) : undefined; +} + async function fetchAccountQuota( provider: string, accountId: string, @@ -2165,13 +2182,23 @@ async function fetchAccountQuota( const writerGeneration = captureConfigGeneration(); const cached = accountQuotaCache.get(key); if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) { + if (provider === "google-antigravity" && cached.quotaFailure && cached.quotaFailureIsCurrent?.() !== true) return { ...cached, quotaFailure: undefined }; return provider === "anthropic" ? { ...cached, quota: normalizeAnthropicQuota(cached.quota, Date.now()) } : cached; } const joinable = accountQuotaInflight.get(key); if (joinable) return joinable; + const epoch = explicitAccountEpoch; const probe = (async (): Promise => { + let diagnosticIdentity: string | undefined; + let quotaFailure: QuotaFailureCode | undefined; + const quotaFailureIsCurrent = () => { + try { return epoch === explicitAccountEpoch && diagnosticIdentity !== undefined && diagnosticIdentity === antigravityQuotaDiagnosticIdentity(accountId); } + catch { return false; } + }; + const diagnosticFields = () => quotaFailure && quotaFailureIsCurrent() ? { quotaFailure, quotaFailureIsCurrent } : {}; try { + if (provider === "google-antigravity") diagnosticIdentity = antigravityQuotaDiagnosticIdentity(accountId); let quota: ProviderQuota | null; let kiroSnapshot: KiroUsageSnapshot | null = null; if (provider === "kiro") { @@ -2189,9 +2216,12 @@ async function fetchAccountQuota( // Per-account Gem/Cla windows (#1082). The project id is part of the stored // credential; without it the probe cannot be made, and that is "unavailable", // never 0%. - const projectId = getAccountCredential(provider, accountId)?.projectId; - if (!projectId) throw new Error("antigravity account has no project id"); - quota = await fetchAntigravityUsageQuota(token, projectId); + const credential = getAccountCredential(provider, accountId); + diagnosticIdentity = credential?.access === token ? antigravityQuotaDiagnosticIdentity(accountId, credential) : undefined; + if (!diagnosticIdentity || !credential?.projectId) throw new Error("antigravity account unavailable"); + const result = await probeAntigravityUsageQuota(token, credential.projectId); + quota = result.kind === "available" ? result.quota : null; + if (result.kind === "unavailable") quotaFailure = result.failure; } else if (provider === "anthropic") { quota = await fetchAnthropicUsageQuota(token); } else { @@ -2207,6 +2237,7 @@ async function fetchAccountQuota( quota: provider === "anthropic" ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, unavailable: true, + ...diagnosticFields(), }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); @@ -2227,11 +2258,13 @@ async function fetchAccountQuota( } return entry; } catch { + if (provider === "google-antigravity") quotaFailure = "account_unavailable"; const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota: provider === "anthropic" ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, unavailable: true, + ...diagnosticFields(), }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); @@ -2264,7 +2297,9 @@ export async function fetchProviderAccountQuotas( accountId: account.id, quota: provider === "anthropic" ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota, ...(entry.unavailable ? { unavailable: true as const } : {}), + ...(entry.unavailable && entry.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? { quotaFailure: entry.quotaFailure } : {}), }; + if (entry.quotaFailureIsCurrent) Object.defineProperty(result, "quotaFailureIsCurrent", { value: entry.quotaFailureIsCurrent }); if (!explicitAccountReader(provider)) return result; const identity = entry.identity; Object.defineProperty(result, "isCurrent", { value: () => { @@ -2895,104 +2930,78 @@ export function setAntigravityAccountQuotaTransportForTests(dependencies: Provid * the destination keeps the `provider\0accountId` cache identity exact across config changes. * A redirect or non-2xx yields null (unavailable), never a partial row. */ -export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise { - const summaryUrl = ANTIGRAVITY_QUOTA_SUMMARY_URL; +type AntigravityQuotaProbeResult = + | { kind: "available"; quota: ProviderQuota; source: "google-antigravity:retrieveUserQuotaSummary" | "google-antigravity:fetchAvailableModels" } + | { kind: "unavailable"; failure: QuotaFailureCode; legacy: { kind: "null" } | { kind: "throw"; error: unknown } }; + +function quotaTransportFailure(error: unknown): QuotaFailureCode { + if (error instanceof ProviderOutboundPolicyError) return "destination_blocked"; + if (error instanceof DestinationDnsResolutionError) return "dns_failed"; + if (error instanceof PinnedHttpError) return error.code === "output_byte_limit" ? "response_unusable" : "timeout"; + if (error instanceof DOMException && error.name === "TimeoutError") return "timeout"; + return "transport_error"; +} + +function quotaHttpFailure(status: number): QuotaFailureCode { + if (status >= 300 && status < 400) return "redirect_blocked"; + if (status === 401 || status === 403) return "access_denied"; + if (status === 429) return "rate_limited"; + return "upstream_error"; +} + +function unavailableAntigravityQuota(failure: QuotaFailureCode): AntigravityQuotaProbeResult { + return { kind: "unavailable", failure, legacy: { kind: "null" } }; +} + +/** Final attempt determines the safe diagnosis; a successful fallback clears the first failure. */ +async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise { + const fetchQuota = (url: string) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { + headers: { + Accept: "application/json", "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(), Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, antigravityOutboundDependencies); try { - const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, { - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": antigravityUserAgent(), - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ project: projectId }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }, antigravityOutboundDependencies); - if (await providerRedirectError(summaryResponse, summaryUrl)) return null; - if (summaryResponse.status === 401 || summaryResponse.status === 403) return null; - if (summaryResponse.ok) { - const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(summaryResponse))); - if (quota) return quota; + const response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL); + if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_SUMMARY_URL)) return unavailableAntigravityQuota("redirect_blocked"); + if (response.status === 401 || response.status === 403) return unavailableAntigravityQuota("access_denied"); + if (response.ok) { + const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(response))); + if (quota) return { kind: "available", quota, source: "google-antigravity:retrieveUserQuotaSummary" }; } } catch { - // Fallback to fetchAvailableModels on error + // Existing behavior: summary transport/parse failure may recover through the models probe. } + try { + const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL); + if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) return unavailableAntigravityQuota("redirect_blocked"); + if (!response.ok) return unavailableAntigravityQuota(quotaHttpFailure(response.status)); + const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response))); + if (!customWindows.length) return unavailableAntigravityQuota("response_unusable"); + return { kind: "available", quota: { customWindows, updatedAt: Date.now() }, source: "google-antigravity:fetchAvailableModels" }; + } catch (error) { + // The public compatibility wrapper still rejects this exact fallback error; it never enters a DTO. + return { kind: "unavailable", failure: quotaTransportFailure(error), legacy: { kind: "throw", error } }; + } +} - const url = ANTIGRAVITY_QUOTA_MODELS_URL; - const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": antigravityUserAgent(), - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ project: projectId }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }, antigravityOutboundDependencies); - if (await providerRedirectError(response, url)) return null; - if (!response.ok) return null; - const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response))); - if (customWindows.length === 0) return null; - return { customWindows, updatedAt: Date.now() }; +export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise { + const result = await probeAntigravityUsageQuota(accessToken, projectId); + if (result.kind === "available") return result.quota; + if (result.legacy.kind === "throw") throw result.legacy.error; + return null; } async function fetchAntigravityQuota(provider: string): Promise { const credential = getCredential("google-antigravity"); if (!credential?.projectId) return null; let accessToken: string; - try { - accessToken = await getValidAccessToken("google-antigravity"); - } catch { - return null; - } - - // Both probes are pinned to Google's own host through the provider-outbound - // transport, mirroring `fetchAntigravityUsageQuota` above: a configured `baseUrl` is a - // routing choice for requests, not a second source of Google's accounting, and these - // requests carry the account bearer. - const summaryUrl = ANTIGRAVITY_QUOTA_SUMMARY_URL; - try { - const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, { - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": antigravityUserAgent(), - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ project: credential.projectId }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }, antigravityOutboundDependencies); - if (await providerRedirectError(summaryResponse, summaryUrl)) return null; - if (summaryResponse.status === 401 || summaryResponse.status === 403) return null; - if (summaryResponse.ok) { - const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(summaryResponse))); - if (quota) { - return report(provider, "google-antigravity:retrieveUserQuotaSummary", quota); - } - } - } catch { - // Fallback on network/fetch error - } - - const url = ANTIGRAVITY_QUOTA_MODELS_URL; - const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": antigravityUserAgent(), - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ project: credential.projectId }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }, antigravityOutboundDependencies); - if (await providerRedirectError(response, url)) return null; - if (!response.ok) return null; - const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response))); - if (customWindows.length === 0) return null; - return report(provider, "google-antigravity:fetchAvailableModels", { - customWindows, - updatedAt: Date.now(), - }); + try { accessToken = await getValidAccessToken("google-antigravity"); } catch { return null; } + const result = await probeAntigravityUsageQuota(accessToken, credential.projectId); + if (result.kind === "available") return report(provider, result.source, result.quota); + if (result.legacy.kind === "throw") throw result.legacy.error; + return null; } type KeyQuotaReader = (name: string, provider: OcxProviderConfig) => Promise; diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 480e8c7842..48e69ba539 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -39,6 +39,7 @@ import { normalizeAccountPoolStrategy, parseAccountPoolStickyLimit, parseAccountPoolStrategy, + parseCodexAccountPoolStrategy, } from "../../codex/pool-rotation"; import { normalizeAccountPoolQuotaWindow, parseAccountPoolQuotaWindow } from "../../oauth/anthropic-routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; @@ -341,7 +342,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return { ...account, quota: row.quota, - ...(quotaMode === "probe" ? { quotaUnavailable: row.unavailable === true } : {}), + ...(quotaMode === "probe" ? { quotaUnavailable: row.unavailable === true, + ...(row.unavailable && row.quotaFailure && row.quotaFailureIsCurrent?.() === true ? { quotaFailure: row.quotaFailure } : {}), + } : {}), }; }), }); @@ -399,8 +402,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // sticky limit is refused identically whichever pool is addressed. let strategy: string | undefined; if (fields.strategy !== undefined) { - const parsed = parseGenericPoolStrategy(fields.strategy); - if (parsed === null) return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400); + const parsed = kind === "codex" ? parseCodexAccountPoolStrategy(fields.strategy) : parseGenericPoolStrategy(fields.strategy); + if (parsed === null) return jsonResponse({ error: kind === "codex" + ? "strategy must be one of: quota, round-robin, fill-first, reset-first" + : "strategy must be one of: quota, round-robin, fill-first" }, 400); strategy = parsed; } let stickyLimit: number | undefined; 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/types/config.ts b/src/types/config.ts index 372c5b82d0..c76327eaae 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -870,7 +870,7 @@ export interface OcxConfig { /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ autoSwitchThreshold?: number; /** New-session account rotation strategy for the Codex pool. Default quota (today's behaviour). */ - accountPoolStrategy?: OcxAccountPoolRotationStrategy; + accountPoolStrategy?: OcxAccountPoolRotationStrategy | "reset-first"; /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ accountPoolStickyLimit?: number; /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */ 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..8bdb406ae8 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -86,13 +86,14 @@ its defaults and exclusions are owned by [Responses transport](../transports/res Adapter events distinguish raw reasoning content from summary-channel thinking; CCA Gemini classification is request-local. See [Google provenance](../providers/google.md). +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. 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 +105,7 @@ 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. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/catalog.md b/structure/catalog.md index 68fb9b9bb3..238911de5e 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). @@ -295,7 +297,17 @@ Provider `showThinkingSummary` is a Responses request default; it does not rewri `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. +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. + 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. 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. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 95abdfeb29..6092ec48f9 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). @@ -95,6 +97,7 @@ its defaults and exclusions are owned by [Responses transport](../transports/res Provider summary defaults are Responses-specific and do not rewrite connected Claude Desktop profiles. See [inbound compatibility](../data-planes/inbound-compat.md). +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. 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. @@ -113,3 +116,9 @@ 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. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/codex-home.md b/structure/codex-home.md index a25eb8d326..9dc16e644a 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. @@ -236,8 +238,15 @@ Injection preflights affected history using the normalized config candidate befo The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. - A config restoration that was attempted and failed retains its failed artifact in the restore 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. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/config.md b/structure/config.md index abafd78404..4634ac59fe 100644 --- a/structure/config.md +++ b/structure/config.md @@ -212,10 +212,15 @@ 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. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. + 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 +230,9 @@ 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. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 730080408a..61d2844db4 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -89,11 +89,16 @@ its defaults and exclusions are owned by [Responses transport](../transports/res CCA image-capable requests do not acquire the text-summary includeThoughts opt-in. See [Google summary boundary](../providers/google.md). +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. 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. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index bf457f3839..7cbfc20b46 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -174,6 +174,7 @@ its defaults and exclusions are owned by [Responses transport](../transports/res The provider summary default applies at Responses ingress; native Chat and Anthropic inbound preferences keep their existing handling. Raw content is never renamed to a summary. See [bridge contract](../providers/chat-compat.md). +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. ## Claude affinity at final Go dispatch `src/server/claude-messages.ts` carries validated conversation affinity privately through @@ -205,10 +206,14 @@ 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. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/design-methodology.md b/structure/design-methodology.md index 9af35d91fc..ce1256134e 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -37,9 +37,14 @@ 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). The pairing panel names the hub, offers an origin-specific command to run on that hub, and separates one-time codes from data/admin credentials. Copy outcomes and request failures use existing notice/button patterns. Failed authentication never masquerades as a stopped connected process. +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. Cline uses the existing file-integration page, tabs, status badge and rollback dialogs. Its localized semantics identify both files and the required stop/restart boundary before users mutate them. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 47bf09b643..56948bd91b 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 @@ -570,22 +572,31 @@ The provider editor field policy exposes `showThinkingSummary` as a boolean prov `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. +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. + 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. 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. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a4275374cd..a1b841cd10 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,9 @@ 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. + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. 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 +344,9 @@ 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. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 93806c89ed..cbb96b988d 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -152,11 +152,16 @@ its defaults and exclusions are owned by [Responses transport](../transports/res Provider summary defaults are evaluated per routed Responses request without changing service lifecycle or sidecar activation. See [runtime](../runtime.md). +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. 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. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/overview.md b/structure/overview.md index 9d0273b86d..ddcd42080c 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). @@ -119,6 +120,11 @@ Raw reasoning content and provider-authored summaries remain distinct on the Res Connected-browser pairing and dashboard failure meanings follow the [management UI contract](gui-and-management-api.md#dashboard-surfaces); machine enrollment alone does not authenticate a browser. +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. 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. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 9bac0dc9b5..b411c083da 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -438,8 +438,12 @@ 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). -## Paginated history writer boundary +## 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. ## Context relay ownership @@ -474,3 +478,31 @@ 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. + +## Reset-first account ordering + +`src/codex/routing.ts` supports Codex-only `accountPoolStrategy: "reset-first"`. For new shared-quota assignments it chooses the earliest future short/weekly reset after existing eligibility, priority and usage-threshold filtering; ties and absent/elapsed deadlines use the existing usage order. Seconds and milliseconds are normalized with `resetAtToMs`. Threshold zero disables usage filtering while retaining reset ordering. Monthly deadlines do not order this strategy. + +Live bindings obey the existing cache-affinity release policy: with `pool.cacheAffinity`, threshold crossing alone retains a healthy account. Manual preference, scoped health and shared-cursor guards remain authoritative. Independent `spark`/`reserve` quota scopes resolve reset-first to existing quota selection because shared reset timestamps do not describe those windows. The configured value stays unchanged. + +The Codex parser in `src/oauth/pool-kernel.ts` is reexported by the compatibility facade and used by both `/api/pool/settings` and the legacy Codex settings route. Generic and Anthropic parsers reject reset-first. The dashboard offers it only for Codex; API, CLI and translated guides preserve the same contract. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index f67fa76e9c..673a0d841b 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -75,6 +75,7 @@ its defaults and exclusions are owned by [Responses transport](../transports/res Grok chat raw reasoning uses content-channel output with an empty summary; hidden replay envelopes retain continuation text. Native Responses content is not promoted to summaries. See [chat compatibility](chat-compat.md). +Codex pool settings and their consumers follow the [reset-first ordering contract](openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. 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. @@ -110,3 +111,7 @@ 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. + +Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/runtime.md b/structure/runtime.md index 6cec43426f..665a3a4928 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 @@ -254,9 +255,13 @@ Responses route normalization resolves provider summary defaults from the origin `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. +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. + 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 +277,9 @@ 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. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/subagents.md b/structure/subagents.md index cea31404e5..02807b2b17 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; @@ -259,11 +261,21 @@ Final-route summary visibility is recomputed after fallback from the original Re `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. +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. + 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. + +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. + +Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 61e9c2ca75..6cb34a0c08 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -52,7 +52,7 @@ Two fake-IP DNS accommodations exist, both for resolved answers only (a literal still rejects). The IANA benchmark range (198.18/15 and its IPv4-mapped IPv6 spellings) is admitted whenever any outbound proxy applies to the host, because the range itself marks the answer synthetic. Mihomo's default IPv6 fake-IP range (fdfe:dcba:9876::/48) is ULA and carries no such mark, so it is -admitted only when the proxy variable that matches the URL scheme is set (HTTPS_PROXY for https:, +admitted for fixed canonical destinations under the transparent TUN exception, or when the proxy variable that matches the URL scheme is set (HTTPS_PROXY for https:, HTTP_PROXY for http:; ALL_PROXY is not consulted because Bun fetch does not honour it), the host is not in NO_PROXY, and the request is then bound to that proxy through Bun's explicit `proxy` option rather than environment inference. Both gates live in the outbound wrapper, not in classification: @@ -83,6 +83,7 @@ its defaults and exclusions are owned by [Responses transport](../transports/res CCA Gemini summary provenance and request opt-in are specified in [Google provider](../providers/google.md); raw Responses content retains its wire channel. +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. 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. @@ -93,3 +94,11 @@ 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. + +## Account quota failure diagnostics + +Antigravity account quota probes expose only a closed `quotaFailure` category when the read is unavailable. Typed transport failures, rejected destinations, redirects, denied access, rate limits and unusable bodies are distinguished; successful fallback clears the earlier failure. The last attempted endpoint determines the diagnosis. A 401/403 category does not change account health, entitlement or routing eligibility. + +`src/providers/quota.ts` binds diagnoses to the probed credential/project and rechecks before cache reads and API projection. Reauthentication invalidates an old diagnosis independently of last-good quota bars. Private digests, callbacks and upstream error values are not serialized. The CLI and current/all-account dashboard views consume the same closed code; unknown codes and local management-read failures retain generic unavailable text. Codes are transient, never persisted quota evidence. Authenticated TUN field acceptance remains separate from deterministic transport coverage. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index f56be38160..cd73e362c7 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -585,12 +585,17 @@ normalization; refusal/error semantics, retryability, cancellation and captured intact. Internal inspection observes original upstream frames. Native codex.response.metadata.headers WebSocket metadata and compact are excluded. This does not disable upstream safety enforcement. +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. 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. + +Account quota surfaces use [safe probe diagnostics](inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 18f603ae5a..a7cb35d51b 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -209,16 +209,21 @@ its defaults and exclusions are owned by [Responses transport](../transports/res Raw reasoning and provider-authored summary deltas both remain real upstream activity; visibility does not change heartbeat or terminal ownership. See [reasoning presentation](../providers/chat-compat.md). +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. 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. + +Account quota surfaces use [safe probe diagnostics](inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 214270e4be..7772accc49 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,70 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("OAuth quota diagnostics use a closed code in human and JSON output", async () => { + oauthAccounts = [{ id: "acct_1", quotaUnavailable: true, quotaFailure: "dns_failed" }]; + const human = await run(["list", "anthropic", "--quota"]); + expect(human.code).toBe(0); + expect(human.stdout).toContain("unavailable (dns_failed)"); + const machine = await run(["list", "anthropic", "--quota", "--json"]); + expect(JSON.parse(machine.stdout).accounts[0].quotaFailure).toBe("dns_failed"); + oauthAccounts = [{ id: "acct_1", quotaUnavailable: true, quotaFailure: RAW_SENTINEL }]; + const unknown = await run(["list", "anthropic", "--quota", "--json"]); + expect(unknown.stdout).not.toContain(RAW_SENTINEL); + expect(JSON.parse(unknown.stdout).accounts[0]).not.toHaveProperty("quotaFailure"); + }); + + 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-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index 1f7905a16f..89909b2391 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -4,6 +4,8 @@ import { normalizeAccountPriority, notePoolRotationSuccess, parseAccountPriority, + parseAccountPoolStrategy, + parseCodexAccountPoolStrategy, peekRoundRobinAccount, pickRoundRobinAccount, selectPriorityTier, @@ -33,6 +35,7 @@ import { import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; import { clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { setAccountQuotaFromParsed } from "../../src/codex/quota"; import { getConfigPath } from "../../src/config"; import type { OcxConfig } from "../../src/types"; import { existsSync, mkdirSync, rmSync } from "node:fs"; @@ -358,6 +361,130 @@ describe("accountPoolStrategy new-session routing", () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); }); + test("reset-first is accepted only by the Codex strategy parser", () => { + expect(parseCodexAccountPoolStrategy("reset-first")).toBe("reset-first"); + expect(parseAccountPoolStrategy("reset-first")).toBeNull(); + expect(parseCodexAccountPoolStrategy("invalid")).toBeNull(); + }); + + test("reset-first compares both windows, previews without writes, and uses the same failover order", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + const seconds = now / 1000; + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: seconds + 600, shortPercent: 10, shortResetAt: seconds + 300 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 60, weeklyResetAt: seconds + 100, shortPercent: 20, shortResetAt: seconds + 500 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 20, weeklyResetAt: seconds + 900, shortPercent: 30, shortResetAt: seconds + 200 }); + expect(previewCodexAccountForRequest("reset-task", config, now)).toBe("b"); + expect(config.activeCodexAccountId).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(resolveCodexAccountForThread("reset-task", config, now)).toBe("b"); + expect(config.activeCodexAccountId).toBe("a"); + expect(pickAlternateCodexAccount(config, "b", now)).toBe("c"); + }); + + test("reset-first compares seconds and milliseconds in the same clock", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now + 30_000 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 60 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: now - 1 }); + expect(previewCodexAccountForRequest(null, config, now)).toBe("a"); + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + }); + + test("reset-first falls back to quota behavior for independent model windows", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 300 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 60, weeklyResetAt: now / 1000 + 10 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 200 }); + expect(previewCodexAccountForRequest("independent", config, now, "spark")).toBe("a"); + expect(resolveCodexAccountForThread("independent", config, now, "spark")).toBe("a"); + expect(resolveCodexAccountForThread(null, config, now, "shared")).toBe("b"); + expect(resolveCodexAccountForThread("independent", config, now, "spark")).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + recordCodexUpstreamOutcome(config, "a", 429, { now, resetAt: now / 1000 + 100, modelId: "gpt-5.3-codex-spark" }); + expect(pickAlternateCodexAccount(config, "a", now + 1, "spark")).toBe("c"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(config.accountPoolStrategy).toBe("reset-first"); + }); + + test.each([false, true])("reset-first respects cacheAffinity=%s for bound tasks", cacheAffinity => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first", pool: { cacheAffinity } }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 30 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 60 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: now / 1000 + 90 }); + expect(resolveCodexAccountForThread("cached-reset", config, now)).toBe("a"); + setAccountQuotaFromParsed("a", { weeklyPercent: 90 }); + expect(previewCodexAccountForRequest("cached-reset", config, now + 1)).toBe(cacheAffinity ? "a" : "b"); + expect(resolveCodexAccountForThread("cached-reset", config, now + 1)).toBe(cacheAffinity ? "a" : "b"); + }); + + test.each([false, true])("reset-first threshold zero retains a spent binding with cacheAffinity=%s", cacheAffinity => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first", autoSwitchThreshold: 0, pool: { cacheAffinity } }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 10 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 20 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: now / 1000 + 30 }); + expect(resolveCodexAccountForThread("zero-reset", config, now)).toBe("a"); + for (const id of ["a", "b", "c"]) setAccountQuotaFromParsed(id, { weeklyPercent: 100 }); + for (const later of [now + 1, now + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1]) { + expect(previewCodexAccountForRequest("zero-reset", config, later)).toBe("a"); + expect(resolveCodexAccountForThread("zero-reset", config, later)).toBe("a"); + } + recordCodexUpstreamOutcome(config, "a", 429, { now: now + 2, resetAt: now / 1000 + 300 }); + expect(pickAlternateCodexAccount(config, "a", now + 3)).toBe("b"); + }); + + test("reset-first keeps affinity until either window reaches the threshold", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + const seconds = now / 1000; + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: seconds + 100 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: seconds + 200 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: seconds + 300 }); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("a"); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: seconds + 50 }); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("a"); + expect(resolveCodexAccountForThread("new", config, now)).toBe("b"); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, shortPercent: 80, shortResetAt: seconds + 10 }); + expect(previewCodexAccountForRequest("bound", config, now)).toBe("b"); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("b"); + setAccountQuotaFromParsed("b", { weeklyPercent: 80 }); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("c"); + }); + + test("reset-first ignores past/missing resets and breaks ties by usage", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 - 1 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 30, weeklyResetAt: now / 1000 + 20 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 20, shortPercent: 10, shortResetAt: now / 1000 + 20 }); + expect(resolveCodexAccountForThread(null, config, now)).toBe("c"); + expect(resolveCodexAccountForThread(null, config, now + 20_000)).toBe("a"); + clearAccountQuota(); + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + }); + + test("reset-first preserves priority and availability and honors disabled thresholds", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 90, weeklyResetAt: now / 1000 + 10 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 20 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 30 }); + expect(resolveCodexAccountForThread(null, config, now)).toBe("b"); + config.autoSwitchThreshold = 0; + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + config.autoSwitchThreshold = 80; + setCodexAccountPriority(config, "c", 2); + expect(resolveCodexAccountForThread(null, config, now)).toBe("c"); + expect(pickAlternateCodexAccount(config, "c", now)).toBe("b"); + setAccountQuotaFromParsed("b", { weeklyPercent: 95 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 99 }); + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + }); + test("round-robin strategy rotates unbound new sessions", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin" }); updateAccountQuota("a", 10); 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/oauth/oauth-accounts-api.test.ts b/tests/oauth/oauth-accounts-api.test.ts index 09a46b95a6..f152d9fc1f 100644 --- a/tests/oauth/oauth-accounts-api.test.ts +++ b/tests/oauth/oauth-accounts-api.test.ts @@ -829,3 +829,45 @@ describe("multiauth accounts API", () => { } }); }); + + +describe("Antigravity quota diagnosis projection", () => { + let savedProxyEnv: Record; + const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; + beforeEach(() => { + savedProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + for (const key of proxyKeys) delete process.env[key]; + }); + afterEach(() => { + for (const key of proxyKeys) { + if (savedProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedProxyEnv[key]; + } + }); + test("authenticated account reads expose only the current safe failure category", async () => { + const { saveCredential } = await import("../../src/oauth/store"); + const { clearAccountQuotaCache, setAntigravityAccountQuotaTransportForTests } = await import("../../src/providers/quota"); + const cfg = baseConfig(); + cfg.providers["google-antigravity"] = { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }; + saveConfig(cfg); + await saveCredential("google-antigravity", { access: "private-diagnostic-access", refresh: "private-diagnostic-refresh", expires: Date.now() + 3600_000, projectId: "private-diagnostic-project", accountId: "diag-account" }); + clearAccountQuotaCache(); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async () => new Response(null, { status: 403 }), + }); + const server = startServer(0); + try { + const response = await fetch(new URL("/api/oauth/accounts?provider=google-antigravity"a=1&refresh=1", server.url)); + expect(response.status).toBe(200); + const body = await response.json() as { accounts: Array<{ quotaFailure?: string; quotaUnavailable?: boolean }> }; + expect(body.accounts[0]).toMatchObject({ quotaFailure: "access_denied", quotaUnavailable: true }); + const text = JSON.stringify(body); + for (const secret of ["private-diagnostic-access", "private-diagnostic-refresh", "private-diagnostic-project", "quotaFailureIsCurrent"]) expect(text).not.toContain(secret); + } finally { + await server.stop(true); + clearAccountQuotaCache(); + setAntigravityAccountQuotaTransportForTests(null); + } + }); +}); diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index e8e05de9d6..bf4855caa5 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -1,3 +1,5 @@ +import { PinnedHttpError } from "../../src/lib/pinned-http"; +import { DestinationDnsResolutionError } from "../../src/lib/destination-policy"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; @@ -810,6 +812,80 @@ describe("google-antigravity per-account quota (#1082)", () => { }); } + test.each([ + [429, "rate_limited"], [503, "upstream_error"], [200, "response_unusable"], + ] as const)("final models HTTP %s has a safe diagnosis", async (status, failure) => { + await saveCredential("google-antigravity", { access: "diagnostic-access", refresh: "diagnostic-refresh", expires: Date.now() + 3600_000, projectId: "private-project", accountId: "agy-a", email: "a@example.com" }); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async url => new Response("{}", { status: url === summaryUrl ? 500 : status }), + }); + const rows = await fetchProviderAccountQuotas("google-antigravity"); + expect(rows[0]).toMatchObject({ unavailable: true, quotaFailure: failure }); + const serialized = JSON.stringify(rows); + for (const secret of ["diagnostic-access", "diagnostic-refresh", "private-project", "quotaFailureIsCurrent", "identity"]) expect(serialized).not.toContain(secret); + }); + + test.each([ + [new PinnedHttpError("connect_timeout", "private-error"), "timeout"], + [new PinnedHttpError("output_byte_limit", "private-error"), "response_unusable"], + [new DOMException("private-error", "TimeoutError"), "timeout"], + [new Error("private-error"), "transport_error"], + ] as const)("typed fallback failure maps to %s", async (error, failure) => { + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async () => { throw error; }, + }); + const rows = await fetchProviderAccountQuotas("google-antigravity"); + expect(rows[0]?.quotaFailure).toBe(failure); + expect(JSON.stringify(rows)).not.toContain("private-error"); + const { fetchAntigravityUsageQuota } = await import("../../src/providers/quota"); + await expect(fetchAntigravityUsageQuota("agy-first", "proj-first")).rejects.toBe(error); + }); + + test("DNS diagnosis and recovery preserve last-good bars and clear the error", async () => { + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + const goodTransport = { + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async (url: string) => url === summaryUrl ? new Response(null, { status: 500 }) : new Response(antigravityBody(0.5, 0.4)), + }; + setAntigravityAccountQuotaTransportForTests(goodTransport); + const good = (await fetchProviderAccountQuotas("google-antigravity", true))[0]; + expect(good.quota).not.toBeNull(); + setAntigravityAccountQuotaTransportForTests({ resolveAddresses: async () => { throw new DestinationDnsResolutionError("private-host"); } }); + const failed = (await fetchProviderAccountQuotas("google-antigravity", true))[0]; + expect(failed).toMatchObject({ unavailable: true, quotaFailure: "dns_failed", quota: good.quota }); + setAntigravityAccountQuotaTransportForTests(goodTransport); + const recovered = (await fetchProviderAccountQuotas("google-antigravity", true))[0]; + expect(recovered).not.toHaveProperty("quotaFailure"); + expect(recovered).not.toHaveProperty("unavailable"); + expect(recovered.quota).not.toBeNull(); + }); + + test("same-id replacement retires pending and cached failure diagnoses", async () => { + const credential = { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }; + await saveCredential("google-antigravity", credential); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async () => { entered.resolve(); await release.promise; return new Response(null, { status: 403 }); }, + }); + const pending = fetchProviderAccountQuotas("google-antigravity", true); + await entered.promise; + const id = idFor("a@example.com"); + try { await saveCredential("google-antigravity", { ...credential, access: "agy-second" }); } + finally { release.resolve(); } + expect(idFor("a@example.com")).toBe(id); + expect((await pending)[0]).not.toHaveProperty("quotaFailure"); + const cached = (await fetchProviderAccountQuotas("google-antigravity", true))[0]; + expect(cached.quotaFailure).toBe("access_denied"); + await saveCredential("google-antigravity", { ...credential, access: "agy-third" }); + expect(cached.quotaFailureIsCurrent?.()).toBe(false); + expect((await fetchProviderAccountQuotas("google-antigravity"))[0]).not.toHaveProperty("quotaFailure"); + }); + test("NO_PROXY denial preserves an unavailable account row without sending its bearer", async () => { await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); process.env.no_proxy = "daily-cloudcode-pa.googleapis.com"; @@ -826,7 +902,7 @@ describe("google-antigravity per-account quota (#1082)", () => { }, pinnedPost: async () => { posted += 1; return new Response(antigravitySummaryBody(0.5, 0.5)); }, }); - expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]); + expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true, quotaFailure: "destination_blocked" }]); expect(admitted).toEqual([false, false]); expect(posted).toBe(0); expect(plainFetchCalls).toBe(0); @@ -847,7 +923,7 @@ describe("google-antigravity per-account quota (#1082)", () => { return new Response(null, { status, headers: { location: "https://daily-cloudcode-pa.googleapis.com/redirect-target" } }); }, }); - expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]); + expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true, quotaFailure: status < 400 ? "redirect_blocked" : "access_denied" }]); expect(posted).toEqual(fallback ? [summaryUrl, modelsUrl] : [summaryUrl]); expect(plainFetchCalls).toBe(0); }); @@ -864,7 +940,7 @@ describe("google-antigravity per-account quota (#1082)", () => { }); const rows = await fetchProviderAccountQuotas("google-antigravity"); expect(posted).toBe(0); - expect(rows).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]); + expect(rows).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true, quotaFailure: "destination_blocked" }]); }); test("a redirecting upstream yields unavailable and the credential-less account is skipped without a request", async () => { 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..1c66163c87 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -667,6 +667,76 @@ describe("unified pool-settings contract (#695 wp5c)", () => { if (dir) removeTreeWithRetry(dir); }); + test("reset-first round-trips through canonical and legacy Codex settings only", async () => { + const server = startServer(0); + try { + const write = async (provider: string, strategy: string) => fetch(new URL("/api/pool/settings", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider, strategy }), + }); + const result = await write("openai", "reset-first"); + expect(result.status).toBe(200); + expect(await result.json()).toMatchObject({ kind: "codex", strategy: "reset-first" }); + expect(loadConfig().accountPoolStrategy).toBe("reset-first"); + const canonical = await fetch(new URL("/api/pool/settings?provider=openai", server.url)); + expect(await canonical.json()).toMatchObject({ strategy: "reset-first" }); + const legacy = new Request("http://localhost/api/codex-auth/active"); + const legacyRead = await handleCodexAuthAPI(legacy, new URL(legacy.url), loadConfig()); + expect(await legacyRead!.json()).toMatchObject({ accountPoolStrategy: "reset-first" }); + for (const provider of ["anthropic", "google-antigravity"]) { + const rejected = await write(provider, "reset-first"); + expect(rejected.status).toBe(400); + await rejected.text(); + } + const compatibility = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ strategy: "reset-first" }), + }); + const compatibilityWrite = await handleCodexAuthAPI(compatibility, new URL(compatibility.url), loadConfig()); + expect(compatibilityWrite!.status).toBe(200); + expect(await compatibilityWrite!.json()).toMatchObject({ accountPoolStrategy: "reset-first" }); + expect(loadConfig().accountPoolStrategy).toBe("reset-first"); + } finally { + await server.stop(true); + } + }); + + 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,