diff --git a/devlog/_plan/260914_cost_guard_stabilization/000_unit.md b/devlog/_plan/260914_cost_guard_stabilization/000_unit.md new file mode 100644 index 0000000000..63d800c88b --- /dev/null +++ b/devlog/_plan/260914_cost_guard_stabilization/000_unit.md @@ -0,0 +1,120 @@ +# 260914 — Cost-guard stabilization for pooled Codex routing (#4546) + +## Where this starts + +#4546 reports that account-pool routing moved a **live** conversation between +accounts once the pool got hot, discarding the account-isolated prompt-cache +prefix on every hop. The reporter measured roughly 1.9 billion total tokens and +323 million uncached tokens across 15,607 requests in about thirteen hours on +five accounts, with a 7k-token turn arriving upstream as a 150k-token turn. + +Those are two different numbers and this unit keeps them apart. Total tokens, +uncached throughput, billed API cost, and subscription quota drawdown are four +separate quantities; only the second is directly attributable to a routing +decision, and the Pro-plan quota-to-dollar conversion is not verifiable from the +report. The defect is real regardless: uncached throughput is the thing routing +controls, and routing multiplied it. + +## The shape of the defect + +The single-threshold rule is the visible half. `autoSwitchThreshold` (default 80) +answers two unrelated questions with one number: *should a new session be placed +here* and *should an existing session be evicted from here*. Those have opposite +cost structures. Placing a new session on a cooler account costs nothing, because +there is no warm prefix yet. Evicting a live session throws away a prefix that was +paid for once and would otherwise be reused for the rest of the conversation. + +The invisible half is that nothing put a floor under the destination. The bound +thread moved to whichever eligible account was **strictly cooler** — by any margin. +Once every account sits in the 80–99% band the coolest one is still over the +threshold, so the next turn moves again. Because the same predicate also +short-circuits the 60-second re-score interval, a thread in that band is +re-scored on *every request* rather than once a minute. That is the ping-pong. + +`pool.cacheAffinity` (#4292, merged 2026-09-12) already raises the eviction bar to +genuine exhaustion, but it is opt-in and off by default, so no existing install is +protected by it. And turning it on does not close the hole: a transient failure +streak deletes the binding through a different code path that never consults the +flag. + +## Objective + +Make the reported incident structurally impossible rather than less likely, in +priority order, with each work phase independently revertible. + +The governing policy, stated once: + +> **A live binding is held for cache; a new session is placed for capacity; a +> failure is handled at the scope where it actually occurred; and expensive work +> is bounded before it is sent, not after it is billed.** + +## Roadmap + +| Doc | Work phase | Outcome | +| --- | --- | --- | +| `010_bound_binding_policy.md` | wp2 | Cache-first is the default for bound threads, and a move requires a destination with real headroom | +| `020_backoff_preserves_binding.md` | wp3 | A transient streak routes around an account without surrendering ownership of the thread | +| `030_move_reason_evidence.md` | wp3 | Every live-binding move carries a machine-readable reason | +| `040_send_budget.md` | wp4 | One logical request has one total send budget across every retry layer | +| `050_worker_isolation.md` | wp5 | Fan-out cannot consume the capacity an interactive session is bound to | +| `060_quota_cache_domains.md` | wp6 | Credentials are grouped by observed quota and cache domain, not by string identity | +| `070_delivery.md` | wp7 | Delivery, verification posture, and merge policy | + +wp2 and wp3 are the incident. wp4 through wp6 are the amplifiers that turn a +routing mistake into a cost event; they ship after the incident is closed. + +## Relationship to #4581 + +The L2 lane unit `devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md` +reached the headroom floor by a different route -- keep the threshold eviction rule, +constrain the destination -- and landed on `dev` as #4581 while this unit was in +flight. That analysis is correct and this work **builds on it** rather than beside +it: `pickCacheSafeQuotaReplacement` is the shipped destination rule and both call +sites here use it unchanged. + +What it deliberately left open, recorded in its own review, is this unit's scope: a +below-threshold sibling still takes the thread once, so the prefix is lost one time +before affinity goes sticky; cache affinity was still opt-in; and the transient path +was untouched. A headroom floor alone still evicts a live session from an 85% +account to a 5% account, which discards a warm prefix for a capacity preference the +session never had. Holding the binding is the primary rule; the headroom floor is +what protects the operator who explicitly opts back out. + +## Write scope + +Permitted: `src/codex/routing.ts`, `src/types/config.ts`, `src/config.ts`, the +account-pool and session-affinity code, their tests under +`tests/codex-integration/`, `docs-site/` configuration reference and its locales, +`structure/` docs that own the affected invariants, and this unit. + +Excluded, owned by concurrent lanes: `src/providers/devin*`, +`src/providers/antigravity*`, `src/server/responses/*`, `src/codex/catalog/*`, +`src/adapters/cursor/*`, `gui/`. + +## Verification posture + +Local suite, typecheck, install and GUI build are **not run** for this unit by +explicit instruction. Proof is hosted CI at the exact final head SHA and nothing +else. Pull requests state that posture in their Verification section rather than +implying a local green. Pushes use `--no-verify`. + +## Acceptance criteria + +1. With no `pool` key configured, a bound thread in the 80–99% band keeps its + account across repeated resolves, and the preview path agrees with resolve. +2. `pool.cacheAffinity: false` restores the historical eviction rule, and under it + a bound thread still refuses to move to a destination without headroom. +3. A transient failure streak routes the current request away from the account + without deleting the binding; once the streak clears the thread is served by + its original account again. +4. Quota refusal, credential invalidation, generation bumps, pause, and TTL expiry + still release a binding, with their existing tests unchanged. +5. Every live-binding move records a reason that names which of those causes fired. +6. Hosted CI is green at the exact final head of each delivery branch. + +## What would make this fail + +Shipping the default flip without finding every test that encodes the old default, +and calling a red CI run a flake. The blast radius is enumerated in `010`; it is +not guesswork, and a surprised assertion is evidence the rule is wrong somewhere, +not that the test is stale. diff --git a/devlog/_plan/260914_cost_guard_stabilization/010_bound_binding_policy.md b/devlog/_plan/260914_cost_guard_stabilization/010_bound_binding_policy.md new file mode 100644 index 0000000000..ee3405f7e8 --- /dev/null +++ b/devlog/_plan/260914_cost_guard_stabilization/010_bound_binding_policy.md @@ -0,0 +1,103 @@ +# 010 — wp2: a live binding is held for cache, not re-scored for capacity + +## Today + +`resolveCodexAccountForThreadDetailed` reuses a live binding, then calls +`reevaluateAffinityQuota`. Under the `quota` strategy that helper scores the bound +account and asks `mayRebindAffinityForQuota` (`src/codex/routing.ts:2235`), whose +answer without `pool.cacheAffinity` is `usage >= autoSwitchThreshold`. When true it +takes `pickLowerUsageAccount`, which returns any **strictly cooler** eligible +account. `previewReusableAffinityAccount` (`:2207`) carries a second copy of the +same rule and the suite asserts the two answer identically. + +Two independent defects fall out of that, and they need different fixes. + +**The threshold is the wrong question for a bound thread.** Crossing 80% says the +account is getting busy. It does not say the account cannot serve this turn, and +the cost of acting on it is the whole warmed prefix. `round-robin` and +`fill-first` already keep bound threads sticky — rotation there is new-session-only +by design. `quota` is the outlier. + +**Nothing constrains the destination.** With every account in the 80–99% band the +coolest is still hot, so the thread is handed on again next turn. `mayRebind` is +also the short circuit for the 60-second re-score interval, so in that band the +thread is re-scored on every request. + +## The rules + +**R1 — cache-first is the default.** `pool.cacheAffinity` resolves to `true` when +unset. A bound thread leaves only when its account genuinely cannot serve: +unusable, paused, credential-invalid, generation-stale, TTL-expired, quota-refused, +or known to be at 100%. An explicit `pool.cacheAffinity: false` restores the +historical rule for operators who want capacity-first behaviour. + +**R2 — a move needs somewhere worth moving to.** Even under R1-off, a bound thread +may only move to an account that has genuine quota headroom, the same bar +`resetFirstAffinityReplacement` already applies for `reset-first` through +`hasCodexQuotaHeadroom`. Headroom alone is not sufficient, because that predicate +deliberately answers true for an account whose usage is **unknown** — +unknown-means-selectable is right for an unbound request and wrong for a bound +one, since trading a warm prefix for an unmeasured account is a guess. The +candidate must clear both bars: headroom, and strictly lower usage than the bound +account. `CODEX_UNKNOWN_USAGE_SCORE` is 101, so an unobserved account can never be +strictly cooler than a known over-threshold score and the second bar excludes it +without a special case. + +R2 is what makes the incident impossible for both settings of the flag. R1 is what +makes the expensive case impossible without the operator having to know the flag +exists. + +## Why the default flip is the right call and not just a preference + +Every comparable system reaches the same place. Upstream Codex has no pool at all: +it pins the cache with a session-scoped `prompt_cache_key` and a turn-sticky +`x-codex-turn-state` token that retries must replay, and its transport sets +`retry_429: false` so a rate-limit answer is classified before anything moves. +Claude Code treats the cache as the retry policy — a `Retry-After` under twenty +seconds waits on the **same** model rather than switching. OpenClaw, which is the +closest analogue because it does pool credentials, auto-pins an auth profile per +session and rotates only on long-window limits, keeping same-key retry separate +from rotation. Published proxy guidance for pooled ChatGPT accounts says the same +thing in one line: pool for quota, pin the session, and do not expect a prefix +warmed on one account to exist on another. + +The asymmetry that makes this safe: a session pinned to a busy account pays +latency. A session moved off a warm account pays the entire prefix again, every +turn, and the pool has no way to move the cache with it. + +## Where it changes + +- `mayRebindAffinityForQuota` — the flag read becomes `?? true`, expressed through + one resolver so the default lives in exactly one place. +- `reevaluateAffinityQuota` and `previewReusableAffinityAccount` — both gain the R2 + destination filter, together, because the suite pins them to agree. +- `resetFirstAffinityReplacement` — already applies R2; it now shares the helper + instead of open-coding it. + +Release paths are deliberately untouched. `hasUnrecoveredCodexQuotaRefusal` +(429/402) still outranks every affinity preference, generation checks still defeat +a late-arriving failure from an account the thread already left, and an exhausted +or unusable account still loses the binding. This narrows a *preference*; it never +weakens a refusal. + +## Blast radius + +The default flip inverts tests that encode the old default. They are not stale — +each one pinned real behaviour — so each is rewritten to state its intent +explicitly with `pool: { cacheAffinity: false }`, and a default-on counterpart is +added next to it. The enumeration is mechanical and complete before the edit; see +`.tmp/research/a6-test-blast-radius.md` for the working list. The near-misses +matter as much as the hits: unbound rotation, 429 refusal, cooldown, pause, +failover streak and TTL tests must all keep passing untouched, and any of them +changing is a signal the edit went too far. + +## Regression tests + +1. No `pool` key, bound thread, account crosses 80% while a 5% sibling exists: the + thread keeps its account across repeated resolves, and preview agrees. +2. `pool.cacheAffinity: false`, same setup: the thread moves once, then stays. +3. `pool.cacheAffinity: false`, every account in the 80–99% band: the thread does + not move at all, and does not move on any subsequent turn. This is the reported + ping-pong and it fails before R2. +4. Known 100% usage on the bound account with a cool sibling: the thread still + leaves under both settings. diff --git a/devlog/_plan/260914_cost_guard_stabilization/020_backoff_preserves_binding.md b/devlog/_plan/260914_cost_guard_stabilization/020_backoff_preserves_binding.md new file mode 100644 index 0000000000..24a1f25dc3 --- /dev/null +++ b/devlog/_plan/260914_cost_guard_stabilization/020_backoff_preserves_binding.md @@ -0,0 +1,79 @@ +# 020 — wp3: a transient streak is a detour, not an eviction + +## Today + +`recordCodexUpstreamOutcome` handles a transient (non-429/402) failure by counting +consecutive failures and, once `upstreamFailoverThreshold` (default 3) trips, +doing three things: it writes an escalating `softAvoidUntil`, it deletes **this** +thread's pin with `deleteThreadAffinitiesForAccount`, and then it clears **every** +thread pinned to that account with `clearThreadAccountMapForAccount` +(`src/codex/routing.ts:3009-3016`). + +The resolve path enforces the same conclusion independently: `failoverReady` is one +of the gates that fails the reuse branch, so the next continue deletes the binding +at `:2522` even if the recorder had left it alone. The preview path carries the +same gates at `:2176-2189`. Fixing only the recorder would be a no-op. + +This is the hole that survives `pool.cacheAffinity`. The flag governs the quota +preference and nothing else, so three 503s — a provider-wide overload that has +nothing to do with this account — discard the binding and the warmed prefix +exactly as an 80% threshold crossing used to. #4269 already showed how badly this +misfires: a retryable 503 whose human-readable message happened to contain +"reauthentication" was classified as an auth error. A failure's blast radius must +come from its scope, not from its text or its count. + +## The rule + +Being temporarily unable to send is not the same as giving up ownership of the +conversation. Separate the two: + +| Account state | This request | The binding | +| --- | --- | --- | +| Healthy | served by the bound account | held | +| Transient streak / soft-avoid | served by an alternate | **held** | +| Hard cooldown from quota refusal (429/402) | served by an alternate | released | +| Unusable, paused, credential-invalid, generation-stale | released | released | +| Known 100% usage | served by an alternate | released | + +The middle row is the change. The request detours; the thread keeps its home. +When the streak clears — and the existing `preservedCooldownFields` design means +`lastFailureStatus` survives exactly until the account serves again — the thread is +served by its own warm account with no further action. + +## The bound on the hold + +A hold with no expiry is a different bug: an account that never recovers would keep +a thread detouring forever while the real conversational cache accumulates +somewhere else. The hold is therefore bounded. The affinity entry records when the +detour started; if the bound account is still unusable when that window lapses, the +binding is released normally and the thread rebinds through the ordinary path. A +successful serve clears the marker. + +This keeps the failure modes ordered correctly: a blip costs nothing, a sustained +outage converges to a real rebind, and neither one is decided by a message string. + +## Where it changes + +- `recordCodexUpstreamOutcome` transient branch — the two affinity clears become + conditional on the release policy rather than unconditional on the streak. +- `resolveCodexAccountForThreadDetailed` — a reuse that fails **only** on + transient evidence takes the detour branch instead of the delete branch. +- `previewReusableAffinityAccount` — same classification, so preview keeps agreeing + with resolve. + +The existing race guard stays exactly as it is: a late failure arriving from +account A must never disturb a binding that has already moved to B, which is what +the generation check and the pinned-account guard in +`deleteThreadAffinitiesForAccount` exist for. Nothing here relaxes them. + +## Regression tests + +1. Three transient 5xx failures on the bound account: the next resolve returns a + different account **and** the binding still names the original. +2. The account then serves successfully: the following resolve returns the original + account again. +3. The streak persists past the hold window: the binding is released and the thread + rebinds to the account that can serve. +4. A 429 on the bound account still releases the binding immediately, unchanged. +5. A late transient failure from an account the thread already left does not touch + the current binding. diff --git a/devlog/_plan/260914_cost_guard_stabilization/030_move_reason_evidence.md b/devlog/_plan/260914_cost_guard_stabilization/030_move_reason_evidence.md new file mode 100644 index 0000000000..291ed25817 --- /dev/null +++ b/devlog/_plan/260914_cost_guard_stabilization/030_move_reason_evidence.md @@ -0,0 +1,36 @@ +# 030 — wp3: every move says why + +## Today + +There is no account-move metric and no persisted move reason. `logCtx.affinity` is +typed as `reused | new_bind | rebound | cleared` but never assigned, and +`appendUsageEntry` would drop it. `src/codex/affinity-debug.ts` is an opt-in +HMAC-tagged header diagnostic for account-switch **compatibility** failures, not a +record of routing decisions. The only way to infer a move today is to read account +labels across log lines, which is how #4546 had to be diagnosed in the first place. + +Cache accounting has a related gap. Missing cache information is correctly omitted +rather than stored as zero on `OcxUsage`, and `cacheHitRate` is `null` when +unobserved — but the bridged Responses, Chat and Anthropic paths always emit +`cached_tokens: 0`, and Kiro always writes 0. A reader cannot distinguish "the +provider reported no cache hit" from "the provider reported nothing", which is +precisely the distinction needed to tell whether a routing change worked. + +## The rule + +A live-binding move is a decision the operator paid for, so it carries its reason: +which cause fired (`soft-quota`, `quota-refusal`, `exhausted`, `transient-hold-expired`, +`unusable`, `paused`, `generation`, `expired`, `detour`), and whether the binding +was held or released. The reason rides the existing per-attempt record in +`usage.jsonl` — the one surface that already has attempt granularity — so the GUI +Logs attempt view and `ocx logs explain` can render it without a new store. + +Missing cache information stays `unknown`. A synthesized `cached_tokens: 0` on a +bridged path is a reporting artifact and must not aggregate as a measured miss. + +## Scope for this unit + +wp3 lands the reason at the decision point and the record, because that is what +makes the wp2 and wp3 rules auditable in the field rather than only in tests. The +dashboard rendering and the amplification metric (sends per logical request) belong +with wp4, where the send budget gives them a denominator that means something. diff --git a/devlog/_plan/260914_cost_guard_stabilization/040_send_budget.md b/devlog/_plan/260914_cost_guard_stabilization/040_send_budget.md new file mode 100644 index 0000000000..abefc9f528 --- /dev/null +++ b/devlog/_plan/260914_cost_guard_stabilization/040_send_budget.md @@ -0,0 +1,37 @@ +# 040 — wp4: one logical request, one send budget + +## Today + +#2981 already found and fixed one instance of this: transient retry and +socket-reset retry nested, so `attempts=3` became up to nine physical sends, and +the fix introduced a shared total-send budget inside the send helper. The lesson +did not generalise. The layers that can each re-send one logical request are still +counted separately: SDK/transport retry, adapter retry, stream recovery and +continuation repair, account failover, and combo failover. Multiplied rather than +summed, a single user turn can reach upstream many more times than any one layer's +configuration suggests, and each one of those sends carries the full prompt. + +That is the second multiplier behind #4546. Routing decided *where* the cold +prefix went; retry decided *how many times* it was sent. + +## The rule + +One logical request carries one total send budget, and every layer decrements it. +A conservative starting policy: at most three total upstream sends per logical +request, of which at most one may be a cross-account move. `Retry-After` is a lower +bound, never shortened by a local maximum delay — #3294 and #3606 already +established that rate limiting and usage exhaustion are different answers and that +5xx bodies can carry quota information worth preserving. A pool-wide retry **ratio** +cap sits above the per-request budget, following the standard overload guidance +that per-request attempt limits alone do not prevent a retry storm. + +What this cannot do is bound a client that re-sends on its own. That needs a shared +logical-request identity with the client, which is out of scope here and noted so +the budget is not mistaken for a total guarantee. + +## Evidence to add + +Sends per logical request, and input tokens spent on retries, aggregated per root +workflow. `sendCount` already counts physical sends per attempt but never reaches +`/api/usage` or the GUI. Surfacing it is what turns "we think retries amplified +this" into a number. diff --git a/devlog/_plan/260914_cost_guard_stabilization/050_worker_isolation.md b/devlog/_plan/260914_cost_guard_stabilization/050_worker_isolation.md new file mode 100644 index 0000000000..791132e577 --- /dev/null +++ b/devlog/_plan/260914_cost_guard_stabilization/050_worker_isolation.md @@ -0,0 +1,41 @@ +# 050 — wp5: fan-out must not spend an interactive session's capacity + +## Today + +The proxy does not budget fan-out at all. There is no per-root cap on concurrent +children, no cold-input ceiling, and no cumulative spend limit per workflow. The +only limits are process-wide (`MAX_ACTIVE_TURNS`, `MAX_ACTIVE_SESSION_LANES`), and +Codex's own `max_concurrent_threads_per_session` and `max_depth` live in client +TOML that the proxy never enforces. `checkInputAdmission` is a single-turn context +preflight, not a budget. The main-account hard lock is described in its own code as +an observed-usage policy rather than a reservation. + +Worse, the pool affinity key is derived from `x-codex-parent-thread-id`, so an +entire fan-out pins to the same binding the interactive session is using. Hundreds +of large children and the conversation the operator is actually watching draw from +one account, and the children are the ones with cold prefixes. + +## The rule + +Reserve before dispatch, not after billing. A root workflow holds a budget covering +its children and their retries; children are admitted against the reservation, and +the reservation is charged with real usage as results arrive. Interactive traffic +keeps capacity that worker fan-out cannot take, whether by separate accounts or by +priority reservation within one pool. Exhausting the worker budget stops dispatch; +it does not spill onto the interactive account, and it never silently escalates to +a paid API path — that needs its own approval and its own ceiling. + +The seams are known: turn admission plus parent-keyed inflight accounting for +concurrency, the pool affinity key and thread resolution for who pays, the spawn +preview before auth for pre-dispatch refusal, and input admission for cold and +cumulative input volume. + +## Identity, kept separate + +Four concepts are currently collapsed and need to stay distinct: the root workflow +that owns the budget, the conversation that owns the account binding, the cache +cohort that can share a prefix, and the execution lane that de-duplicates +overlapping runs of the same child. The existing split between a parent-preferring +affinity key and a parent+child execution lane is deliberate and correct — +serialising siblings under one parent id turns healthy parallelism into 503 +collisions — so the budget must attach to the root without re-merging the lanes. diff --git a/devlog/_plan/260914_cost_guard_stabilization/060_quota_cache_domains.md b/devlog/_plan/260914_cost_guard_stabilization/060_quota_cache_domains.md new file mode 100644 index 0000000000..38265d0cbf --- /dev/null +++ b/devlog/_plan/260914_cost_guard_stabilization/060_quota_cache_domains.md @@ -0,0 +1,43 @@ +# 060 — wp6: group credentials by observed domain, not by string identity + +## Today + +A credential pool is a list. Two API keys are assumed to be two independent pools +of capacity, and two accounts on one provider are assumed not to share a cache. +Both assumptions are wrong in opposite directions, and each one costs money in a +different way. + +OpenAI documents that prompt caches are not shared across organizations or +processing regions, and that changing keys within one organization does not +guarantee a hit; rate limits are defined per organization and project, with model +groups sharing a limit — so failing over from key A to key B inside the same limit +buys no capacity while still paying a cold prefix. Anthropic isolates prompt cache +per workspace even inside one organization, and excludes cache-read tokens from +input TPM while counting cache writes and ordinary input — so identical token +counts consume quota differently per provider. Azure documents its own cache-key +guidance and per-deployment breakpoint differences for the same model family. +Gemini's current interactions surface supports implicit caching but not explicit +cache objects, and explicit caches are project- and region-scoped resources rather +than portable strings. + +## The rule + +Three identities, tracked separately: the authentication identity, the cache +compatibility domain, and the quota-sharing domain. They are a conservative +classification the proxy maintains, never a claim to know where a provider stores +its cache. Undocumented providers stay `unknown`, and `unknown` is never silently +read as "no cache" or as "shared across accounts". + +Cache compatibility and conversational portability are also different questions. A +request carrying `previous_response_id`, file ids, or a provider-side conversation +id cannot be replayed onto another account at all; the adapter must confirm +portability and return a clear error rather than replaying onto the wrong +credential. Comparable gateways implement exactly this as a separate pre-call +check, which is evidence the distinction is load-bearing in production rather than +theoretical. + +## Consequence for placement + +New sessions are placed by cache-reuse likelihood and free capacity within a quota +domain. Keys that share a documented limit count once toward available capacity. +Existing sessions are not re-placed by any of this — wp2 already settled that. diff --git a/devlog/_plan/260914_cost_guard_stabilization/070_delivery.md b/devlog/_plan/260914_cost_guard_stabilization/070_delivery.md new file mode 100644 index 0000000000..63617e8708 --- /dev/null +++ b/devlog/_plan/260914_cost_guard_stabilization/070_delivery.md @@ -0,0 +1,32 @@ +# 070 — wp7: delivery + +## Shape + +wp2 and wp3 ship together as one pull request against `dev`: they are one policy — +hold a live binding for cache, release it only on real evidence — and splitting +them would land a default flip whose main remaining hole is still open. wp4, wp5 +and wp6 follow as separate pull requests, each independently revertible. + +## Verification + +Local suite, typecheck, install and GUI build are not run, by explicit instruction. +The pull request states that plainly in its Verification section. The only proof is +hosted CI at the exact final head SHA; a green run against an earlier commit is not +evidence for the head that gets merged. + +Pushes use `--no-verify`. Merges into `dev` are squash merges under the +single-maintainer dev integration policy in `MAINTAINERS.md`, with the merge +commit and the exact-head CI run recorded. + +## Issue linkage + +`Closes #4546` for the pull request carrying wp2 and wp3. Because pull requests +here target `dev` rather than the default branch, GitHub will not auto-close it; +the issue is closed by hand once the change is on `dev`, naming the merge commit. + +## Documentation + +The configuration reference and every locale translation change in the same pull +request as the behaviour, because a default documented in eight languages is wrong +in eight languages the moment the code lands. `structure/` ownership docs for the +affected invariants change with them. diff --git a/devlog/_plan/260914_cost_guard_stabilization/080_codex_cache_reinforcement.md b/devlog/_plan/260914_cost_guard_stabilization/080_codex_cache_reinforcement.md new file mode 100644 index 0000000000..63ba61e077 --- /dev/null +++ b/devlog/_plan/260914_cost_guard_stabilization/080_codex_cache_reinforcement.md @@ -0,0 +1,58 @@ +# 080 — wp8: reinforce the Codex prompt cache and report it honestly + +## Why this is here + +Holding a binding (wp2) protects a cache that already exists. It does nothing if +the cache was never warm, and nothing if the operator cannot tell whether it was. +Both are true on the Codex path today, and the second one is why #4546 took a +token-burn incident to notice instead of a dashboard. + +Anthropic-shaped clients go out of their way to force caching: explicit +`cache_control` breakpoints, a session-latched beta header and TTL so a toggle +cannot invalidate 20-70k tokens mid-conversation, and a retry policy that keeps a +short `Retry-After` on the **same** model precisely to avoid losing the prefix. +Upstream Codex does the equivalent with a session-scoped `prompt_cache_key` and a +turn-sticky `x-codex-turn-state` token that retries replay. OpenCodex forwards +what it is given and adds little of its own. + +## Cache reinforcement + +**Stable cohort identity.** A conversation should present one stable cache key for +its lifetime. Where the inbound request already carries `prompt_cache_key`, it is +preserved unchanged — it is the client's cohort and rewriting it is how a prefix +gets split. Where it is absent but a stable conversation identity exists, derive +one deterministically from that identity rather than leaving the destination to +guess, and keep the derivation stable across turns, retries and detours. + +**Wire contracts are per destination, not per model name.** The canonical ChatGPT +Codex backend rejects `prompt_cache_options`, which is why the adapter already +strips it; the public API, Azure deployments and custom Responses gateways each +document their own support. A cache parameter is sent only where that destination +documents it. "Same model name" is not evidence of the same wire contract. + +**Prefix stability is part of the cache.** Reordering tool definitions, rewriting +instructions, or toggling a header between turns invalidates a prefix just as +surely as changing accounts. Anything that varies per turn belongs after the +stable prefix, not inside it. + +## Honest reporting + +Missing cache information must stay **unknown**. Today the bridged Responses, Chat +and Anthropic paths always emit `cached_tokens: 0` and Kiro always writes 0, so a +provider that reports nothing is indistinguishable from a provider that reports a +total miss. `OcxUsage` already omits rather than zero-fills and `cacheHitRate` is +already `null` when unobserved — the defect is upstream of that, in the synthesized +zeros, and it is what makes the cache indicator look broken. + +What the operator needs to see for a Codex model: cache reads, cache writes, and +ordinary input as three separate numbers, with unknown rendered as unknown; and +per provider, since the arithmetic differs — OpenAI folds cache reads and writes +into the input total while Anthropic reports them as separate fields, so a single +subtraction rule is wrong for one of them. + +## Boundaries + +The proxy does not manage a provider's KV cache and must not claim to. It controls +placement, pinning, parameter fidelity and prefix stability. Observed cache ratios +are evidence, not a guarantee, and a request whose result was lost after sending is +not refunded to zero. diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 3b545cee09..89f27eebf3 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -154,7 +154,7 @@ la route à un autre compte Pool admissible. Ce mécanisme est distinct d'`opena - Choisir manuellement un compte s'applique immédiatement : un fil déjà associé y passe à sa prochaine requête, et seules les requêtes déjà en cours conservent le compte capturé. Le choix manuel est aussi épinglé : la fiche affiche le badge **ÉPINGLÉ**, et un ordre de sélection supérieur ne peut pas prendre la priorité sur ce compte avant son épuisement, la sélection d'un autre compte ou la modification de l'ordre de sélection de n'importe quel compte. - Chaque fiche de compte possède un contrôle **Ordre de sélection** (**Premier**, **Plus tôt**, **Normal**, **Plus tard**, **Dernier**). Les ordres supérieurs sont utilisés en premier ; le pool ne descend à un ordre inférieur qu'une fois tous les comptes supérieurs épuisés ou indisponibles. Un changement d'ordre s'applique dès la prochaine requête sans association et ne déplace jamais un fil déjà associé. Le compte Codex Desktop principal est ordonné comme les autres : il peut être placé en **Dernier** et conservé comme réserve. Un ordre défini avec `ocx account priority` en dehors de ces cinq préréglages reste visible et sélectionnable sur la fiche. -- L'affinité des fils évite les changements à chaque requête. Lorsque le changement automatique selon les quotas est activé, un fil de longue durée est réévalué périodiquement et peut être réassocié quand son utilisation pertinente atteint le seuil et qu'il existe un compte admissible dont l'utilisation est strictement inférieure. +- L'affinité des fils évite les changements à chaque requête. Avec `pool.cacheAffinity` activé (par défaut), un fil de longue durée n'est pas réassocié simplement parce que l'utilisation a atteint le seuil ; il reste jusqu'à ce que le compte soit épuisé ou ne puisse plus servir, puis seulement vers un compte dont l'utilisation est strictement inférieure et qui dispose d'une véritable marge de quota. Définissez le drapeau à `false` pour rétablir la réaffectation au seuil lorsqu'un compte admissible strictement moins utilisé existe. - Les nouvelles sessions peuvent choisir le compte admissible le moins utilisé. Pour les forfaits payants, le score retient la fenêtre connue la plus sollicitée parmi 5 h, une semaine et 30 jours ; les forfaits Go/Free utilisent uniquement la fenêtre de 30 jours. - Lorsque WHAM fournit `limit_window_seconds`, **Authentification Codex** classe une fenêtre principale d'au moins 28 jours comme une fenêtre de 30 jours au lieu de supposer que toute fenêtre principale est hebdomadaire. Les réponses sans durée conservent l'ancienne interprétation hebdomadaire. - **Actualiser les quotas** relit immédiatement l'utilisation des comptes afin que le routage et les fiches utilisent les mêmes valeurs. diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index b531e1b307..afa6fc19ec 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -229,9 +229,8 @@ entrée. **L'omission de la valeur lit** la commande actuelle au lieu d'en écri comptes éligibles, en prenant le niveau de commande le plus élevé qui dispose encore d'une marge de quota et en laissant `accountPoolStrategy` pour choisir à l'intérieur. La pause, le temps de recharge et la réauthentification ne sont pas affectés. Les modifications s'appliquent à partir de la **prochaine requête non liée**, et pas seulement à partir des sessions nouvellement démarrées : mouvements de préemption -une demande non liée augmente dès qu'un ordre supérieur retrouve de la marge. Sujets déjà liés à un compte -conservez-le normalement jusqu’à ce que ce compte soit vidé ; un échec de réauthentification, un temps de recharge du quota ou un -une séquence de défaillances transitoires libère la liaison avant cela. Toute écriture acceptée publie également un manuel +une demande non liée augmente dès qu'un ordre supérieur retrouve de la marge. Les fils déjà liés à un compte +le conservent normalement jusqu’à ce que ce compte soit vidé ; un échec de réauthentification ou un temps de recharge du quota libère encore la liaison avant cela. Une séquence de défaillances transitoires (5xx et autres échecs hors quota atteignant `upstreamFailoverThreshold`, 3 par défaut) ne supprime pas une liaison active : la requête est servie par un autre compte, puis le fil y revient dès que le sien sert à nouveau ; si le compte échoue encore après 10 minutes, la liaison est libérée normalement. Toute écriture acceptée publie également un manuel épingle "utiliser ce compte maintenant", sur le compte qui le détenait, y compris une écriture qui stocke le commander un compte déjà possédé — c'est le seul moyen d'effacer un code PIN tout en conservant le compte qui est actuellement sélectionné. (La compensation du compte actif via la gestion API libère un 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 ec4613a3be..2b8545629c 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -38,9 +38,9 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `activeCodexAccountId?` | `string` | — | Compte de pool sélectionné manuellement pour la prochaine demande. La sélection efface l'affinité des threads ; les demandes en cours conservent les informations d’identification capturées. | | `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, en les déplaçant uniquement vers un compte admissible qui dispose encore d'une marge sous le seuil. 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" \| "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 qui dispose encore d'une marge sous le seuil. 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. | +| `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. Les tâches liées conservent leur compte au-delà du seuil par défaut (`pool.cacheAffinity`) jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir, et ne basculent alors que vers un compte dont l'utilisation est strictement inférieure et qui dispose d'une véritable marge de quota. Définissez `pool.cacheAffinity: false` pour réévaluer aussi les tâches liées à ce seuil. `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" \| "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. Les tâches liées suivent `pool.cacheAffinity` (activé par défaut) : elles restent jusqu'à ce que le compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir, et ne se relient alors qu'à un compte dont l'utilisation est strictement inférieure et qui dispose d'une véritable marge de quota. Définissez le drapeau à `false` pour relier de manière proactive une tâche liée à un compte admissible moins utilisé au seuil, toujours sous la même contrainte de destination. `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` | `true` | Ordre d'affinité de cache pour les threads Codex liés, indépendant de `pool.kernel`. Activé par défaut ; omettre la clé ou la définir à `true` conserve la liaison, et une valeur autre que `false` est lue comme activée. Une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a franchi `autoSwitchThreshold`, car déplacer une conversation liée jette le cache d'invites isolé par compte. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, exclu du plan, identifiants invalides, génération remplacée, TTL expiré, refus de quota 429/402, ou réellement épuisé (utilisation connue à 100 %) — et seulement vers un compte dont l'utilisation est strictement inférieure et qui dispose d'une véritable marge de quota. Un compte dont l'utilisation est inconnue n'est jamais choisi comme destination d'une tâche liée. Si tous les comptes dépassent le seuil, la tâche liée reste, car aucune destination n'est meilleure. Définissez `false` pour rétablir la réaffectation au seuil, toujours sous la même contrainte de destination. L'affinité est 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. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Seuil facultatif du coupe-circuit pour les échecs DNS/TCP avérés avant connexion sur les requêtes Responses OpenAI natives en mode transfert et les envois compacts. `0` le désactive ; `1`–`20` ouvre, après ce nombre de requêtes logiques arrivées à leur terme, une temporisation de 30 secondes propre à l'origine du fournisseur. Tant que le circuit est ouvert, les requêtes reçoivent `503` avec `Retry-After` avant la sélection du compte ou l'envoi en amont ; après la temporisation, une requête est admise en état semi-ouvert. Les délais d'attente et les réponses HTTP ne sont jamais comptabilisés, et toute réponse HTTP ferme le circuit. Ce mécanisme s'applique uniquement au routage du pool Codex sans compte épinglé ; il reste inactif pour `codexAccountMode: "direct"` et les sélecteurs qualifiés par compte. | @@ -183,8 +183,7 @@ Deux accommodements fake-IP DNS existent pour les utilisateurs de Clash / Surge Utilisez **Codex Auth** dans le tableau de bord pour ajouter des comptes au groupe et actualiser les quotas. `config.json` stocke les métadonnées non secrètes ; les jetons d'accès et d'actualisation utilisent le magasin d'identifiants renforcé. Le routage du pool distingue l'affectation des requêtes nouvelles ou non liées, la commutation proactive fondée sur l'utilisation et la récupération après incident. Une tâche liée -conserve normalement son affinité. Par défaut, `quota` peut la relier lors de sa requête suivante une fois le seuil d'utilisation -franchi, et uniquement vers un compte admissible qui dispose encore d'une marge sous le seuil ; avec `pool.cacheAffinity` activé, cette réaffectation attend que le compte lié soit épuisé ou ne puisse plus servir. La suspension, la temporisation, la réauthentification et la gestion des échecs peuvent, indépendamment, effacer ou déplacer son routage. +conserve normalement son affinité. Par défaut (`pool.cacheAffinity`), cette réaffectation attend que le compte lié soit épuisé ou ne puisse plus servir, et seulement vers un compte dont l'utilisation est strictement inférieure et qui dispose d'une véritable marge de quota. Définissez `pool.cacheAffinity: false` pour laisser `quota` la relier dès la requête suivante une fois le seuil franchi, toujours sous la même contrainte de destination. Un refus de quota 429/402, une pause, une invalidation des identifiants ou l'expiration du TTL libèrent encore la liaison immédiatement ; une série d'échecs transitoires (5xx et autres échecs hors quota) sert la requête sur un autre compte sans supprimer la liaison active. Une requête non liée ne possède aucune liaison active à un compte ; il peut s'agir d'une tâche existante visible après le redémarrage du proxy ou la réinitialisation de l'affinité. Un 429 ou un 402 reçu avant le début de la diffusion déclenche une nouvelle tentative unique sur un autre compte admissible au sein de la même requête, même lorsque la commutation proactive fondée sur l'utilisation est désactivée. Les changements de @@ -204,7 +203,7 @@ et suspend uniquement ceux dont l'utilisation vient d'être confirmée à 100 % | Stratégie | Comportement | | --- | --- | -| `quota` (par défaut) | S'il n'existe aucun compte actif, choisir le compte admissible le moins utilisé selon les fenêtres de 5 heures, d'une semaine et de 30 jours. Sinon, conserver un compte actif admissible sous `autoSwitchThreshold` ; une fois le seuil franchi, une requête non liée peut être déplacée vers un compte admissible moins utilisé, et la requête suivante d'une tâche liée vers un compte admissible qui dispose encore d'une marge sous le seuil sauf si `pool.cacheAffinity` est activé. Avec ce drapeau, l'affinité de cache prime sur la marge de quota et la tâche liée reste jusqu'à ce que le compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir (suspendu, inutilisable). `0` désactive cette réévaluation fondée sur l'utilisation, mais pas la récupération après incident. | +| `quota` (par défaut) | S'il n'existe aucun compte actif, choisir le compte admissible le moins utilisé selon les fenêtres de 5 heures, d'une semaine et de 30 jours. Sinon, conserver un compte actif admissible sous `autoSwitchThreshold` ; une fois le seuil franchi, une requête non liée peut être déplacée vers un compte admissible moins utilisé. Les tâches liées conservent l'affinité de cache par défaut et restent jusqu'à ce que le compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir (suspendu, inutilisable) ; un déplacement exige alors une véritable marge de quota et une utilisation strictement inférieure sur la destination. Définissez `pool.cacheAffinity: false` pour laisser la requête suivante d'une tâche liée bouger au seuil, toujours vers un compte admissible moins utilisé disposant d'une véritable marge de quota. `0` désactive cette réévaluation fondée sur l'utilisation, mais pas la récupération après incident. | | `round-robin` | Répartit uniformément les requêtes non liées entre les comptes admissibles. `autoSwitchThreshold` ne modifie pas la sélection circulaire normale. `accountPoolStickyLimit` (1–100) compte les affectations effectuées avec une même sélection, et non les réponses réussies en amont. | | `fill-first` | Attribue les requêtes non liées au compte actif jusqu'à sa temporisation, sa réauthentification ou le seuil d'évacuation configuré ; une utilisation inconnue n'impose pas de changement. Les tâches liées et saines conservent leur affinité. | diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 2a9f03f0f7..ef987bae78 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -250,9 +250,11 @@ maintainers do not provide policy advice and cannot resolve provider enforcement thread that is already bound. The Codex Desktop (main) account is ordered like any other, so it can be set to **Last** and kept as the reserve. An order set from `ocx account priority` outside those five presets stays visible and selectable on the card. -- Thread affinity prevents per-request flapping. With quota auto-switch enabled, a long-running - thread is periodically re-evaluated and may rebind after its relevant usage reaches the threshold - and a strictly lower-usage eligible account exists. +- Thread affinity prevents per-request flapping. With `pool.cacheAffinity` on (the default), a + long-running thread is not rebound merely because usage crossed the threshold; it stays until the + account is exhausted or cannot serve, and then only onto an account with genuine quota headroom + and strictly lower usage. Set the flag `false` to restore threshold rebinding, still only onto + such a destination. - New sessions can choose the lowest-usage eligible account. Paid plans score the hottest known 5h, weekly, or 30d window; Go/Free plans use the 30d window only. - When WHAM supplies `limit_window_seconds`, Codex Auth classifies a primary window of at least 28 diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index dc1186b2c4..80f78a3be1 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -133,9 +133,10 @@ Codex タスクだけに適用され、このオプション自体が委任を 紐づいた thread を移動させることはありません。Codex Desktop(メイン)アカウントも同じように 並べ替えられるので、**最後** にして予備に回せます。`ocx account priority` でプリセット以外の値を設定した場合も、カード上に 選択肢として残ります。 -- Thread affinity がリクエストごとにアカウントが揺れるのを防ぎます。クォータ自動切り替えがオンなら長く - 実行される thread も定期的に再評価します。関連使用量がしきい値以上で、使用量が確実により低い - 健全アカウントがあればそのアカウントに再紐付けできます。 +- Thread affinity がリクエストごとにアカウントが揺れるのを防ぎます。`pool.cacheAffinity` は既定でオンなので、長く + 実行される thread は使用量がしきい値以上だという理由だけでは再紐付けされません。アカウントが使い切られるか + 処理できなくなったときだけ離れ、その場合も実際に quota 余裕があり usage がより低いアカウントへだけ移ります。 + フラグをオフにすると、使用量が確実により低く実際に quota 余裕がある健全アカウントがあるときのしきい値再紐付けに戻ります。 - 新規セッションは使用量が最も低い健全アカウントを選べます。有料プランは既知の 5 時間、週間、30 日 枠のうち最も高い使用率でスコア付けし、Go/Free プランは 30 日枠のみ使います。 - **クォータ更新**はアカウント使用量を即座に再読み込みし、ルーティングと画面のアカウントカードが同じ値を見るようにします。 diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 4130980693..cb4f5489e6 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -175,7 +175,7 @@ Codex pool のアカウント別選択順を読み書きします。**値が大 適格なアカウントの中で行われ、まだ quota に余裕がある最上位 tier を取り、その中は `accountPoolStrategy` が選びます。一時停止、cooldown、再認証には影響しません。変更は新しいセッションだけでなく **次の未バインドリクエスト** から適用されます。上位の順序に余裕が戻れば preemption が未バインドリクエストを直ちに引き上げます。既にアカウントに紐づいた thread は、通常はそのアカウントを -使い切るまで維持します。ただし再認証エラー、quota cooldown、一時的な失敗の連続はそれより早く紐付けを解除します。受理された書き込みは、どのアカウントの手動の「今すぐこのアカウントを使う」固定も解除します。すでに設定済みの順序を書き込んだ場合も同様で、これは現在選択中のアカウントを保ったまま固定を解除する唯一の方法です(管理 API でアクティブアカウントを解除しても固定は解除されますが、その選択自体も失われます)。プロキシに接続できない場合、 +使い切るまで維持します。再認証エラーと quota cooldown はそれより早く紐付けを解除できます。一時的な失敗の連続は live な紐付けを削除しなくなりました。受理された書き込みは、どのアカウントの手動の「今すぐこのアカウントを使う」固定も解除します。すでに設定済みの順序を書き込んだ場合も同様で、これは現在選択中のアカウントを保ったまま固定を解除する唯一の方法です(管理 API でアクティブアカウントを解除しても固定は解除されますが、その選択自体も失われます)。プロキシに接続できない場合、 不明なアカウント id、受け付けない値はいずれも終了コード 1 です。`--json` は次を返します。 ```text 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 221980eb23..9b5acfa274 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -36,9 +36,9 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `codexAccountPickerEnabled?` | `boolean` | map が空なら off | 有効な `codexAccountNamespaces` mapping から account-qualified Codex picker row を生成するかを制御します。`true` は mapping された行の表示を許可します。空でない map で省略した場合は後方互換性のため有効として扱われ、map が空なら off です。`false` は mapping を削除せず、明示的な `/` routing も無効にせずに、生成行を非表示にして picker の bare native 行を復元します。 | | `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" \| "reset-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`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 は固定ではなく並べ替えです。 | +| `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価できます。紐付け済みタスクは既定(`pool.cacheAffinity`)ではしきい値を超えても同じアカウントを維持し、アカウントが使い切られるか処理できなくなったときだけ離れ、その場合も実際に quota 余裕があり usage がより低いアカウントへだけ移ります。`pool.cacheAffinity: false` にするとしきい値で紐付け済みタスクも再評価します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。紐付け済みタスクは既定ではアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持され、離れるときは実際に quota 余裕があり usage がより低いアカウントへだけ移ります。フラグをオフにすると、しきい値で紐付け済みタスクの次のリクエストも実際に quota 余裕があり usage がより低い適格アカウントへ移せます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 `reset-first`: 使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。 月次リセットはこの順序に使用しません。 | +| `pool.cacheAffinity?` | `boolean` | `true` | 紐付け済み Codex スレッド向けの cache-affinity 順序。`pool.kernel` とは独立で、既定はオンです。不正な値はオンとして読みます。live な紐付けが quota 余裕より優先され、`quota` は使用量が `autoSwitchThreshold` を超えたという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れますが、実際に quota 余裕があり usage がより低いアカウントへだけ移ります。`false` にするとしきい値での再紐付けに戻ります。affinity は固定ではなく並べ替えです。 | | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 | | `upstreamHostCircuitThreshold?` | `number` | `0` | ネイティブOpenAI forwardのResponses送信とcompact送信で、実証済みの接続前DNS/TCP障害に適用するオプトインのサーキットしきい値です。`0`で無効、`1`〜`20`ではその回数の終端論理リクエストが失敗するとprovider-originを30秒間遮断します。遮断中はアカウント選択やupstream送信の前に`Retry-After`付き`503`を返し、時間経過後はhalf-openリクエストを1件だけ許可します。タイムアウトとHTTP応答は数えず、HTTP応答が1件でもあれば回路を閉じます。 Codex Pool ルーティングでアカウントが固定されていない場合にのみ適用され、`codexAccountMode: "direct"` とアカウント修飾セレクターでは動作しません。 | @@ -163,8 +163,10 @@ Clash / Surge / Mihomo 利用者向けの fake-IP DNS 例外は 2 種類あり pool アカウントの追加と quota 更新はダッシュボードの **Codex Auth** ページで処理してください。設定には secret で ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 保管します。Pool routing は新規/未紐付け割り当て、使用量ベースのプロアクティブ切り替え、障害回復に分かれます。 -紐付け済みタスクは通常 affinity を維持します。既定では `quota` はしきい値超過後の次のリクエストで、しきい値未満の余裕が残っている適格アカウントへだけ再紐付けでき、 -`pool.cacheAffinity` がオンなら、紐付け先アカウントが使い切られるか処理できなくなるまでその再紐付けを延期します。 +紐付け済みタスクは通常 affinity を維持します。既定(`pool.cacheAffinity`)では、紐付け先アカウントが +使い切られるか処理できなくなるまで再紐付けを延期し、離れるときは実際に quota 余裕があり usage が +より低いアカウントへだけ移ります。フラグをオフにすると、`quota` はしきい値超過後の次のリクエストで +再紐付けできます。 pause、cooldown、再認証、障害処理も独立して routing を消去または変更できます。未紐付けリクエストには プロキシ再起動や affinity リセット後の既存タスクも含まれます。出力前の **429/402** は使用量ベースの 切り替えがオフでも同じリクエストで適格な代替アカウントへ 1 回再試行できます。アカウント変更後も会話 @@ -178,7 +180,7 @@ pause、cooldown、再認証、障害処理も独立して routing を消去ま 別の適格な Pool アカウントへリクエストを切り替えることがあります。これらの障害回復は `autoSwitchThreshold: 0` でも有効であり、`0` が無効にするのは使用量に基づく予防的な切り替えだけです。 -**割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。`autoSwitchThreshold` 超過後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも、しきい値未満の余裕が残っている適格アカウントへ再紐付けできます。オンなら cache affinity が quota 余裕より優先され、紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は +**割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。`autoSwitchThreshold` 超過後は未紐付けリクエストを移せます。既定では cache affinity が quota 余裕より優先され、紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持され、離れるときは実際に quota 余裕があり usage がより低いアカウントへだけ移ります。usage が不明なアカウントは紐付け済みタスクの移動先にはならず、すべてのアカウントがしきい値を超えていればそのまま残ります。フラグをオフにすると紐付け済みタスクの次のリクエストもしきい値で再紐付けできますが、その場合も実際に quota 余裕があり usage がより低いアカウントへだけ移ります。`round-robin` は 未紐付けリクエストを均等分散し、しきい値は通常の rotation を変えません。`accountPoolStickyLimit` (既定 `1`、1–100)は成功応答ではなく割り当て/紐付け数を数えます。`fill-first` は未紐付けリクエストを cooldown、再認証、または drain threshold までアクティブアカウントへ割り当て、正常な紐付け済みタスクは diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 178620abe9..a6a359245a 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -147,9 +147,11 @@ Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적 않습니다. Codex Desktop(메인) 계정도 똑같이 정렬되므로 **가장 마지막**으로 두어 예비로 남길 수 있습니다. `ocx account priority`로 프리셋 밖의 값을 지정해도 카드에서 그대로 보이고 선택할 수 있습니다. -- Thread affinity가 요청마다 계정이 흔들리는 일을 막습니다. 할당량 자동 전환이 켜져 있으면 오래 - 실행되는 thread도 주기적으로 다시 평가합니다. 관련 사용량이 임계값 이상이고 사용량이 확실히 더 낮은 - 정상 계정이 있으면 그 계정으로 다시 묶일 수 있습니다. +- Thread affinity가 요청마다 계정이 흔들리는 일을 막습니다. `pool.cacheAffinity`가 기본으로 켜져 + 있으므로, 오래 실행되는 thread는 사용량이 임계값 이상이라는 이유만으로 다시 묶이지 않습니다. + 계정이 소진되었거나 처리할 수 없을 때에만 떠나며, 그때도 실제 quota 여유가 있고 usage가 더 낮은 + 계정으로만 옮깁니다. 플래그를 끄면 사용량이 확실히 더 낮고 실제 quota 여유가 있는 정상 계정이 + 있을 때 임계값 재바인딩이 복원됩니다. - 새 세션은 사용량이 가장 낮은 정상 계정을 고를 수 있습니다. 유료 플랜은 알려진 5시간, 주간, 30일 창 중 가장 높은 사용률로 점수를 매기고, Go/Free 플랜은 30일 창만 사용합니다. - WHAM이 `limit_window_seconds`를 제공하면 Codex Auth는 28일 이상인 primary window를 주간이 아닌 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 519fbf27c8..3d637dbd92 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 @@ -240,7 +240,7 @@ Codex pool 계정 하나의 선택 순서를 읽거나 설정합니다. **값이 순서는 어떤 계정을 먼저 볼지 정할 뿐 어떤 계정을 쓸 수 있는지는 정하지 않습니다. 선택은 여전히 적격한 계정 안에서 이루어지며, quota 여유가 남은 최상위 tier를 고른 뒤 그 안은 `accountPoolStrategy`가 정합니다. 일시 중지, cooldown, 재인증에는 영향을 주지 않습니다. 변경은 새 세션뿐 아니라 **다음 미바인딩 요청** 부터 적용됩니다. 상위 순서에 여유가 돌아오면 preemption이 -미바인딩 요청을 곧바로 끌어올립니다. 이미 계정에 바인딩된 thread는 보통 그 계정이 소진될 때까지 유지하지만, 재인증 실패나 quota cooldown, 연속된 일시적 실패는 그보다 먼저 바인딩을 해제합니다. 받아들여진 쓰기는 어떤 계정에 걸려 있든 수동 "지금 이 계정 사용" 고정도 해제합니다. 이미 설정된 순서를 그대로 쓰는 경우에도 마찬가지이며, 이는 현재 선택된 계정을 그대로 두고 고정만 해제하는 유일한 방법입니다(관리 API로 활성 계정을 비우면 고정도 풀리지만 그 선택까지 사라집니다). 프록시에 연결할 수 없거나, 없는 +미바인딩 요청을 곧바로 끌어올립니다. 이미 계정에 바인딩된 thread는 보통 그 계정이 소진될 때까지 유지하지만, 재인증 실패나 quota cooldown은 그보다 먼저 바인딩을 해제할 수 있습니다. 연속된 일시적 실패는 더 이상 live 바인딩을 삭제하지 않습니다. 받아들여진 쓰기는 어떤 계정에 걸려 있든 수동 "지금 이 계정 사용" 고정도 해제합니다. 이미 설정된 순서를 그대로 쓰는 경우에도 마찬가지이며, 이는 현재 선택된 계정을 그대로 두고 고정만 해제하는 유일한 방법입니다(관리 API로 활성 계정을 비우면 고정도 풀리지만 그 선택까지 사라집니다). 프록시에 연결할 수 없거나, 없는 계정 id, 허용되지 않는 값은 모두 종료 코드 1입니다. `--json`은 다음을 반환합니다. ```text 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 f8a4660b71..b6657ba58d 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -36,9 +36,9 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `codexAccountPickerEnabled?` | `boolean` | map이 비어 있으면 꺼짐 | 유효한 `codexAccountNamespaces` 매핑에서 account-qualified Codex 선택기 행을 생성할지 제어합니다. `true`는 매핑된 행의 표시를 허용합니다. 비어 있지 않은 map에서 생략하면 이전 버전과의 호환성을 위해 활성화된 것으로 취급되며, map이 비어 있으면 꺼집니다. `false`는 매핑을 삭제하거나 명시적 `/` 라우팅을 비활성화하지 않은 채 생성 행을 숨기고 선택기에 bare native 행을 복원합니다. | | `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" \| "reset-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 임계값 미만의 여유가 남은 적격 계정으로 옮길 수 있습니다. `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는 고정이 아니라 재정렬입니다. | +| `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있습니다. 바인딩된 작업은 기본값(`pool.cacheAffinity`)에서 이 임계값을 넘어도 계정을 유지하며, 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때에만 떠나고, 그때도 실제 quota 여유가 있고 usage가 더 낮은 계정으로만 옮깁니다. `pool.cacheAffinity: false`로 두면 임계값에서 바인딩된 작업도 재평가합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있습니다. 바인딩된 작업은 기본값에서 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지되며, 떠날 때는 실제 quota 여유가 있고 usage가 더 낮은 계정으로만 옮깁니다. 플래그를 끄면 임계값에서 바인딩된 작업의 다음 요청도 실제 quota 여유가 있고 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. `reset-first`: 사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다. 월간 초기화는 이 순서에 사용하지 않습니다. | +| `pool.cacheAffinity?` | `boolean` | `true` | 바인딩된 Codex 스레드의 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 켜짐입니다. 잘못된 값은 켜진 것으로 읽습니다. live 바인딩이 quota 여유보다 우선하므로 `quota`는 사용량이 `autoSwitchThreshold`를 넘었다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 떠나되, 실제 quota 여유가 있고 usage가 더 낮은 계정으로만 옮깁니다. `false`로 두면 임계값 재바인딩이 복원됩니다. affinity는 고정이 아니라 재정렬입니다. | | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. | | `upstreamHostCircuitThreshold?` | `number` | `0` | 네이티브 OpenAI forward Responses와 compact 전송에서 입증된 연결 전 DNS/TCP 실패에 적용하는 선택적 회로 차단 임계값입니다. `0`은 비활성화하며, `1`~`20`은 이 횟수만큼 최종 논리 요청이 실패하면 provider-origin을 30초 동안 차단합니다. 차단 중에는 계정 선택이나 업스트림 전송 전에 `Retry-After`가 포함된 `503`을 반환하고, 시간이 지나면 반개방 요청 하나만 허용합니다. 타임아웃과 HTTP 응답은 집계하지 않으며, HTTP 응답이 하나라도 오면 회로를 닫습니다. Codex Pool 라우팅에서 계정이 고정되지 않은 경우에만 적용되며, `codexAccountMode: "direct"` 및 계정 한정 선택자에서는 동작하지 않습니다. | @@ -163,9 +163,9 @@ Clash / Surge / Mihomo 사용자를 위한 fake-IP DNS 예외는 두 가지이 pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지에서 처리하세요. 설정에는 secret이 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 보관합니다. Pool 라우팅은 새 작업/바인딩 없는 작업 배정, 사용량 기반 선제 전환, 실패 복구로 -구분됩니다. 바인딩된 작업은 보통 affinity를 유지합니다. 기본값에서 `quota`는 사용량 임계값을 넘은 뒤 -다음 요청에서, 임계값 미만의 여유가 남은 적격 계정으로만 재바인딩할 수 있고, `pool.cacheAffinity`가 켜져 있으면 바인딩된 계정이 소진되었거나 -더 이상 처리할 수 없을 때까지 그 재바인딩을 미룹니다. 일시 중지, cooldown, 재인증, 실패 처리도 +구분됩니다. 바인딩된 작업은 보통 affinity를 유지합니다. 기본값(`pool.cacheAffinity`)에서는 바인딩된 계정이 소진되었거나 +더 이상 처리할 수 없을 때까지 재바인딩을 미루고, 떠날 때는 실제 quota 여유가 있고 usage가 더 낮은 +계정으로만 옮깁니다. 플래그를 끄면 `quota`가 사용량 임계값을 넘은 뒤 다음 요청에서 재바인딩할 수 있습니다. 일시 중지, cooldown, 재인증, 실패 처리도 독립적으로 라우팅을 지우거나 바꿀 수 있습니다. 바인딩 없는 요청은 live 계정 바인딩이 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 전 **429/402**는 사용량 기반 선제 전환이 꺼져 있어도 같은 요청에서 적격 대체 계정으로 한 번 재시도할 수 있습니다. 계정이 바뀌어도 @@ -181,7 +181,7 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 `autoSwitchThreshold: 0`에서도 계속 작동하며, `0`은 사용량 기반 선제 전환만 비활성화합니다. **배정 및 선제 전환 전략:** `quota`(기본)는 활성 계정이 없을 때 최저 usage의 적격 계정을 선택하고, -적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 임계값 미만의 여유가 남은 적격 계정으로 옮길 수 있습니다. 플래그가 켜져 있으면 cache affinity가 quota 여유보다 우선하며, 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 처리할 수 없을 때까지 유지됩니다. +적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있습니다. 기본값에서 cache affinity가 quota 여유보다 우선하며, 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 처리할 수 없을 때까지 유지되고, 떠날 때는 실제 quota 여유가 있고 usage가 더 낮은 계정으로만 옮깁니다. usage를 모르는 계정은 바인딩된 작업의 목적지가 되지 않으며, 모든 계정이 임계값 위이면 그대로 둡니다. 플래그를 끄면 바인딩된 작업의 다음 요청도 임계값에서 옮길 수 있지만, 그때도 실제 quota 여유가 있고 usage가 더 낮은 계정으로만 갑니다. `round-robin`은 바인딩 없는 요청을 균등 분배하며 임계값은 기본 순환에 영향을 주지 않습니다. `accountPoolStickyLimit`(기본 `1`, 1–100)은 성공 응답이 아니라 배정/바인딩 횟수를 셉니다. `fill-first`는 바인딩 없는 요청을 cooldown, 재인증 또는 drain threshold까지 활성 계정에 배정하고, 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 a44101a558..9033cec5f1 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -372,8 +372,12 @@ eligible accounts, taking the highest order tier that still has quota headroom a `accountPoolStrategy` to choose inside it. Pause, cooldown, and reauthentication are unaffected. Changes apply from the **next unbound request**, not only from newly started sessions: preemption moves an unbound request up as soon as a higher order regains headroom. Threads already bound to an account -normally keep it until that account is drained; a reauthentication failure, a quota cooldown, or a -transient-failure streak releases the binding before that. Any accepted write also releases a manual +normally keep it until that account is drained; a reauthentication failure or a quota cooldown still +releases the binding immediately. A transient-failure streak (5xx and other non-quota failures +reaching `upstreamFailoverThreshold`, default 3) no longer deletes a live binding: the request is +served by another account while the binding is kept, and the task returns to its own account as soon +as that account serves again. If the account is still failing after 10 minutes the binding is +released normally. This hold is independent of `pool.cacheAffinity`. Any accepted write also releases a manual "use this account now" pin, on whichever account held it, including a write that stores the order an account already had — this is the only way to clear a pin while keeping the account that is currently selected. (Clearing the active account through the management API releases a diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 705c28a6f4..7eb8a12524 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -51,9 +51,9 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | | `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, moving them only to an eligible account that still has headroom below the 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" \| "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 an eligible account that still has headroom below the threshold. 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. | +| `autoSwitchThreshold?` | `number` | `80` | Usage threshold for placing new/unbound work. `quota` can re-evaluate unbound tasks on their next request once usage crosses this threshold. Bound tasks keep their account past the threshold by default (`pool.cacheAffinity`); they leave only when that account is exhausted or otherwise cannot serve, and then only for an account with genuine quota headroom and strictly lower usage. Set `pool.cacheAffinity: false` to re-evaluate bound tasks at this threshold, still only onto such a destination. `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" \| "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. Bound tasks follow `pool.cacheAffinity` (on by default): they stay until the account is exhausted (known usage at 100%) or otherwise cannot serve, and then may rebind only to an account with genuine quota headroom and strictly lower usage. Set the flag `false` to proactively rebind a bound task at the threshold, still only onto such a destination. `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` | `true` | Cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. On by default; omitting the key or setting `true` keeps a bound task on its account until that account genuinely cannot serve. Only an explicit `false` restores threshold-based rebinding of bound tasks. 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%) — and then only to an account with genuine quota headroom and strictly lower usage. Under either setting, an account with unknown usage is never chosen as a destination for a bound task, so when every account sits above the threshold the task stays put. 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. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Opt-in circuit threshold for proven pre-connection DNS/TCP failures on native OpenAI forward Responses and compact sends. `0` disables it; `1`–`20` opens a 30-second provider-origin cooldown after that many terminal logical requests. While open, requests receive `503` with `Retry-After` before account selection or upstream send; after cooldown, one half-open request is admitted. Timeouts and HTTP responses never count, and any HTTP response closes the circuit. Applies only to Codex Pool routing with no pinned account; it is inert for `codexAccountMode: "direct"` and account-qualified selectors. | @@ -538,10 +538,14 @@ validation never applies the IPv6 accommodation. Use **Codex Auth** in the dashboard to add pool accounts and refresh quotas. `config.json` stores non-secret metadata; access and refresh tokens use the hardened credential store. Pool routing separates new/unbound assignment, usage-based proactive switching, and failure recovery. A bound task -normally keeps affinity. By default `quota` may rebind it on its next request after the usage -threshold is crossed, and only to an eligible account that still has headroom below the threshold; with `pool.cacheAffinity` on, that rebind waits until the bound account is -exhausted or otherwise cannot serve. Pause, cooldown, reauthentication, and failure handling can -clear or move routing independently. An unbound request has no live account binding; this can include an existing visible +normally keeps affinity. By default (`pool.cacheAffinity`) `quota` does not rebind it merely because +the usage threshold is crossed; that rebind waits until the bound account is exhausted or otherwise +cannot serve, and then only onto an account with genuine quota headroom and strictly lower usage. +Set `pool.cacheAffinity: false` to restore threshold rebinding of bound tasks, still only onto such +a destination. Pause, cooldown, reauthentication, and quota refusals still release a live binding +immediately. A transient-failure streak serves the request from another account while keeping the +binding, and the task returns once that account serves again; after 10 minutes the binding is +released normally. An unbound request has no live account binding; this can include an existing visible task after proxy restart or affinity reset. A pre-stream 429 or 402, or a 5xx response whose bounded body explicitly reports quota exhaustion, retries once on an eligible alternate account in the same request, even when usage-based proactive switching is off. The ordinary transient-5xx policy runs @@ -562,7 +566,7 @@ and pauses only accounts freshly confirmed at 100%; unknown or failed refreshes | Strategy | Behaviour | | --- | --- | -| `quota` (default) | If no active account exists, choose the lowest-usage eligible account across 5-hour, weekly, and 30-day windows. Otherwise retain an eligible active account below `autoSwitchThreshold`; after it crosses the threshold, an unbound request can move to a lower-usage eligible account, and a bound task's next request can move to an eligible account that still has headroom below the threshold unless `pool.cacheAffinity` is on. With that flag on, cache affinity outranks quota headroom and the bound task stays until the account is exhausted (known usage at 100%) or cannot serve (paused, unusable). `0` disables this usage-driven re-evaluation, not failure recovery. | +| `quota` (default) | If no active account exists, choose the lowest-usage eligible account across 5-hour, weekly, and 30-day windows. Otherwise retain an eligible active account below `autoSwitchThreshold`; after it crosses the threshold, an unbound request can move to a lower-usage eligible account. Bound tasks keep cache affinity by default and stay until the account is exhausted (known usage at 100%) or cannot serve (paused, unusable); a move then requires genuine quota headroom and strictly lower usage on the destination, so the task stays put when every account is over the threshold or the only cooler account has unknown usage. Set `pool.cacheAffinity: false` to let a bound task's next request move at the threshold, still only onto such a destination. `0` disables this usage-driven re-evaluation, not failure recovery. | | `round-robin` | Evenly assign unbound requests across eligible accounts. `autoSwitchThreshold` does not change normal round-robin selection. `accountPoolStickyLimit` (1–100) counts assignments on one pick, not successful upstream responses. | | `fill-first` | Assign unbound requests to the active account until cooldown, reauthentication, or the configured drain threshold; unknown usage does not force a switch. Healthy bound tasks keep affinity. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 0410d733ef..43f1f26bb5 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -139,9 +139,11 @@ bun run dev:gui остальными, поэтому его можно поставить **Последним** и держать в резерве. Порядок, заданный через `ocx account priority` вне этих пяти пресетов, остаётся видимым и выбираемым на карточке. - Привязка потока предотвращает метание между аккаунтами на каждом запросе. При включённом - автопереключении по квоте долгоживущий поток периодически переоценивается и может - перепривязаться, когда его релевантное использование достигает порога и существует подходящий - аккаунт со строго меньшим использованием. + по умолчанию `pool.cacheAffinity` долгоживущий поток не перепривязывается только потому, + что использование достигло порога; он остаётся, пока аккаунт не исчерпан или не может + обслуживать запрос, и тогда переносится только на аккаунт со строго меньшим использованием + и реальным запасом квоты. Выключите флаг, чтобы вернуть перепривязку по порогу, когда есть + подходящий аккаунт со строго меньшим использованием. - Новые сессии могут выбирать подходящий аккаунт с наименьшим использованием. Платные тарифы оцениваются по самому загруженному из известных окон — 5-часового, недельного или 30-дневного; тарифы Go/Free используют только 30-дневное окно. diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 928082fe6d..05bce2e1e4 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -216,7 +216,7 @@ generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, него аккаунт выбирает `accountPoolStrategy`. Пауза, cooldown и повторная аутентификация не затрагиваются. Изменения действуют начиная со **следующего непривязанного запроса**, а не только для новых сессий: как только у более высокого порядка снова появляется запас, preemption сразу поднимает непривязанный -запрос. Потоки, уже привязанные к аккаунту, обычно сохраняют его до исчерпания, но ошибка повторной аутентификации, cooldown по квоте или серия временных сбоев снимают привязку раньше. +запрос. Потоки, уже привязанные к аккаунту, обычно сохраняют его до исчерпания; ошибка повторной аутентификации или cooldown по квоте по-прежнему снимают привязку раньше. Серия временных сбоев (5xx и другие не-квотные ошибки, достигшие `upstreamFailoverThreshold`, по умолчанию 3) живую привязку не удаляет: запрос обслуживается на другом аккаунте, и поток возвращается, как только свой аккаунт снова может обслуживать; если аккаунт всё ещё сбоит через 10 минут, привязка снимается обычным образом. Любая принятая запись также снимает ручное закрепление "использовать этот аккаунт сейчас" с того аккаунта, на котором оно стояло. Это касается и записи того же порядка, который уже был установлен. Такой способ — единственный, который снимает закрепление, сохранив выбранный аккаунт. Сброс активного аккаунта через management API тоже снимает закрепление, но вместе с самим выбором. Недоступный прокси, неизвестный id аккаунта или значение вне допустимого набора завершаются с кодом 1. `--json` возвращает: 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 ff4833b339..ca389d9ffd 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -37,9 +37,9 @@ ocx models provider openrouter on | `codexAccountPickerEnabled?` | `boolean` | выкл. при пустой map | Управляет созданием account-qualified строк picker'а Codex из подходящих сопоставлений `codexAccountNamespaces`. `true` разрешает показывать сопоставленные строки. Если поле не задано при непустой map, функция считается включённой для обратной совместимости; при пустой map она выключена. `false` скрывает созданные строки и возвращает bare native-строки в picker, не удаляя сопоставления и не отключая точную маршрутизацию `/`. | | `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" \| "reset-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт, у которого ещё есть запас ниже порога. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный 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 меняет порядок, а не закрепляет учётные данные. | +| `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос. Привязанная задача по умолчанию (`pool.cacheAffinity`) сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос, и переносится только на аккаунт со строго меньшим usage и реальным запасом квоты. Установите `pool.cacheAffinity: false`, чтобы повторно оценивать привязанные задачи на этом пороге. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Привязанные задачи следуют `pool.cacheAffinity` (по умолчанию включён): они остаются, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос, и тогда переносятся только на аккаунт со строго меньшим usage и реальным запасом квоты. Установите флаг в `false`, чтобы на пороге проактивно перепривязать задачу к подходящему аккаунту с меньшим usage, всё равно только при той же проверке назначения. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. `reset-first`: Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию. Месячный сброс не определяет этот порядок. | +| `pool.cacheAffinity?` | `boolean` | `true` | Порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию включён; пропуск ключа или `true` сохраняет привязку, а любое значение кроме `false` читается как включённое. Живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage пересёк `autoSwitchThreshold` — перенос живого разговора сбрасывает изолированный по аккаунту prompt cache. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден, исключён планом, с недействительными credential'ами, со сменённой generation, с истёкшим TTL, при отказе квоты 429/402 или реально исчерпан (известный usage 100%) — и только на аккаунт со строго меньшим usage и реальным запасом квоты. Аккаунт с неизвестным usage никогда не выбирается как назначение для привязанной задачи. Если все аккаунты выше порога, привязанная задача остаётся: лучшего назначения нет. `false` возвращает перепривязку по порогу, всё равно только при той же проверке назначения. Affinity меняет порядок, а не закрепляет учётные данные. | | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Опциональный порог circuit breaker для доказанных DNS/TCP-сбоев до соединения в нативных OpenAI forward Responses- и compact-отправках. `0` отключает его; `1`–`20` открывает 30-секундный cooldown для provider-origin после такого числа завершившихся логических запросов. Пока circuit открыт, до выбора аккаунта и upstream-отправки возвращается `503` с `Retry-After`; после cooldown допускается один half-open запрос. Таймауты и HTTP-ответы не учитываются, а любой HTTP-ответ закрывает circuit. Применяется только к маршрутизации Codex Pool без закреплённого аккаунта; при `codexAccountMode: "direct"` и для селекторов с указанием аккаунта схема не активна. | @@ -192,10 +192,8 @@ redirect'ов для обычных provider-request'ов реализована Конфигурация хранит только несекретные метаданные аккаунтов; access- и refresh-токены хранятся в защищённом хранилище учётных данных аккаунтов Codex. Pool routing разделяет назначение новых/непривязанных задач, проактивное переключение по использованию и восстановление после сбоев. -Привязанная задача обычно сохраняет affinity. По умолчанию `quota` может перепривязать её при следующем -запросе после превышения порога, и только на подходящий аккаунт, у которого ещё есть запас ниже порога; при включённом `pool.cacheAffinity` эта перепривязка ждёт, пока -привязанный аккаунт не будет исчерпан или не сможет обслуживать запрос. Pause, cooldown, повторная аутентификация и обработка сбоев также -могут независимо очистить или изменить routing. Непривязанным может стать и существующая задача +Привязанная задача обычно сохраняет affinity. По умолчанию (`pool.cacheAffinity`) эта перепривязка ждёт, пока +привязанный аккаунт не будет исчерпан или не сможет обслуживать запрос, и тогда только на аккаунт со строго меньшим usage и реальным запасом квоты. Установите `pool.cacheAffinity: false`, чтобы `quota` перепривязывала задачу при следующем запросе после превышения порога, всё равно только при той же проверке назначения. Отказ квоты 429/402, пауза, инвалидация credential'ов и истечение TTL по-прежнему снимают привязку сразу; серия временных сбоев (5xx и другие не-квотные ошибки) обслуживает запрос на другом аккаунте, не удаляя живую привязку. Непривязанным может стать и существующая задача после перезапуска прокси или сброса affinity. Отказ **429/402** до вывода допускает одну попытку на подходящем альтернативном аккаунте даже при выключенном переключении по использованию. Контекст разговора сохраняется и воспроизводится, но prompt cache провайдера между аккаунтами @@ -211,7 +209,7 @@ redirect'ов для обычных provider-request'ов реализована после чего запрос может перейти на другой подходящий аккаунт Pool. Эти переходы восстановления остаются активными при `autoSwitchThreshold: 0`; значение `0` отключает только проактивное переключение по использованию. -**Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт, у которого ещё есть запас ниже порога. Если флаг включён, cache affinity важнее запаса квоты, и привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы, а порог не +**Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Привязанные задачи по умолчанию держат cache affinity и остаются, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос; перенос тогда требует реального запаса квоты и строго меньшего usage на назначении. Установите `pool.cacheAffinity: false`, чтобы следующий запрос привязанной задачи мог уйти на пороге, всё равно только на подходящий аккаунт с меньшим usage и реальным запасом квоты. `round-robin` равномерно распределяет непривязанные запросы, а порог не меняет обычную ротацию. `accountPoolStickyLimit` (по умолчанию `1`, 1–100) считает назначения/bind, а не успешные ответы. `fill-first` назначает непривязанные запросы активному аккаунту до cooldown, reauth или порога исчерпания; здоровые привязанные задачи сохраняют affinity. Эти стратегии не diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index 36d4c66f21..abb564ac48 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -175,10 +175,12 @@ ve diğer sağlayıcılardan ayrıdır. (ana) hesabı diğerleri gibi sıralanır, böylece **Son** olarak ayarlanabilir ve yedek olarak tutulabilir. Bu beş önayarın dışındaki `ocx account priority`'den ayarlanan bir sıra kartta görünür ve seçilebilir kalır. -- İş parçacığı bağlılığı istek başına dalgalanmayı önler. Kota otomatik geçişi - etkinken uzun süredir çalışan bir iş parçacığı düzenli olarak yeniden - değerlendirilir ve ilgili kullanımı eşiğe ulaştıktan ve kesinlikle daha düşük - kullanımlı uygun bir hesap mevcut olduğunda yeniden bağlanabilir. +- İş parçacığı bağlılığı istek başına dalgalanmayı önler. `pool.cacheAffinity` + varsayılan olarak açıkken uzun süredir çalışan bir iş parçacığı, kullanım eşiğe + ulaştı diye yeniden bağlanmaz; hesap tükenene veya hizmet veremez hale gelene + kadar kalır ve o zaman yalnızca kullanımı kesin olarak daha düşük ve gerçek kota + payı olan bir hesaba geçer. Bayrağı kapatınca, kesinlikle daha düşük kullanımlı + uygun bir hesap varsa eşik yeniden bağlaması geri gelir. - Yeni oturumlar en düşük kullanımlı uygun hesabı seçebilir. Ücretli planlar bilinen en sıcak 5 saatlik, haftalık veya 30 günlük pencereyi puanlar; Go/Ücretsiz planlar yalnızca 30 günlük pencereyi kullanır. diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index cc1f49e105..bee889f6d8 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -264,8 +264,11 @@ doğrulama etkilenmez. Değişiklikler yalnızca yeni başlatılan oturumlardan **bir sonraki bağımsız istekten** itibaren geçerlidir: önceliklendirme daha yüksek bir sıra pay kazandığı anda bağımsız bir isteği yukarı taşır. Bir hesaba zaten bağlı olan iş parçacıkları normalde o hesap boşalana kadar onu tutar; bir -yeniden kimlik doğrulama hatası, bir kota soğuma süresi veya bir geçici arıza -serisi bundan önce bağlamayı serbest bırakır. Kabul edilen herhangi bir yazma, +yeniden kimlik doğrulama hatası veya bir kota soğuma süresi bağlamayı hâlâ +bundan önce serbest bırakır. Geçici arıza serisi (5xx ve diğer kota dışı arızaların +`upstreamFailoverThreshold`'a, varsayılan 3, ulaşması) canlı bağlamayı silmez: +istek başka bir hesapta sunulur ve görev, kendi hesabı yeniden hizmet verince +oraya döner; hesap 10 dakika sonra hâlâ arızalıysa bağlama normal şekilde serbest kalır. Kabul edilen herhangi bir yazma, hangi hesap tutarsa tutsun manuel bir "bu hesabı şimdi kullan" sabitlemesini de serbest bırakır, bir hesabın zaten sahip olduğu sırayı saklayan bir yazma dahil — bu, geçerli olarak seçilen hesabı tutarken bir sabitlemeyi temizlemenin tek 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 2306e9318e..3126b9046c 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -38,9 +38,9 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `activeCodexAccountId?` | `string` | — | Sonraki istek için manuel olarak seçilen Havuz hesabı. Seçim iş parçacığı bağlılığını temizler; devam eden istekler yakalanan kimlik bilgilerini korur. | | `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; yalnızca eşiğin altında hâlâ kota payı olan uygun bir hesaba taşır. `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" \| "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 eşiğin altında hâlâ kota payı olan 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. | +| `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. Bağlı görevler varsayılan olarak (`pool.cacheAffinity`) eşiğin ötesinde hesabını korur; hesap tükenene veya hizmet veremez hale gelene kadar kalır ve o zaman yalnızca kullanımı kesin olarak daha düşük ve gerçek kota payı olan bir hesaba geçer. `pool.cacheAffinity: false` ile bağlı görevler de bu eşikte yeniden değerlendirilir. `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" \| "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. Bağlı görevler `pool.cacheAffinity`'yi izler (varsayılan açık): hesap tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır ve o zaman yalnızca kullanımı kesin olarak daha düşük ve gerçek kota payı olan bir hesaba geçer. Bayrağı `false` yapınca bağlı bir görev eşiğinde proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlanabilir; hedef kısıtı yine geçerlidir. `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` | `true` | Bağlı Codex iş parçacıkları için önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak açıktır; anahtarı atlamak veya `true` vermek bağlamayı korur ve `false` dışındaki bir değer açık okunur. Canlı bağlama kota payından öndedir: `quota`, kullanımın `autoSwitchThreshold`'u geçmesi nedeniyle iş parçacığını taşımaz, çünkü canlı bir konuşmayı taşımak hesaba özel istem önbelleğini atar. Hesap duraklatılmış, kullanılamaz, plandan dışlanmış, kimlik bilgisi geçersiz, generation değişmiş, TTL dolmuş, 429/402 kota reddi almış veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır ve yalnızca kullanımı kesin olarak daha düşük ve gerçek kota payı olan bir hesaba geçer. Kullanımı bilinmeyen bir hesap bağlı bir görev için hedef olarak asla seçilmez. Tüm hesaplar eşiğin üzerindeyse bağlı görev yerinde kalır; daha iyi bir hedef yoktur. `false` eşiğe göre yeniden bağlamayı geri getirir; hedef kısıtı yine geçerlidir. 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. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Yerel OpenAI iletme Responses ve sıkıştırma gönderimlerinde kanıtlanmış bağlantı öncesi DNS/TCP arızaları için isteğe bağlı devre eşiği. `0` devre dışı bırakır; `1`–`20`, bu kadar terminal mantıksal istekten sonra 30 saniyelik bir sağlayıcı-kaynak soğuma süresi açar. Açıkken istekler, hesap seçiminden veya yukarı akış gönderiminden önce `Retry-After` ile `503` alır; soğuma süresinden sonra bir yarı açık isteğe izin verilir. Zaman aşımları ve HTTP yanıtları asla sayılmaz ve herhangi bir HTTP yanıtı devreyi kapatır. Yalnızca sabitlenmiş hesabı olmayan Codex Havuz yönlendirmesi için geçerlidir; `codexAccountMode: "direct"` ve hesap nitelikli seçiciler için etkisizdir. | @@ -198,11 +198,8 @@ Auth** kullanın. `config.json` gizli olmayan meta verileri saklar; erişim ve yenileme belirteçleri güçlendirilmiş kimlik bilgisi deposunu kullanır. Havuz yönlendirmesi yeni/bağımsız atamayı, kullanıma dayalı proaktif geçişi ve arıza kurtarmayı ayırır. Bağlı bir görev normalde bağlılığı korur. Varsayılan olarak -`quota`, kullanım eşiği aşıldıktan sonraki isteğinde onu yalnızca eşiğin altında hâlâ kota payı olan uygun bir hesaba yeniden bağlayabilir; -`pool.cacheAffinity` açıkken bu yeniden bağlama, bağlı hesap tükenene veya -hizmet veremez hale gelene kadar bekler. Duraklatma, soğuma, yeniden kimlik -doğrulama ve arıza işleme ise yönlendirmeyi bağımsız olarak temizleyebilir veya -taşıyabilir. Bağımsız bir +(`pool.cacheAffinity`) bu yeniden bağlama, bağlı hesap tükenene veya +hizmet veremez hale gelene kadar bekler ve o zaman yalnızca kullanımı kesin olarak daha düşük ve gerçek kota payı olan bir hesaba geçer. `pool.cacheAffinity: false` ile `quota`, kullanım eşiği aşıldıktan sonraki isteğinde onu yeniden bağlayabilir; hedef kısıtı yine geçerlidir. 429/402 kota reddi, duraklatma, kimlik bilgisi geçersizliği ve TTL dolması bağlamayı hemen serbest bırakır; geçici arıza serisi (5xx ve diğer kota dışı arızalar) isteği başka bir hesapta sunar ama canlı bağlamayı silmez. Bağımsız bir isteğin canlı hesap bağlaması yoktur; bu, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra mevcut görünür bir görevi içerebilir. Akış öncesi bir 429 veya 402, kullanıma dayalı proaktif geçiş kapalı olsa bile aynı istekte @@ -230,7 +227,7 @@ kalır. | Strateji | Davranış | | --- | --- | -| `quota` (varsayılan) | Aktif bir hesap yoksa 5 saatlik, haftalık ve 30 günlük pencerelerde en düşük kullanımlı uygun hesabı seçin. Aksi takdirde `autoSwitchThreshold` altında uygun bir aktif hesabı tutun; eşiği aştıktan sonra bağımsız bir istek daha düşük kullanımlı uygun bir hesaba geçebilir ve `pool.cacheAffinity` kapalıysa bağlı bir görevin bir sonraki isteği eşiğin altında hâlâ kota payı olan uygun bir hesaba geçebilir. Bayrak açıkken önbellek bağlılığı kota payından öndedir ve bağlı görev, hesap tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene (duraklatılmış, kullanılamaz) kadar kalır. `0`, bu kullanım odaklı yeniden değerlendirmeyi devre dışı bırakır, arıza kurtarmayı devre dışı bırakmaz. | +| `quota` (varsayılan) | Aktif bir hesap yoksa 5 saatlik, haftalık ve 30 günlük pencerelerde en düşük kullanımlı uygun hesabı seçin. Aksi takdirde `autoSwitchThreshold` altında uygun bir aktif hesabı tutun; eşiği aştıktan sonra bağımsız bir istek daha düşük kullanımlı uygun bir hesaba geçebilir. Bağlı görevler varsayılan olarak önbellek bağlılığını korur ve hesap tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene (duraklatılmış, kullanılamaz) kadar kalır; bir geçiş o zaman hedefte gerçek kota payı ve kesin olarak daha düşük kullanım ister. `pool.cacheAffinity: false` ile bağlı bir görevin bir sonraki isteği eşikte hareket edebilir, yine yalnızca gerçek kota payı olan daha düşük kullanımlı uygun bir hesaba. `0`, bu kullanım odaklı yeniden değerlendirmeyi devre dışı bırakır, arıza kurtarmayı devre dışı bırakmaz. | | `round-robin` | Bağımsız istekleri uygun hesaplar arasında eşit olarak atayın. `autoSwitchThreshold` normal round-robin seçimini değiştirmez. `accountPoolStickyLimit` (1–100), başarılı yukarı akış yanıtlarını değil, bir seçimdeki atamaları sayar. | | `fill-first` | Bağımsız istekleri soğuma, yeniden kimlik doğrulama veya yapılandırılmış tükenme eşiğine kadar aktif hesaba atayın; bilinmeyen kullanım geçişe zorlamaz. Sağlıklı bağlı görevler bağlılığı korur. | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 5482c0a89c..83a97ee387 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -119,9 +119,10 @@ Pool 模式会在主账号和已添加的 Codex 账号之间选择;Direct 只 只有当它上面的账号全部耗尽或不可用时才会降到更靠后的顺序。改动顺序会从**下一个未绑定请求**起生效, 且不会移动已经绑定的 thread。Codex Desktop(主)账号同样参与排序,可以设为 **最后** 留作备用。 用 `ocx account priority` 设置的非预设值也会保留在卡片上,仍可选择。 -- Thread affinity 可避免每个请求都来回切换账号。启用配额自动切换后,长时间运行的 thread 会被 - 定期重新评估;当相关 usage 达到阈值,并且存在使用率确实更低的可用账号时,该 thread 可能会 - 重新绑定。 +- Thread affinity 可避免每个请求都来回切换账号。默认开启 `pool.cacheAffinity` 后,长时间运行的 + thread 不会仅因 usage 达到阈值就重新绑定;只有账号耗尽或无法继续服务时才会离开,并且只改绑到 + 确有额度余量且使用率确实更低的账号。关闭该标志后,才会在存在使用率确实更低且确有额度余量的 + 可用账号时按阈值重新绑定。 - 新 session 可以选择 usage 最低的可用账号。付费计划按已知 5h、每周、30d 窗口中的最高使用率 评分;Go/Free 计划只使用 30d 窗口。 - **Refresh quotas** 会立即重新读取账号 usage,使路由逻辑与页面上的账号卡片使用同一份数据。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 59645d6356..181b2aa4e9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -193,7 +193,7 @@ generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, 顺序决定的是先考虑哪些账号,而不是哪些账号可用:选择仍然只在合格账号中进行,取仍有 quota 余量的 最高 tier,再由 `accountPoolStrategy` 在该 tier 内挑选。暂停、cooldown 和重新认证都不受影响。改动 从**下一个未绑定请求**起生效,而不仅限于新开的 session:一旦更高顺序重新有了余量,preemption 会立即把 -未绑定请求提上去。已绑定账号的 thread 通常会保留该账号直到其用尽,但重新认证失败、quota cooldown 或连续的临时失败都会更早解除绑定。任何被接受的写入也会解除手动的“立即使用此账号”固定,无论固定在哪个账号上;写入与当前相同的顺序同样会解除,这是在保留当前所选账号的前提下解除固定的唯一方式(通过管理 API 清空活动账号同样会解除固定,但所选账号也一并丢失)。代理不可达、账号 id 不存在或取值不在 +未绑定请求提上去。已绑定账号的 thread 通常会保留该账号直到其用尽;重新认证失败或 quota cooldown 仍可能更早解除绑定。连续的临时失败不再删除仍有效的线程绑定:请求会改由其他账号处理,绑定保留,该账号恢复服务后任务会回到原账号;若 10 分钟后仍在失败,绑定才会按常规解除。任何被接受的写入也会解除手动的“立即使用此账号”固定,无论固定在哪个账号上;写入与当前相同的顺序同样会解除,这是在保留当前所选账号的前提下解除固定的唯一方式(通过管理 API 清空活动账号同样会解除固定,但所选账号也一并丢失)。代理不可达、账号 id 不存在或取值不在 允许范围内都会返回退出码 1。`--json` 返回: ```text 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 cd8fcacd71..b28a5a2af4 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 @@ -36,9 +36,9 @@ ocx models provider openrouter on | `codexAccountPickerEnabled?` | `boolean` | 映射为空时关闭 | 控制是否根据有效的 `codexAccountNamespaces` 映射生成账户限定的 Codex 选择器行。`true` 允许显示映射行。在非空映射中省略此字段时,为保持向后兼容会视为已启用;映射为空时则关闭。`false` 会隐藏生成行并恢复选择器中的裸原生行,但不会删除映射,也不会禁用精确的 `/` 路由。 | | `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" \| "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 是重排而非钉死。 | +| `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务。已绑定任务默认(`pool.cacheAffinity`)在越过阈值后仍保留账号,直到该账号耗尽或无法继续服务,并且只改绑到确有额度余量且 usage 严格更低的账号。将 `pool.cacheAffinity` 设为 `false` 才会在该阈值重新评估已绑定任务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号。已绑定任务默认会保留到账号耗尽(已知 usage 为 100%)或无法继续服务,改绑时只前往确有额度余量且 usage 严格更低的账号。关闭该标志后,也可在该阈值把已绑定任务的下一次请求改绑到确有额度余量且 usage 严格更低的账号。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 `reset-first`: 在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。 此排序不使用月额度重置时间。 | +| `pool.cacheAffinity?` | `boolean` | `true` | 已绑定 Codex 线程的 cache-affinity 排序,独立于 `pool.kernel`。默认开启;省略该键或设为 `true` 即为开启,非法值视为开启。live 绑定优先于 quota 余量:`quota` 不会仅因用量越过 `autoSwitchThreshold` 就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,且只改绑到确有额度余量且 usage 严格更低的账号。用量未知的账号不会作为已绑定任务的改绑目标。设为 `false` 可恢复按阈值改绑。affinity 是重排而非钉死。 | | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 | | `upstreamHostCircuitThreshold?` | `number` | `0` | 原生 OpenAI forward Responses 与 compact 发送的可选断路器阈值,仅统计已证明的连接前 DNS/TCP 故障。`0` 表示禁用;`1`–`20` 表示在这么多个终止逻辑请求失败后,对 provider-origin 冷却 30 秒。断路期间会在账户选择和上游发送之前返回带 `Retry-After` 的 `503`;冷却结束后只允许一个半开请求。超时和 HTTP 响应不计数,任意 HTTP 响应都会关闭断路器。 仅适用于未固定账户的 Codex Pool 路由;在 `codexAccountMode: "direct"` 或使用账户限定选择器时不会启用。 | @@ -162,9 +162,7 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account metadata;access/refresh token 存放在加固的 Codex account credential store 中。Pool routing -分为新建/未绑定任务分配、基于用量的主动切换和故障恢复。已绑定任务通常保持 affinity。默认情况下 -`quota` 可在超过阈值后的下一次请求中重新绑定到仍有低于阈值余量的合格账号;开启 `pool.cacheAffinity` 后,该重新绑定会等到 -绑定账号耗尽或无法继续服务。暂停、cooldown、重新认证和故障处理也能独立清除或改变 +分为新建/未绑定任务分配、基于用量的主动切换和故障恢复。已绑定任务通常保持 affinity。默认(`pool.cacheAffinity`)下,该重新绑定会等到绑定账号耗尽或无法继续服务,并且只改绑到确有额度余量且 usage 严格更低的账号;所有账号都高于阈值时,已绑定任务留在原账号。关闭该标志后,`quota` 可在超过阈值后的下一次请求中重新绑定,但仍只改绑到确有额度余量且 usage 严格更低的账号。暂停、cooldown、重新认证和故障处理也能独立清除或改变 routing。未绑定请求没有 live 账号绑定,也可能是代理重启或 affinity 重置后的已有任务。输出前的 **429/402** 即使在关闭基于用量的主动切换时,也可在同一请求中对合格替代账号重试一次。 账号变化后会保留并重放对话上下文,但账号间的 provider prompt cache 不保证复用,可能需要重新预热。 @@ -177,7 +175,7 @@ routing。未绑定请求没有 live 账号绑定,也可能是代理重启或 并可将请求切换到另一个符合条件的 Pool 账户。即使 `autoSwitchThreshold: 0`, 这些故障恢复流程仍然有效;`0` 只会禁用基于用量的主动切换。 -**分配与主动切换策略:** `quota`(默认)在没有活跃账号时选择 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切到仍有低于阈值余量的合格账号。开启后,cache affinity 优先于 quota 余量,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求,用量 +**分配与主动切换策略:** `quota`(默认)在没有活跃账号时选择 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号。默认下 cache affinity 优先于 quota 余量,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务,改绑时只前往确有额度余量且 usage 严格更低的账号。关闭该标志后,也可在该阈值把已绑定任务的下一次请求改绑到确有额度余量且 usage 严格更低的账号。`round-robin` 均匀分配未绑定请求,用量 阈值不会改变正常轮换。`accountPoolStickyLimit`(默认 `1`,1–100)统计分配/绑定,而不是成功响应。 `fill-first` 在 cooldown、重新认证或耗尽阈值前把未绑定请求分配给活跃账号;健康的已绑定任务保持 affinity。这些策略不能规避 provider enforcement。 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 037d092c9b..b6f79775d3 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -111,9 +111,10 @@ Dashboard 的 **Sub-agent delegation** 選擇器會儲存 `injectionModel`,以 - 手動選擇帳號會影響下一次新建的 Codex session;已經繫結帳號的 thread 不會因為這次手動切換而 在中途轉移。 -- Thread affinity 可避免每個請求都來回切換帳號。啟用配額自動切換後,長時間執行的 thread 會被 - 定期重新評估;當相關 usage 達到閾值,並且存在使用率確實更低的可用帳號時,該 thread 可能會 - 重新繫結。 +- Thread affinity 可避免每個請求都來回切換帳號。預設開啟 `pool.cacheAffinity` 後,長時間執行的 + thread 不會只因 usage 達到閾值就重新繫結;只有帳號耗盡或無法繼續服務時才會離開,並且只重新繫結到 + 確有額度餘裕且使用率確實更低的帳號。關閉該設定後,才會在存在使用率確實更低且確有額度餘裕的 + 可用帳號時依閾值重新繫結。 - 新 session 可以選擇 usage 最低的可用帳號。付費計劃按已知 5h、每週、30d 視窗中的最高使用率 評分;Go/Free 計劃只使用 30d 視窗。 - **Refresh quotas** 會立即重新讀取帳號 usage,使路由邏輯與頁面上的帳號卡片使用同一份資料。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index e1ad7b7653..0dbe9f071d 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -202,8 +202,7 @@ openai main last` 就是把它保留為後備的方式。 順序決定哪些帳號優先被考慮,而不是哪些可用:選取仍在合格帳號之間進行,取仍有配額 餘裕的最高順序層級,並讓 `accountPoolStrategy` 在該層級內選擇。暫停、冷卻與重新認證 不受影響。變更從**下一個未繫結請求**開始生效,而不只是新啟動的 session:一旦較高的 -順序恢復餘裕,preemption 就會優先移動未繫結的請求。已繫結到某個帳號的執行緒通常會 -保留到該帳號被耗盡;重新認證失敗、配額冷卻或一連串暫時失敗會提前解除繫結。任何接受的 +順序恢復餘裕,preemption 就會優先移動未繫結的請求。已繫結到某個帳號的執行緒通常會保留到該帳號被耗盡;重新認證失敗或配額冷卻仍可能提前解除繫結。一連串暫時失敗不再刪除仍有效的執行緒繫結:請求會改由其他帳號處理,繫結保留,該帳號恢復服務後任務會回到原帳號;若 10 分鐘後仍在失敗,繫結才會按常規解除。任何接受的 寫入也都會釋放手動「立即使用此帳號」的 pin——無論 pin 在哪個帳號上——包括寫入一個 帳號已經持有的順序;這是清除 pin 同時保留目前選取帳號的唯一方式。(透過管理 API 清除 active account 也會釋放 pin,但會一併丟掉該選取。)代理無法連線、未知的帳號 id 或超出 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 1f52d93a7b..0680fa35aa 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 @@ -34,9 +34,9 @@ ocx models provider openrouter on | `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 | | `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" \| "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%)時仍會離開,因此親和性是重排而非釘死。 | +| `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務。綁定任務預設(`pool.cacheAffinity`)在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務,並且只改綁到確有額度餘裕且用量嚴格更低的帳號。將 `pool.cacheAffinity` 設為 `false` 才會在此閾值重新評估綁定任務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號。綁定任務預設會保留到帳號耗盡(已知用量 100%)或無法繼續服務,改綁時只前往確有額度餘裕且用量嚴格更低的帳號。關閉該設定後,也可在閾值將綁定任務的下一個請求改綁到確有額度餘裕且用量嚴格更低的帳號。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 `reset-first`: 在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。 此排序不使用月額度重設時間。 | +| `pool.cacheAffinity?` | `boolean` | `true` | 綁定 Codex 執行緒的 cache-affinity 排序,獨立於 `pool.kernel`。預設開啟;省略該鍵或設為 `true` 即為開啟,格式錯誤視為開啟。即時綁定優先於配額餘裕:`quota` 不會只因用量越過 `autoSwitchThreshold` 就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,且只改綁到確有額度餘裕且用量嚴格更低的帳號。用量未知的帳號不會作為綁定任務的改綁目標。設為 `false` 可恢復依閾值重新綁定。親和性是重排而非釘死。 | | `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 | | `upstreamFailoverThreshold?` | `number` | `3` | 未來新 session 容錯移轉前的連續暫時性失敗。設 `0` 停用。 | | `modelCacheTtlMs?` | `number` | `300000` | Per-供應商 `/models` 快取的新鮮度視窗。 | @@ -130,7 +130,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 ## Codex 帳號池 -在儀表板中使用 **Codex Auth** 新增池帳號並重新整理配額。`config.json` 儲存非秘密中繼資料;access 與 refresh token 使用強化的憑證存放。池路由將新/未綁定指派、基於用量的主動切換與失敗復原分開。綁定任務通常保留親和性。預設下 `quota` 可在超過用量閾值後的下一個請求時,將它重新綁定到仍有低於閾值餘裕的合格帳號;開啟 `pool.cacheAffinity` 後,該重新綁定會等到綁定帳號耗盡或無法繼續服務。暫停、冷卻、重新認證與失敗處理可獨立清除或移動路由。未綁定請求沒有即時帳號綁定;這可包含代理重啟或親和性重置後的既有可見任務。Pre-stream 的 429 或 402 在同一個請求中於一個合格的備用帳號上重試一次,即使基於用量的主動切換關閉。帳號變更保留並重播對話 context,但跨帳號的供應商端 prompt-cache 重用不保證,cache 可能需要重新暖機。 +在儀表板中使用 **Codex Auth** 新增池帳號並重新整理配額。`config.json` 儲存非秘密中繼資料;access 與 refresh token 使用強化的憑證存放。池路由將新/未綁定指派、基於用量的主動切換與失敗復原分開。綁定任務通常保留親和性。預設(`pool.cacheAffinity`)下,該重新綁定會等到綁定帳號耗盡或無法繼續服務,並且只改綁到確有額度餘裕且用量嚴格更低的帳號;所有帳號都高於閾值時,綁定任務留在原帳號。關閉該設定後,`quota` 可在超過用量閾值後的下一個請求時重新綁定它,但仍只改綁到確有額度餘裕且用量嚴格更低的帳號。暫停、冷卻、重新認證與失敗處理可獨立清除或移動路由。未綁定請求沒有即時帳號綁定;這可包含代理重啟或親和性重置後的既有可見任務。Pre-stream 的 429 或 402 在同一個請求中於一個合格的備用帳號上重試一次,即使基於用量的主動切換關閉。帳號變更保留並重播對話 context,但跨帳號的供應商端 prompt-cache 重用不保證,cache 可能需要重新暖機。 在 **401/403** 時,App 登入清除該帳號的行程本地親和性並要求重新認證。 在 **429** 時,opencodex 遵循 `Retry-After`、啟動帳號冷卻、清除親和性,並可能將請求輪換到另一個合格的池帳號。這些失敗轉換在 `autoSwitchThreshold: 0` 時仍然活躍;該設定僅停用基於用量的主動切換。 @@ -139,7 +139,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | 策略 | 行為 | | --- | --- | -| `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則將合格現用帳號保持在 `autoSwitchThreshold` 以下;在超過閾值後,未綁定請求可移至較低用量的合格帳號,未開啟 `pool.cacheAffinity` 時綁定任務的下一個請求也可移至仍有低於閾值餘裕的合格帳號。開啟後,cache affinity 優先於配額餘裕,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`0` 停用此用量驅動的重新評估,而非失敗復原。 | +| `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則將合格現用帳號保持在 `autoSwitchThreshold` 以下;在超過閾值後,未綁定請求可移至較低用量的合格帳號。預設下 cache affinity 優先於配額餘裕,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務,改綁時只前往確有額度餘裕且用量嚴格更低的帳號。關閉該設定後,也可在閾值將綁定任務的下一個請求改綁到確有額度餘裕且用量嚴格更低的帳號。`0` 停用此用量驅動的重新評估,而非失敗復原。 | | `round-robin` | 在合格帳號間均勻指派未綁定請求。`autoSwitchThreshold` 不變更一般 round-robin 選擇。`accountPoolStickyLimit`(1–100)計數一次選擇上的指派,而非成功的上游回應。 | | `fill-first` | 將未綁定請求指派到現用帳號直到冷卻、重新認證或設定的排空閾值;未知用量不強制切換。健康的綁定任務保留親和性。 | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 992396cd49..70b62515d9 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1399,7 +1399,7 @@ export const de: Record = { "codexAuth.switchBack": "Zurück zum Hauptkonto?", "codexAuth.switchBackDesc": "Wird sofort wirksam. Bereits laufende Anfragen behalten ihr Konto; alles andere wechselt zu deinem App-Login-Konto, wobei Konten mit derselben Auswahlreihenfolge sich weiterhin abwechseln.", "codexAuth.autoSwitch": "Proaktiver Wechsel nach Nutzung", - "codexAuth.autoSwitchQuotaDesc": "Kontingent: Ab {threshold} % Nutzung kann die nächste Anfrage zu einem geeigneten Konto mit geringerer Nutzung wechseln, auch bei einer bereits gebundenen Aufgabe; Go/Free nutzen nur 30 Tage.", + "codexAuth.autoSwitchQuotaDesc": "Kontingent: Ab {threshold} % Nutzung kann die nächste ungebundene Anfrage zu einem geeigneten Konto mit geringerer Nutzung wechseln. Gebundene Aufgaben behalten standardmäßig die Affinität und wechseln nur, wenn das Konto keine Anfragen mehr bedienen kann, und nur auf ein Konto mit nachgewiesenem freien Kontingent; Go/Free nutzen nur 30 Tage.", "codexAuth.autoSwitchQuotaOffDesc": "Der proaktive Wechsel nach Nutzung ist aus. Zuweisung neuer/ungebundener Aufgaben und Fehlerbehebung bleiben aktiv.", "codexAuth.autoSwitchRoundRobinDesc": "Round-Robin-Zuweisung verwendet diesen Schwellenwert nicht und rotiert weiter neue/ungebundene Aufgaben.", "codexAuth.autoSwitchFillFirstDesc": "Fill-first: {threshold} % ist der Entleerungspunkt für neue/ungebundene Aufgaben; gesunde gebundene Aufgaben behalten ihr Konto.", @@ -1453,7 +1453,7 @@ export const de: Record = { "accountPool.strategyQuota": "Kontingent", "accountPool.strategyRoundRobin": "Round-Robin", "accountPool.strategyFillFirst": "Fill-first", - "accountPool.strategyHintQuota": "Kontingent kann eine bestehende Aufgabe bei ihrer nächsten Anfrage neu binden, nachdem die Nutzungsschwelle überschritten wurde.", + "accountPool.strategyHintQuota": "Kontingent bindet eine bestehende Aufgabe an der Nutzungsschwelle nur neu, wenn `pool.cacheAffinity` aus ist (standardmäßig an). Sonst bleibt die Aufgabe, bis das Konto keine Anfragen mehr bedienen kann, und wechselt nur auf ein Konto mit nachgewiesenem freien Kontingent.", "accountPool.strategyHintRoundRobin": "Round-Robin rotiert nur Aufgaben ohne aktive Bindung; die Nutzungsschwelle ändert die normale Rotation nicht.", "accountPool.strategyHintFillFirst": "Fill-first nutzt die Schwelle als Entleerungspunkt für ungebundene Aufgaben; gesunde gebundene Aufgaben behalten ihre Affinität.", "accountPool.unboundDefinition": "Neue/ungebundene Aufgabe bedeutet eine Anfrage ohne aktuelle Kontobindung; eine sichtbare bestehende Aufgabe kann nach einem Proxy- oder Affinitätsreset ungebunden sein.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c208f9cd29..de2b8906f8 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1985,7 +1985,7 @@ export const en = { "codexAuth.switchBack": "Switch back to Main?", "codexAuth.switchBackDesc": "Takes effect immediately. Existing account-affine threads and requests already in flight keep their captured account; new or unbound requests use your App login account's order tier, and accounts at the same selection order still take turns.", "codexAuth.autoSwitch": "Usage-based proactive switching", - "codexAuth.autoSwitchQuotaDesc": "Quota: at {threshold}% usage or above, the next request may move to a lower-usage eligible account, including an already-bound task; Go/Free use 30d only.", + "codexAuth.autoSwitchQuotaDesc": "Quota: at {threshold}% usage or above, the next unbound request may move to a lower-usage eligible account. Bound tasks keep affinity by default and move only when the account cannot serve, and only onto genuine quota headroom; Go/Free use 30d only.", "codexAuth.autoSwitchQuotaOffDesc": "Usage-based proactive switching is off. New/unbound assignment and failure recovery still apply.", "codexAuth.autoSwitchRoundRobinDesc": "Round-robin assignment does not use this threshold; it continues to rotate new/unbound tasks.", "codexAuth.autoSwitchFillFirstDesc": "Fill-first: {threshold}% is the drain point for new/unbound tasks; healthy bound tasks keep their account.", @@ -2039,7 +2039,7 @@ export const en = { "accountPool.strategyQuota": "Quota", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "Fill-first", - "accountPool.strategyHintQuota": "Quota can also rebind an existing task on its next request after the usage threshold is crossed.", + "accountPool.strategyHintQuota": "Quota rebinds an existing task at the usage threshold only when `pool.cacheAffinity` is off (it is on by default). Bound tasks otherwise stay until the account cannot serve, then only onto genuine quota headroom.", "accountPool.strategyHintRoundRobin": "Round-robin rotates only tasks without a live binding; the usage threshold does not change normal rotation.", "accountPool.strategyHintFillFirst": "Fill-first uses the threshold as a drain point for unbound tasks; healthy bound tasks keep affinity.", "accountPool.unboundDefinition": "New/unbound task means a request with no current account binding; an existing visible task can become unbound after a proxy or affinity reset.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index f6f30b11ba..5b9a38a588 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1917,7 +1917,7 @@ export const fr: Record = { "codexAuth.switchBack": "Revenir au compte principal ?", "codexAuth.switchBackDesc": "Prend effet immédiatement. Les fils liés à un compte et les requêtes déjà en cours conservent le compte capturé ; les requêtes nouvelles ou non liées utilisent le niveau d’ordre du compte de connexion à l’application, et les comptes de même ordre continuent d’alterner.", "codexAuth.autoSwitch": "Changement proactif selon l’utilisation", - "codexAuth.autoSwitchQuotaDesc": "Quota : à partir de {threshold}% d’utilisation, la requête suivante peut passer à un compte admissible moins utilisé, y compris pour une tâche déjà liée ; Go/Free utilisent uniquement 30 j.", + "codexAuth.autoSwitchQuotaDesc": "Quota : à partir de {threshold}% d’utilisation, la prochaine requête non liée peut passer à un compte admissible moins utilisé. Les tâches liées conservent l’affinité par défaut et ne changent de compte que si le compte ne peut plus servir, et seulement vers une véritable marge de quota ; Go/Free utilisent uniquement 30 j.", "codexAuth.autoSwitchQuotaOffDesc": "Le changement proactif selon l’utilisation est désactivé. L’affectation nouvelle/non liée et la récupération après échec restent actives.", "codexAuth.autoSwitchRoundRobinDesc": "L’affectation en rotation n’utilise pas ce seuil ; elle continue d’alterner les tâches nouvelles/non liées.", "codexAuth.autoSwitchFillFirstDesc": "Remplissage prioritaire : {threshold}% est le seuil d’épuisement pour les tâches nouvelles/non liées ; les tâches liées saines conservent leur compte.", @@ -1969,7 +1969,7 @@ export const fr: Record = { "accountPool.strategyQuota": "Quota", "accountPool.strategyRoundRobin": "Rotation", "accountPool.strategyFillFirst": "Remplissage prioritaire", - "accountPool.strategyHintQuota": "La stratégie Quota peut également relier une tâche existante à un autre compte lors de sa requête suivante, une fois le seuil d’utilisation franchi.", + "accountPool.strategyHintQuota": "La stratégie Quota ne relie une tâche existante au seuil d’utilisation que si `pool.cacheAffinity` est désactivé (activé par défaut). Sinon la tâche reste jusqu’à ce que le compte ne puisse plus servir, puis seulement vers une véritable marge de quota.", "accountPool.strategyHintRoundRobin": "La rotation ne concerne que les tâches sans liaison active ; le seuil d’utilisation ne modifie pas la rotation normale.", "accountPool.strategyHintFillFirst": "Le remplissage prioritaire utilise le seuil comme point d’épuisement pour les tâches non liées ; les tâches liées saines conservent leur affinité.", "accountPool.unboundDefinition": "Une tâche nouvelle/non liée désigne une requête sans liaison actuelle à un compte ; une tâche existante visible peut devenir non liée après la réinitialisation du proxy ou de l’affinité.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2014a28342..b21d4d59b7 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1842,7 +1842,7 @@ export const ja: Record = { "codexAuth.switchBack": "メインに戻しますか?", "codexAuth.switchBackDesc": "すぐに反映されます。アカウントに紐付いた既存スレッドと処理中のリクエストは現在のアカウントを維持し、新規または未紐付けのリクエストはアプリログインアカウントの順序ティアを使います。同じ選択順序のアカウントは引き続き交代で使われます。", "codexAuth.autoSwitch": "使用量ベースのプロアクティブ切り替え", - "codexAuth.autoSwitchQuotaDesc": "クォータ: 使用率が {threshold}% 以上になると、既に紐付いたタスクを含む次のリクエストが、使用率の低い適格アカウントへ移る場合があります。Go/Free は 30 日枠のみを使用します。", + "codexAuth.autoSwitchQuotaDesc": "クォータ: 使用率が {threshold}% 以上になると、未紐付けの次のリクエストが使用率の低い適格アカウントへ移る場合があります。紐付け済みタスクは既定でアフィニティを維持し、アカウントが処理できなくなったときだけ、実際にクォータ余裕があるアカウントへ移ります。Go/Free は 30 日枠のみを使用します。", "codexAuth.autoSwitchQuotaOffDesc": "使用量ベースのプロアクティブ切り替えはオフです。新規/未紐付けタスクの割り当てと障害回復は引き続き適用されます。", "codexAuth.autoSwitchRoundRobinDesc": "ラウンドロビン割り当てはこのしきい値を使用せず、新規/未紐付けタスクを引き続きローテーションします。", "codexAuth.autoSwitchFillFirstDesc": "フィルファースト: {threshold}% は新規/未紐付けタスクの使い切り基準です。正常な紐付け済みタスクはアカウントを維持します。", @@ -1896,7 +1896,7 @@ export const ja: Record = { "accountPool.strategyQuota": "クォータ", "accountPool.strategyRoundRobin": "ラウンドロビン", "accountPool.strategyFillFirst": "フィルファースト", - "accountPool.strategyHintQuota": "クォータ戦略は使用量しきい値を超えると、既存タスクの次のリクエストも別アカウントへ再紐付けできます。", + "accountPool.strategyHintQuota": "クォータ戦略が使用量しきい値で既存タスクを再紐付けするのは `pool.cacheAffinity` がオフのときだけです(既定はオン)。既定ではアカウントが処理できなくなるまで維持し、その場合も実際にクォータ余裕があるアカウントへだけ移ります。", "accountPool.strategyHintRoundRobin": "ラウンドロビンは有効な紐付けがないタスクだけをローテーションし、使用量しきい値は通常のローテーションを変えません。", "accountPool.strategyHintFillFirst": "フィルファーストはしきい値を未紐付けタスクの使い切り基準として使用し、正常な紐付け済みタスクは親和性を維持します。", "accountPool.unboundDefinition": "新規/未紐付けタスクとは、現在のアカウント紐付けがないリクエストです。既存の表示中タスクも、プロキシまたは親和性のリセット後は未紐付けになる場合があります。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8cd711d927..a6e76eb7b1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1435,7 +1435,7 @@ export const ko: Record = { "codexAuth.switchBack": "메인 계정으로 돌아가시겠습니까?", "codexAuth.switchBackDesc": "즉시 적용됩니다. 계정에 바인딩된 기존 스레드와 이미 진행 중인 요청은 기존 계정을 유지하고, 새 요청이나 바인딩 없는 요청은 앱 로그인 계정의 순서 티어를 사용합니다. 같은 선택 순서의 계정은 계속 번갈아 사용됩니다.", "codexAuth.autoSwitch": "사용량 기반 선제 전환", - "codexAuth.autoSwitchQuotaDesc": "할당량: 사용량이 {threshold}% 이상이면 이미 바인딩된 작업을 포함해 다음 요청이 사용량이 더 낮은 적격 계정으로 이동할 수 있습니다. Go/Free는 30일만 봅니다.", + "codexAuth.autoSwitchQuotaDesc": "할당량: 사용량이 {threshold}% 이상이면 바인딩 없는 다음 요청이 사용량이 더 낮은 적격 계정으로 이동할 수 있습니다. 바인딩된 작업은 기본 어피니티를 유지하며, 계정이 처리할 수 없을 때에만 실제 할당량 여유가 있는 계정으로 옮깁니다. Go/Free는 30일만 봅니다.", "codexAuth.autoSwitchQuotaOffDesc": "사용량 기반 선제 전환이 꺼져 있습니다. 새 작업/바인딩 없는 작업 배정과 실패 복구는 계속 적용됩니다.", "codexAuth.autoSwitchRoundRobinDesc": "라운드로빈 배정은 이 임계값을 사용하지 않으며, 바인딩 없는 새 작업을 계속 순환합니다.", "codexAuth.autoSwitchFillFirstDesc": "필 퍼스트: {threshold}%는 새 작업/바인딩 없는 작업의 소진 기준이며, 정상적인 바인딩 작업은 계정을 유지합니다.", @@ -1489,7 +1489,7 @@ export const ko: Record = { "accountPool.strategyQuota": "할당량", "accountPool.strategyRoundRobin": "라운드로빈", "accountPool.strategyFillFirst": "필 퍼스트", - "accountPool.strategyHintQuota": "할당량 전략은 사용량 임계값을 넘으면 기존 작업의 다음 요청도 다른 계정에 다시 바인딩할 수 있습니다.", + "accountPool.strategyHintQuota": "할당량 전략이 사용량 임계값에서 기존 작업을 다시 바인딩하는 것은 `pool.cacheAffinity`가 꺼져 있을 때만입니다(기본값은 켜짐). 기본값에서는 계정이 처리할 수 없을 때까지 유지하며, 그때도 실제 할당량 여유가 있는 계정으로만 옮깁니다.", "accountPool.strategyHintRoundRobin": "라운드로빈은 현재 바인딩이 없는 작업만 순환하며, 사용량 임계값은 기본 순환에 영향을 주지 않습니다.", "accountPool.strategyHintFillFirst": "필 퍼스트는 임계값을 바인딩 없는 작업의 소진 기준으로 사용하며, 정상적인 바인딩 작업은 어피니티를 유지합니다.", "accountPool.unboundDefinition": "새 작업/바인딩 없는 작업은 현재 계정 바인딩이 없는 요청입니다. 기존에 보이던 작업도 프록시나 어피니티 상태가 초기화되면 바인딩이 없어질 수 있습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0c6034ae6e..c28f4464c3 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1912,7 +1912,7 @@ export const ru: Record = { "codexAuth.switchBack": "Вернуться на основной аккаунт?", "codexAuth.switchBackDesc": "Применяется сразу. Существующие привязанные к аккаунту потоки и уже выполняющиеся запросы сохраняют прежний аккаунт; новые или непривязанные запросы используют порядковый уровень аккаунта входа через приложение. Аккаунты с тем же порядком выбора продолжают чередоваться.", "codexAuth.autoSwitch": "Проактивное переключение по использованию", - "codexAuth.autoSwitchQuotaDesc": "Квота: при использовании {threshold}% или выше следующий запрос может перейти на подходящий аккаунт с меньшим использованием, включая уже привязанную задачу; Go/Free используют только 30 дней.", + "codexAuth.autoSwitchQuotaDesc": "Квота: при использовании {threshold}% или выше следующий непривязанный запрос может перейти на подходящий аккаунт с меньшим использованием. Привязанные задачи по умолчанию сохраняют affinity и переносятся, только если аккаунт не может обслуживать запрос, и только на аккаунт с реальным запасом квоты; Go/Free используют только 30 дней.", "codexAuth.autoSwitchQuotaOffDesc": "Проактивное переключение по использованию выключено. Назначение новых/непривязанных задач и восстановление после сбоев остаются активными.", "codexAuth.autoSwitchRoundRobinDesc": "Round-robin не использует этот порог и продолжает ротировать новые/непривязанные задачи.", "codexAuth.autoSwitchFillFirstDesc": "Fill-first: {threshold}% — порог исчерпания для новых/непривязанных задач; здоровые привязанные задачи сохраняют аккаунт.", @@ -1966,7 +1966,7 @@ export const ru: Record = { "accountPool.strategyQuota": "Квота", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "Fill-first", - "accountPool.strategyHintQuota": "Quota может перепривязать существующую задачу при следующем запросе после превышения порога использования.", + "accountPool.strategyHintQuota": "Quota перепривязывает существующую задачу по порогу использования, только если `pool.cacheAffinity` выключен (по умолчанию включён). Иначе задача остаётся, пока аккаунт не сможет обслуживать запрос, и переносится только на аккаунт с реальным запасом квоты.", "accountPool.strategyHintRoundRobin": "Round-robin ротирует только задачи без действующей привязки; порог использования не меняет обычную ротацию.", "accountPool.strategyHintFillFirst": "Fill-first использует порог как точку исчерпания для непривязанных задач; здоровые привязанные задачи сохраняют affinity.", "accountPool.unboundDefinition": "Новая/непривязанная задача — запрос без текущей привязки к аккаунту; видимая существующая задача может стать непривязанной после сброса прокси или affinity.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 5e624434ac..c6671c3e28 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1942,7 +1942,7 @@ export const tr: Record = { "codexAuth.switchBack": "Ana hesaba geri dönülsün mü?", "codexAuth.switchBackDesc": "Anında yürürlüğe girer. Mevcut hesaba bağlı iş parçacıkları ve işlenmekte olan istekler yakalanan hesaplarını korur; yeni veya bağımsız istekler Uygulama giriş hesabınızın sıra kademesini kullanır ve aynı seçim sırasındaki hesaplar sırayla görev almaya devam eder.", "codexAuth.autoSwitch": "Kullanıma dayalı proaktif geçiş", - "codexAuth.autoSwitchQuotaDesc": "Kota: %{threshold} veya üzeri kullanımda sonraki istek daha az kullanılan bir hesaba geçebilir.", + "codexAuth.autoSwitchQuotaDesc": "Kota: %{threshold} veya üzeri kullanımda sonraki bağımsız istek daha az kullanılan uygun bir hesaba geçebilir. Bağlı görevler varsayılan önbellek bağlılığını korur ve hesap hizmet veremez hale gelince yalnızca gerçek kota payı olan bir hesaba geçer.", "codexAuth.autoSwitchQuotaOffDesc": "Kullanıma dayalı proaktif geçiş kapalı.", "codexAuth.autoSwitchRoundRobinDesc": "Round-robin bu eşiği kullanmaz.", "codexAuth.autoSwitchFillFirstDesc": "Kullanım %{threshold} eşiğini aşana kadar hesabı doldurun, ardından sonraki kullanılabilir hesaba geçin.", @@ -1985,7 +1985,7 @@ export const tr: Record = { "accountPool.strategyQuota": "Kota", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "İlk doldurma", - "accountPool.strategyHintQuota": "Kota kullanımı eşik aşıldığında hesabı değiştirebilir.", + "accountPool.strategyHintQuota": "Kota, kullanım eşiğinde hesabı yalnızca `pool.cacheAffinity` kapalıyken değiştirir (varsayılan açıktır). Bağlı görevler hesabın hizmet veremez hale gelmesine kadar kalır, sonra yalnızca gerçek kota payı olan bir hesaba geçer.", "accountPool.strategyHintRoundRobin": "Round-robin yalnızca canlı bir hesap bağı olmayan yeni/bağımsız görevleri döndürür; mevcut görevler bağlı kalabilir ve kullanım eşiği normal rotasyonu değiştirmez.", "accountPool.strategyHintFillFirst": "İlk doldurma eşiği boşaltma noktası olarak kullanır.", "accountPool.unboundDefinition": "Bağlı olmayan yeni görev.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 919318c809..9e154fa1b2 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2101,13 +2101,13 @@ export const zhTW: Record = { "usage.range.available": "可用歷史紀錄", "usage.historyTruncated": "總計僅涵蓋可用歷史紀錄,因為較舊的用量未被載入。", "usage.historyTruncatedWindow": "已載入紀錄的請求開始時間介於 {start} 到 {end} 之間。受讀取上限限制,檔案較前的項目已被略過,所選期間可能不完整。", - "codexAuth.autoSwitchQuotaDesc": "配額:使用率達 {threshold}% 或以上時,下一個請求可能移至用量較低的合格帳號,包括已綁定的任務;Go/Free 僅使用 30 天。", + "codexAuth.autoSwitchQuotaDesc": "配額:使用率達 {threshold}% 或以上時,未綁定的下一個請求可能移至用量較低的合格帳號。已綁定任務預設保持親和性,僅在帳號無法繼續服務時離開,並且只改綁到確有額度餘裕的帳號;Go/Free 僅使用 30 天。", "codexAuth.autoSwitchQuotaOffDesc": "基於用量的主動切換已關閉。新增/未綁定分派與故障恢復仍然適用。", "codexAuth.autoSwitchRoundRobinDesc": "輪詢分派不使用此閾值;它會繼續輪換新增/未綁定的任務。", "codexAuth.autoSwitchFillFirstDesc": "優先填滿:{threshold}% 是新增/未綁定任務的耗盡點;健康的已綁定任務保留其帳號。", "codexAuth.autoSwitchFillFirstOffDesc": "優先填滿對新增/未綁定任務沒有用量耗盡點;冷卻、重新驗證與故障恢復仍可改變路由。", "codexAuth.failureRecoveryNote": "故障恢復是獨立的:請求在輸出前被拒絕(429/402)、冷卻、重新驗證、排除或已設定的暫時容錯移轉,可能選擇另一個合格帳號。", - "accountPool.strategyHintQuota": "配額也可以在跨越用量閾值後,於下次請求時重新綁定現有任務。", + "accountPool.strategyHintQuota": "配額僅在 `pool.cacheAffinity` 關閉時才會在跨越用量閾值後重新綁定現有任務(預設開啟)。預設下任務會保留到帳號無法繼續服務,並且只改綁到確有額度餘裕的帳號。", "accountPool.strategyHintRoundRobin": "輪詢僅輪換沒有有效綁定的任務;用量閾值不會改變正常輪換。", "accountPool.strategyHintFillFirst": "優先填滿將閾值用作未綁定任務的耗盡點;健康的已綁定任務保持親和性。", "accountPool.unboundDefinition": "新增/未綁定任務表示沒有當前帳號綁定的請求;現有可見任務在代理或親和性重設後可能變成未綁定。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 93b43a4ee2..018f6e251b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1416,7 +1416,7 @@ export const zh: Record = { "codexAuth.switchBack": "切换回主账号?", "codexAuth.switchBackDesc": "立即生效。已在进行中的请求保留原账号,其余都会切换到应用登录账号;不过选择顺序相同的账号仍会轮换使用。", "codexAuth.autoSwitch": "基于用量的主动切换", - "codexAuth.autoSwitchQuotaDesc": "配额:使用率达到或超过 {threshold}% 时,包括已绑定任务在内的下一次请求可能转到用量更低的合格账号;Go/Free 仅使用 30 天窗口。", + "codexAuth.autoSwitchQuotaDesc": "配额:使用率达到或超过 {threshold}% 时,未绑定的下一次请求可能转到用量更低的合格账号。已绑定任务默认保持亲和性,仅在账号无法继续服务时离开,并且只改绑到确有额度余量的账号;Go/Free 仅使用 30 天窗口。", "codexAuth.autoSwitchQuotaOffDesc": "基于用量的主动切换已关闭。新建/未绑定任务分配和故障恢复仍然生效。", "codexAuth.autoSwitchRoundRobinDesc": "轮询分配不使用此阈值,并会继续轮换新建/未绑定任务。", "codexAuth.autoSwitchFillFirstDesc": "填满优先:{threshold}% 是新建/未绑定任务的耗尽点;健康的已绑定任务继续使用原账号。", @@ -1470,7 +1470,7 @@ export const zh: Record = { "accountPool.strategyQuota": "配额", "accountPool.strategyRoundRobin": "轮询", "accountPool.strategyFillFirst": "填满优先", - "accountPool.strategyHintQuota": "配额策略在超过用量阈值后,也可以在现有任务的下一次请求中重新绑定账号。", + "accountPool.strategyHintQuota": "配额策略仅在 `pool.cacheAffinity` 关闭时才会在超过用量阈值后重新绑定现有任务(默认开启)。默认下任务会保留到账号无法继续服务,并且只改绑到确有额度余量的账号。", "accountPool.strategyHintRoundRobin": "轮询只轮换没有有效绑定的任务;用量阈值不会改变正常轮换。", "accountPool.strategyHintFillFirst": "填满优先把阈值用作未绑定任务的耗尽点;健康的已绑定任务保持亲和性。", "accountPool.unboundDefinition": "新建/未绑定任务是当前没有账号绑定的请求;已有的可见任务在代理或亲和性重置后也可能变为未绑定。", diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx index 1eb9ebcb5d..45e2aeea26 100644 --- a/gui/tests/account-pool-strategy.test.tsx +++ b/gui/tests/account-pool-strategy.test.tsx @@ -162,7 +162,7 @@ describe("AccountPoolStrategyControls", () => { // Custom Select only paints the active label until opened (sidecar DNA). expect(quota).toContain("Quota"); expect(quota).toContain("select-trigger"); - expect(quota).toContain("Quota can also rebind an existing task on its next request after the usage threshold is crossed."); + expect(quota).toContain("rebinds an existing task at the usage threshold only when"); expect(quota).not.toContain("New/unbound assignments before rotate"); const rr = renderToStaticMarkup( diff --git a/gui/tests/codex-account-auto-switch.test.tsx b/gui/tests/codex-account-auto-switch.test.tsx index 695f62bd62..1cf8723361 100644 --- a/gui/tests/codex-account-auto-switch.test.tsx +++ b/gui/tests/codex-account-auto-switch.test.tsx @@ -119,7 +119,7 @@ describe("Codex account auto-switch threshold", () => { expect(html).toContain('max="100"'); expect(html).toContain('aria-label="Usage threshold, percent"'); expect(html).toContain("95% usage or above"); - expect(html).toContain("including an already-bound task"); + expect(html).toContain("Bound tasks keep affinity by default"); expect(html).toContain('aria-pressed="true"'); }); @@ -143,7 +143,7 @@ describe("Codex account auto-switch threshold", () => { const roundRobin = renderSetting(80, "80", false, false, null, "round-robin"); const fillFirst = renderSetting(80, "80", false, false, null, "fill-first"); - expect(quota).toContain("including an already-bound task"); + expect(quota).toContain("Bound tasks keep affinity by default"); expect(roundRobin).toContain("does not use this threshold"); expect(fillFirst).toContain("drain point for new/unbound tasks"); for (const html of [quota, roundRobin, fillFirst]) { @@ -165,7 +165,7 @@ describe("Codex account auto-switch threshold", () => { , ); - expect(renderStrategy("quota")).toContain("can also rebind an existing task"); + expect(renderStrategy("quota")).toContain("rebinds an existing task at the usage threshold only when"); expect(renderStrategy("round-robin")).toContain("usage threshold does not change normal rotation"); const fillFirst = renderStrategy("fill-first"); expect(fillFirst).toContain("healthy bound tasks keep affinity"); diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 7a07ccbabd..4eb35cc208 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -46,6 +46,16 @@ type ThreadAffinityEntry = { // Last time the bound account's quota threshold was re-evaluated for this // thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS. lastReevalAt: number; + // When a transient failure streak first forced this thread onto another account + // while the binding was HELD (#4546). Cleared the moment the bound account serves + // again; once it ages past CODEX_TRANSIENT_AFFINITY_HOLD_MS the binding is + // released through the ordinary path instead of detouring forever. + transientHoldSince?: number; + // Which account is serving this thread while its own is held under a transient hold. + // Remembered rather than re-picked per request: under round-robin a fresh pick each turn + // would walk the ring and start cold on every hop, which is the behaviour the hold exists + // to prevent. Cleared with transientHoldSince when the bound account serves again. + transientDetourAccountId?: string; }; export type CodexThreadResolution = @@ -169,6 +179,23 @@ const MAX_AFFINITY_COMPONENT_BYTES = 512; // Well under the 5h/weekly quota windows, but enough to stop per-request flapping. export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000; +/** + * How long a live binding outlives a TRANSIENT failure streak on its own account (#4546). + * + * Being unable to send right now is not the same as losing ownership of the conversation. + * A 5xx streak is frequently provider-wide rather than account-specific, and deleting the + * binding for it discards a prompt-cache prefix that the next turn then pays for again -- + * the same cost the quota threshold used to impose, arriving through a different door. + * So the request detours to another account while the binding is held here. + * + * Bounded, because an unbounded hold is its own defect: an account that never recovers + * would keep a thread detouring indefinitely while the conversation's real warm prefix + * accumulates somewhere else. Ten minutes is longer than the whole soft-avoid escalation + * ladder up to its final step, so an ordinary outage resolves inside the hold and a + * genuine one converts to a real rebind instead of a permanent detour. + */ +export const CODEX_TRANSIENT_AFFINITY_HOLD_MS = 10 * 60_000; + const upstreamHealth = new Map(); /** * Reset-derived 429s can describe a quota owned by one native model family, @@ -1516,6 +1543,111 @@ function hasCodexQuotaHeadroom( return usage < threshold; } +/** + * Is a live binding held for its prompt cache? + * + * Unset means yes. Cache affinity shipped as an opt-in flag (#4292) and then #4546 measured + * what the default costs: a pool whose accounts all sit in the 80-99% band hands a bound + * conversation from account to account, and because provider prompt caches are account-isolated + * every hop re-sends the entire prefix. An install that has never heard of this flag is exactly + * the install that gets hurt by it, so the protection cannot be something you have to find. + * + * `false` restores capacity-first routing byte-for-byte. It is a real choice -- a pinned thread + * on a busy account pays latency -- and it stays available; it is just no longer the default. + */ +function isCacheAffinityEnabled(config: OcxConfig): boolean { + return config.pool?.cacheAffinity !== false; +} + +/** + * Is a transient failure streak the ONLY thing standing between this thread and its account? + * + * The point is the word "only". A binding must still be released for every cause that means + * the account cannot serve this conversation at all -- a quota refusal it already answered, + * an operator pause, a plan exclusion, an unusable or superseded credential, a hard cooldown, + * an avoided quota window. What is left after those is a 5xx streak and the escalating + * soft-avoid window it writes, and that is a statement about right now, not about ownership. + * + * #4269 is the cautionary case: a retryable 503 whose human-readable body happened to contain + * the word "reauthentication" was classified as an auth failure. A failure's blast radius has + * to come from the scope it was recorded at, which is what this predicate reads. + * + * Deliberately NOT gated on `pool.cacheAffinity`. That flag chooses between cache-first and + * capacity-first QUOTA routing; it says nothing about how a failure should be attributed, and + * an operator who prefers capacity-first has not asked for three 503s to cost them a prefix. + */ +function isTransientOnlyAffinityBlock( + config: OcxConfig, + entry: ThreadAffinityEntry, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): boolean { + if (!isThreadAffinityGenerationLive(entry)) return false; + if (hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope)) return false; + if (isCodexAccountPaused(config, entry.accountId)) return false; + if (isCodexAccountPlanExcluded(config, entry.accountId)) return false; + if (!isCodexAccountUsable(config, entry.accountId, selectionOptions)) return false; + if (getCodexQuotaHealthSnapshot(entry.accountId, quotaScope, now) !== null) return false; + if (isCodexQuotaAvoided(entry.accountId, quotaScope, now)) return false; + return shouldFailover(config, entry.accountId, now) || isCodexAccountSoftAvoided(entry.accountId, now); +} + +/** Has a held binding waited longer than a transient failure can reasonably explain? */ +function isTransientHoldExpired(entry: ThreadAffinityEntry, now: number): boolean { + return entry.transientHoldSince !== undefined + && now - entry.transientHoldSince > CODEX_TRANSIENT_AFFINITY_HOLD_MS; +} + +/** + * Is every pin this thread holds on the failing account past its hold window? + * + * A thread that has never detoured has no hold to spend, so it answers false: the resolve path + * has not yet had the chance to route around the failure, and deleting the pin here would take + * that chance away. + */ +function isTransientHoldSpentForAccount(threadId: string, accountId: string, now: number): boolean { + const affinities = threadAccountMap.get(threadId); + if (!affinities) return false; + let matched = false; + for (const entry of affinities.values()) { + if (entry.accountId !== accountId) continue; + matched = true; + if (!isTransientHoldExpired(entry, now)) return false; + } + return matched; +} + +/** + * Who serves this thread while its own account is held. Prefers the account already doing so, + * because a detour that moves every turn is just the original defect wearing a different name. + */ +function transientDetourAccount( + config: OcxConfig, + entry: ThreadAffinityEntry, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, + allowFreshPick = true, +): string | null { + const held = entry.transientDetourAccountId; + if ( + held !== undefined + && held !== entry.accountId + && isCodexAccountSelectable(config, held, now, quotaScope, selectionOptions) + && !hasUnrecoveredCodexQuotaRefusal(held, quotaScope) + && !shouldFailover(config, held, now) + && !isCodexAccountSoftAvoided(held, now) + ) { + return held; + } + // A fresh pick is a side effect under round-robin: pickRoundRobinAccount commits and advances + // the ring. The preview path is contractually read-only, so it reports a detour only once the + // request path has actually chosen one, rather than moving the ring to answer a question. + if (!allowFreshPick) return null; + return pickAlternateCodexAccount(config, entry.accountId, now, quotaScope, selectionOptions); +} + /** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */ function pickResetFirstCodexAccount( config: OcxConfig, @@ -2182,11 +2314,25 @@ function previewReusableAffinityAccount( if ( !entry || isThreadAffinityExpired(entry, now) - || !isThreadAffinityGenerationLive(entry) + ) { + return null; + } + if ( + !isThreadAffinityGenerationLive(entry) || !isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions) || hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope) || shouldFailover(config, entry.accountId, now) ) { + // Preview must reach the same answer as resolve, including the transient detour, or the + // subagent fallback decides against a binding the next real request would have held. + // Read-only by contract: no hold is started and no detour is recorded here. + if ( + !isTransientHoldExpired(entry, now) + && isTransientOnlyAffinityBlock(config, entry, now, quotaScope, selectionOptions) + ) { + const detour = transientDetourAccount(config, entry, now, quotaScope, selectionOptions, false); + if (detour !== null && detour !== entry.accountId) return detour; + } return null; } if (accountPoolStrategyForScope(config, quotaScope) === "reset-first") { @@ -2223,13 +2369,15 @@ function previewReusableAffinityAccount( /** * May a LIVE binding be moved for quota reasons? * - * Default: yes once usage crosses `autoSwitchThreshold`, which is the historical rule. + * Default: no. The bar is genuine exhaustion, because moving a bound conversation discards + * the prompt cache warmed on its account and a threshold crossing is a hint that the account + * is getting busy rather than evidence it cannot serve (#4546). Deliberately NOT + * `hasCodexQuotaHeadroom`, which reads `usage < autoSwitchThreshold` and would reproduce the + * old rule under a new name. * - * With `pool.cacheAffinity` on, the bar becomes genuine exhaustion. Moving a bound - * conversation discards the prompt cache warmed on its account, so a threshold crossing -- a - * hint that the account is getting busy -- does not justify paying that cost; the account has - * to be unable to serve. Deliberately NOT `hasCodexQuotaHeadroom`, which reads - * `usage < autoSwitchThreshold` and would reproduce the old rule under a new name. + * With `pool.cacheAffinity: false` the historical rule comes back: a crossing of + * `autoSwitchThreshold` is enough. That is capacity-first routing, and an operator who wants + * it keeps it -- but it is no longer what an install gets by never having heard of the flag. */ function mayRebindAffinityForQuota( config: OcxConfig, @@ -2239,7 +2387,7 @@ function mayRebindAffinityForQuota( selectionOptions?: CodexAccountUsabilityOptions, ): boolean { const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; - if (config.pool?.cacheAffinity !== true) return overThreshold; + if (!isCacheAffinityEnabled(config)) return overThreshold; // The usable half is already guaranteed by both callers, which gate on // isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own. return !isCodexAccountUsable(config, accountId, selectionOptions) @@ -2495,6 +2643,11 @@ export function resolveCodexAccountForThreadDetailed( && !shouldFailover(config, detourEntry.accountId, now); if (detourReusable) { detourEntry.lastUsedAt = now; + // Same as the ordinary lane: serving again ends the hold. Without this the marker + // survives recovery, and a later streak reads a hold that started before the account + // ever came back -- which is the pin drop this whole branch exists to prevent. + if (detourEntry.transientHoldSince !== undefined) delete detourEntry.transientHoldSince; + if (detourEntry.transientDetourAccountId !== undefined) delete detourEntry.transientDetourAccountId; // Model detours follow the same affinity policy as ordinary bindings: // RR/fill-first stay sticky, while quota strategy may re-evaluate an // over-threshold account without changing the ordinary lane. @@ -2511,6 +2664,21 @@ export function resolveCodexAccountForThreadDetailed( } return { status: "selected", accountId: detourEntry.accountId }; } + // The model lane gets the same transient hold as the ordinary one. Without it a + // model-scoped request drops its detour pin on three 503s and falls back to an ordinary + // home account that may not even be entitled to this model. + if ( + !isTransientHoldExpired(detourEntry, now) + && isTransientOnlyAffinityBlock(config, detourEntry, now, quotaScope, selectionOptions) + ) { + const lane = transientDetourAccount(config, detourEntry, now, quotaScope, selectionOptions); + if (lane !== null && lane !== detourEntry.accountId) { + detourEntry.transientHoldSince ??= now; + detourEntry.transientDetourAccountId = lane; + detourEntry.lastUsedAt = now; + return { status: "selected", accountId: lane }; + } + } // Detour expiry or invalidation must not expire the ordinary task. Drop only // this model lane and select from ordinary/shared state below. deleteModelDetourAffinity(threadId, modelId, quotaScope); @@ -2544,6 +2712,10 @@ export function resolveCodexAccountForThreadDetailed( && !failoverReady ) { entry.lastUsedAt = now; + // Serving again ends any transient hold: the thread is home, so the detour it was + // parked on is no longer the answer to anything. + if (entry.transientHoldSince !== undefined) delete entry.transientHoldSince; + if (entry.transientDetourAccountId !== undefined) delete entry.transientDetourAccountId; // Periodic quota re-eval: a long-lived bound thread must still switch when // it crosses autoSwitchThreshold, but only onto an account that has genuine // quota headroom AND is strictly cooler — moving to a destination still over @@ -2565,6 +2737,24 @@ export function resolveCodexAccountForThreadDetailed( } return { status: "selected", accountId: entry.accountId }; } + // Transient trouble on the bound account is a reason to send elsewhere, not a reason to + // give up the conversation. Detour this request and KEEP the binding, so recovery is free + // instead of costing another cold prefix (#4546). Bounded: once the hold outlives what a + // transient failure can explain, fall through and release it like any other dead account. + if ( + !isTransientHoldExpired(entry, now) + && isTransientOnlyAffinityBlock(config, entry, now, quotaScope, selectionOptions) + ) { + const detour = transientDetourAccount(config, entry, now, quotaScope, selectionOptions); + if (detour !== null && detour !== entry.accountId) { + entry.transientHoldSince ??= now; + entry.transientDetourAccountId = detour; + entry.lastUsedAt = now; + // Deliberately no promoteActiveCodexAccount and no rebind: this is one request routing + // around a blip, not the pool deciding where the conversation now lives. + return { status: "selected", accountId: detour }; + } + } // A model-only exclusion does not invalidate the shared task binding. Health, // generation, pause, cooldown, and failure evidence still retire it normally. if (!modelScopedSelection || !healthyForSharedAffinity) { @@ -3055,14 +3245,22 @@ export function recordCodexUpstreamOutcome( // thread is still pinned to the FAILING account — a late failure from account A // must not delete a newer healthy binding to account B (race: T→A, A fails, // T→B, late A failure must not delete B's mapping). - if (!meta.fixedAccount && failoverReady && meta.threadId) { + // A transient streak no longer surrenders the conversation: the resolve path detours this + // thread onto a remembered alternate and KEEPS the binding, so recovering costs nothing + // (#4546). The pin is dropped only once the hold has outlived what a transient failure can + // explain, the same bound the resolve path applies -- recorded here so a thread that simply + // stops sending cannot leave a dead pin behind. + if ( + !meta.fixedAccount + && failoverReady + && meta.threadId + && isTransientHoldSpentForAccount(meta.threadId, accountId, now) + ) { deleteThreadAffinitiesForAccount(meta.threadId, accountId); } - // Once the account is past the failover streak, clear every thread still pinned - // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer. - if (!meta.fixedAccount && shouldFailover(config, accountId, now)) { - clearThreadAccountMapForAccount(accountId); - } + // No account-wide clear for a transient streak. Every pinned thread reaches the same detour + // on its own next request, and wiping the map would retire bindings for quota scopes the + // failure never described -- a spent Terra window must not evict the same thread's Spark pin. if ( !meta.fixedAccount && !isIndependentCodexQuotaScope(quotaScope) diff --git a/src/types/config.ts b/src/types/config.ts index 8f87281eb8..ec9c99f068 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -850,14 +850,26 @@ export interface OcxConfig { pool?: { kernel?: boolean; /** - * Opt-in cache-affinity ordering, off by default. + * Cache-affinity ordering for bound Codex threads. **On unless set to `false`.** * - * With it on, a bound Codex thread keeps its account until that account genuinely cannot - * serve, instead of moving the moment usage crosses `autoSwitchThreshold`. Moving a live + * A bound Codex thread keeps its account until that account genuinely cannot serve, + * instead of moving the moment usage crosses `autoSwitchThreshold`. Moving a live * conversation throws away the prompt cache warmed on that account, and a threshold - * crossing is a hint rather than evidence the account is spent. Separate from `kernel` - * on purpose: that one governs the generic OAuth strategy consumer, and one switch - * carrying two unrelated meanings cannot be turned on alone. + * crossing is a hint rather than evidence the account is spent. + * + * This shipped as an opt-in (#4292) and then #4546 measured what the opt-in default + * costs: a pool whose accounts all sit in the 80-99% band hands a conversation from + * account to account, re-sending the whole prefix every turn, and the install that gets + * hurt is precisely the one that never heard of this setting. `false` restores + * capacity-first routing for operators who want it. + * + * Separate from `kernel` on purpose: that one governs the generic OAuth strategy + * consumer, and one switch carrying two unrelated meanings cannot be turned on alone. + * + * Note what this does NOT govern. Unbound placement still follows + * `autoSwitchThreshold` and the configured strategy. A bound thread's destination must + * have real headroom under either setting, and a transient failure streak holds the + * binding under either setting -- neither is a cache-affinity preference. */ cacheAffinity?: boolean; }; diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 47d5488834..a1824746dc 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -78,8 +78,8 @@ plus `thread-id` pair is mapped to an opaque HMAC under a random process-local k oversized components remain unbound, raw identifiers and durable hashes are never stored, and account-qualified selectors skip both lookup and mutation. Selection, subagent fallback preview, and terminal outcome accounting carry the same key so route planning cannot preview one account -and authenticate another, and a transient failure clears the binding that actually selected the -account. +and authenticate another. A transient-failure streak does not delete the live binding that +actually selected the account; the request is served by another account while the binding is kept. > Decision record: [ADR-0085](../decisions/ADR-0085-public-provider-contract.md) @@ -250,10 +250,12 @@ the auto-switch threshold, cooling down, soft-avoided, paused, or needs reauth; drains a tier, and every tier drained leaves the eligible list untouched. Ordering never admits an account that pause, cooldown, health, or reauth already excluded, and never overrides those exclusions. It adds no new rebind cause for a bound thread, which still moves only for the reasons it -already had: a quota-strategy threshold re-evaluation, a failover streak, an account that stopped -being selectable, or affinity expiry. The stable `__main__` alias carries an order on equal terms with -added accounts, which is what lets the Desktop login be ordered last. An absent or empty map -reproduces the prior selection sequence exactly. +already had: a quota-strategy re-evaluation when `pool.cacheAffinity` is off (threshold) or the bound +account cannot serve (the default), an account that stopped being selectable, or affinity expiry. +A transient-failure streak does not delete a live binding. A bound move requires genuine quota +headroom and strictly lower usage on the destination. The stable `__main__` alias carries an order on +equal terms with added accounts, which is what lets the Desktop login be ordered last. An absent or +empty map reproduces the prior selection sequence exactly. Preemption moves unbound requests back up when a higher tier regains headroom, and it holds the runtime cursor only. Under an independent quota scope it must never touch the shared active cursor, @@ -515,7 +517,7 @@ The history read API reports a median effective token estimate and interval samp `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. +Live bindings obey the cache-affinity release policy: `pool.cacheAffinity` is on by default, so threshold crossing alone retains a healthy account. A bound thread that does leave may move only onto an account with genuine quota headroom and strictly lower usage. Manual preference, scoped health and shared-cursor guards remain authoritative. Set the flag false to restore threshold rebinding of bound tasks. 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/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index c23a1cda2c..b488bc7855 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -999,7 +999,7 @@ describe("Codex auth context", () => { .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); - test("late transient failure cannot delete a newer Desktop affinity binding", async () => { + test("late transient failure cannot disturb a held Desktop affinity binding", async () => { const cfg = config(); cfg.autoSwitchThreshold = 0; cfg.upstreamFailoverThreshold = 3; @@ -1036,7 +1036,11 @@ describe("Codex auth context", () => { clearCodexUpstreamHealth(); cfg.activeCodexAccountId = "pool-a"; await expect(resolveCodexAuthContext(headers, cfg, "pool")) - .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + // The streak detoured this session onto pool-b but never surrendered its binding + // (#4546), so with pool-a healthy again the session comes home to its warm prefix. + // That is also what proves the late failure did no damage: a guard that had dropped + // the held pin would leave nothing to come home to. + .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); }); test("selection order never bypasses an exact account selector", async () => { diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index 522ea4a8e3..cdf7120cb1 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -439,7 +439,7 @@ describe("accountPoolStrategy new-session routing", () => { }); test("reset-first keeps affinity until either window reaches the threshold", () => { - const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first", pool: { cacheAffinity: false } }); const now = Date.now(); const seconds = now / 1000; setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: seconds + 100 }); @@ -1180,7 +1180,7 @@ describe("selection order across rotation strategies", () => { accountPoolStrategy: "quota", autoSwitchThreshold: 80, activeCodexAccountId: "a", - ...(cacheAffinity ? { pool: { cacheAffinity: true } } : {}), + pool: { cacheAffinity }, } as Partial); const threadId = "cache-affine-thread"; // Bind the thread while "a" is the natural quota pick, which is how a real conversation @@ -1295,7 +1295,11 @@ describe("selection order across rotation strategies", () => { accountPoolStrategy: "quota", autoSwitchThreshold: 80, activeCodexAccountId: "a", - }); + // This case is about WHERE a threshold-driven move may land, so it states the + // capacity-first setting explicitly (#4546). Under the default a bound thread does + // not move on a threshold crossing at all, and the destination rule never runs. + pool: { cacheAffinity: false }, + } as Partial); const threadId = "cache-safe-real-improvement"; updateAccountQuota("a", 10); updateAccountQuota("b", 50); @@ -1369,6 +1373,138 @@ describe("selection order across rotation strategies", () => { expect(resolveCodexAccountForThread(threadId, config, later)).toBe("b"); }); + test("an install that never configured pool keeps a bound thread on its account (#4546)", () => { + // No pool key at all. This is the case the incident was reported from: the operator had + // never heard of cacheAffinity, so the protection has to be the default or it is not + // protection. + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + }); + const threadId = "default-affinity-thread"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + updateAccountQuota("a", 90); + updateAccountQuota("b", 5); + updateAccountQuota("c", 5); + const later = Date.now() + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + expect(resolveCodexAccountForThread(threadId, config, later)).toBe("a"); + expect(previewCodexAccountForRequest(threadId, config, later)).toBe("a"); + }); + + test("capacity-first refuses a destination with no headroom (#4546 ping-pong)", () => { + // The reported spiral, reproduced with the historical rule explicitly restored: every + // account is over the threshold, so every turn found a "cooler" account and moved again. + // A move now has to be worth making, so the thread stays and keeps its prefix. + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + pool: { cacheAffinity: false }, + } as Partial); + const threadId = "hot-pool-thread"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + updateAccountQuota("a", 95); + updateAccountQuota("b", 90); + updateAccountQuota("c", 85); + let at = Date.now() + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + expect(resolveCodexAccountForThread(threadId, config, at)).toBe("a"); + at += CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + expect(resolveCodexAccountForThread(threadId, config, at)).toBe("a"); + expect(previewCodexAccountForRequest(threadId, config, at)).toBe("a"); + }); + + test("capacity-first still moves a bound thread to an account that has headroom", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + pool: { cacheAffinity: false }, + } as Partial); + const threadId = "capacity-first-thread"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + updateAccountQuota("a", 95); + updateAccountQuota("b", 10); + updateAccountQuota("c", 50); + const later = Date.now() + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + expect(resolveCodexAccountForThread(threadId, config, later)).toBe("b"); + }); + + test("a transient streak detours the request and keeps the binding (#4546)", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + upstreamFailoverThreshold: 3, + }); + const threadId = "transient-hold-thread"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + updateAccountQuota("c", 30); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + recordCodexUpstreamOutcome(config, "a", 503); + recordCodexUpstreamOutcome(config, "a", 503); + recordCodexUpstreamOutcome(config, "a", 503); + + // Served elsewhere, because "a" cannot take this request right now. + const served = resolveCodexAccountForThread(threadId, config); + expect(served).not.toBe("a"); + // Preview agrees once the request path has chosen a detour, so subagent fallback scores + // the account that will actually serve. + expect(previewCodexAccountForRequest(threadId, config)).toBe(served); + + // The binding was never surrendered: past the soft-avoid window and the failure window, + // the thread is home again with its prefix intact. A deleted binding could not do this. + const recovered = Date.now() + 6 * 60_000; + expect(resolveCodexAccountForThread(threadId, config, recovered)).toBe("a"); + }); + + test("a transient hold that outlives its window releases the binding", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + upstreamFailoverThreshold: 3, + }); + const threadId = "transient-hold-expiry-thread"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + updateAccountQuota("c", 30); + const start = Date.now(); + expect(resolveCodexAccountForThread(threadId, config, start)).toBe("a"); + + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + expect(resolveCodexAccountForThread(threadId, config, start)).toBe("b"); + + // Still failing eleven minutes later: a hold is a grace period, not a pin, so the binding + // is released and the thread rebinds to whatever can actually serve it. + const late = start + 11 * 60_000; + recordCodexUpstreamOutcome(config, "a", 503, { now: late }); + recordCodexUpstreamOutcome(config, "a", 503, { now: late }); + recordCodexUpstreamOutcome(config, "a", 503, { now: late }); + expect(resolveCodexAccountForThread(threadId, config, late)).toBe("b"); + + // "a" is healthy again, and the thread does NOT return: it lives on "b" now, which is the + // difference between a released binding and a held one. + const healthy = late + 6 * 60_000; + expect(resolveCodexAccountForThread(threadId, config, healthy)).toBe("b"); + }); + test("the pool moves, then a manual pick wins the next unbound dispatch", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin", diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index c11be73f2e..44160ec8b0 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -2185,7 +2185,8 @@ describe("codex routing", () => { // Phase 40 (260630_wsl-account-autoswitch): bound-thread quota re-eval. test("bound thread over threshold switches after the re-eval interval", () => { - const config = makeConfig(); + // Capacity-first is now opt-in, so this pins it explicitly (#4546). + const config = makeConfig({ pool: { cacheAffinity: false } }); const now = 1_800_000_000_000; updateAccountQuota("a", 10); updateAccountQuota("b", 10); @@ -2200,7 +2201,7 @@ describe("codex routing", () => { }); test("bound thread over threshold switches immediately without waiting for re-eval (#584)", () => { - const config = makeConfig(); + const config = makeConfig({ pool: { cacheAffinity: false } }); const now = 1_800_000_000_000; updateAccountQuota("a", 10); updateAccountQuota("b", 10); @@ -2241,7 +2242,7 @@ describe("codex routing", () => { }); test("bound thread over threshold switches once and does not ping-pong", () => { - const config = makeConfig(); + const config = makeConfig({ pool: { cacheAffinity: false } }); const now = 1_800_000_000_000; updateAccountQuota("a", 10); updateAccountQuota("b", 10); @@ -2565,6 +2566,7 @@ describe("codex account selection order", () => { const threadId = "quota-detour-failover-candidate"; const modelId = "gpt-daybreak-blue-latest"; const config = makeConfig({ + pool: { cacheAffinity: false }, accountPoolStrategy: "quota", activeCodexAccountId: "c", codexAccounts: [ @@ -2626,6 +2628,7 @@ describe("codex account selection order", () => { const now = 1_800_000_000_000; const threadId = "ordinary-quota-failover-candidate"; const config = makeConfig({ + pool: { cacheAffinity: false }, accountPoolStrategy: "quota", activeCodexAccountId: "a", codexAccounts: [ @@ -3417,7 +3420,7 @@ describe("codex account selection order", () => { }); test("a bound thread over threshold moves to the highest tier with headroom", () => { - const config = makeConfig({ activeCodexAccountId: "b" }); + const config = makeConfig({ activeCodexAccountId: "b", pool: { cacheAffinity: false } }); updateAccountQuota("a", 10); updateAccountQuota("b", 10); expect(resolveCodexAccountForThread("thread-1", config)).toBe("b");