diff --git a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md index 393c498934..dcf7ac9924 100644 --- a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md +++ b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md @@ -101,3 +101,139 @@ The Anthropic and generic halves are not frozen, so a narrower first slice exist unify the affinity key for those two kinds only, leaving the Codex thread-affinity map on its current key until the freeze lifts. That slice still needs assumption 1 answered, which is why this phase stays closed rather than being re-scoped now. + +## wp3 plan — what criterion c-4 actually requires + +This phase was recorded as blocked on three product decisions: the shared affinity key shape, +the shared-cohort `prompt_cache_key` fallback, and a minimum-token cache gate. Re-reading the +criterion against the code shows none of the three is on the path to it. + +> c-4: Account selection consults cache affinity before quota for subscription pools, proven by +> a test where the cache-affine account is chosen over a higher-headroom one. + +That is a statement about **ordering**, not about key shape. The phase title pairs ordering with +"a unified affinity key", but only the ordering half is an acceptance criterion, and the two are +separable: reordering uses each kind's EXISTING affinity binding and introduces no new key. +Assumption 1 gates the unified key, not this. Assumption 2 is a property of the Anthropic +session-key derivation, which the ordering change does not touch. Assumption 3 is explicitly +optional in the original text ("decide whether to implement") and is not required by c-4. + +So the unified key stays open and stays out of this cycle. The ordering ships now. + +## Only one kind actually breaks cache affinity + +Verified on the branch head rather than assumed: + +- **Anthropic already honours affinity unconditionally.** `src/oauth/anthropic-routing.ts`:604-610 + returns `{ reason: "affinity" }` whenever the affined account is present, not reauth-flagged, + not cooled and credential-usable. `autoSwitchThreshold` governs NEW-session picks + (`anthropicAutoSwitchThreshold`, :111) and never rebinds a live session. +- **Codex does not.** `reevaluateAffinityQuota` (`src/codex/routing.ts`:2031) rebinds a live + thread whenever the quota strategy is active and usage crosses `autoSwitchThreshold` (:2047), + which throws away a warm prompt cache on a hint rather than on evidence. +- The generic OAuth kind has no affinity at all, so it has nothing to reorder. + +That makes this a one-function change, and it makes the criterion's "pools" plural satisfiable: +after it, both subscription pools keep a bound conversation on its account until that account +genuinely cannot serve. + +## Change surface + +`src/codex/routing.ts`, `reevaluateAffinityQuota` only. Under `pool.kernel`, the rebind bar +stops being "crossed the threshold" and becomes the same **drained** test the pin-release path +already uses (`releaseDrainedCodexAccountPin`, :1866): + +``` +!isCodexAccountUsable(config, entry.accountId, selectionOptions) + || !hasCodexQuotaHeadroom(config, entry.accountId, selectionOptions, now) +``` + +Reusing that predicate rather than inventing a second notion of "spent" is deliberate: two +definitions of exhausted in one file is how they drift. The reeval-interval short circuit keeps +its current shape so a bound thread is still not re-scored more than once a minute. + +Flag off restores today's behaviour exactly, which is what makes shipping this without the three +open decisions safe rather than presumptuous. + +## Acceptance + +- A bound thread on an account at 90% usage with `autoSwitchThreshold: 80` and a sibling at 10% + KEEPS its account while the flag is on — the cache-affine account chosen over the + higher-headroom one, which is c-4 verbatim. +- The same fixture with the flag off still moves, so the old behaviour is provably intact. +- A bound thread whose account is genuinely drained still moves with the flag on, so the change + is a reordering and not a pin. +- Red control: with the flag branch removed, the first case must fail. + +### wp3 plan audit — FAIL, folded + +**Blocker 1 — the "drained" bar I proposed IS the threshold.** `releaseDrainedCodexAccountPin` +reads `!isCodexAccountUsable || !hasCodexQuotaHeadroom`, and `hasCodexQuotaHeadroom` +(`src/codex/routing.ts`:1387-1395) is `usage < (autoSwitchThreshold ?? 80)`. Reusing it inside +`reevaluateAffinityQuota` would have preserved today's 80% rebind exactly, so the plan's own +acceptance case — a bound thread at 90% with threshold 80 KEEPING its account — could not have +passed. The argument for reuse ("don't invent a second notion of spent") was right in spirit and +wrong in fact: the pin path deliberately releases at the auto-switch crossing, which is a +different question from whether the account can still serve. + +The bar this phase needs is genuine exhaustion, and it is not expressible as the existing +predicate. Definition used instead, local to the reeval and stated once: + +``` +spent = !isCodexAccountUsable(config, id, selectionOptions) // reauth, excluded, cooled + || (!isUnknownUsage(usage) && usage >= 100) // allowance actually gone +``` + +Per minor 7 the usable half is already guaranteed by the caller, which requires +`isCodexAccountSelectable`, so in practice the test reduces to the usage half — kept explicit +anyway so the predicate reads correctly on its own. + +**Major 3 — `previewReusableAffinityAccount` duplicates the same threshold move.** +`src/codex/routing.ts`:1984 carries its own copy for the preview path. Changing only the +mutating site would make `previewCodexAccountForRequest` disagree with +`resolveCodexAccountForThreadDetailed` — and the suite already contains cases asserting those +two agree. Both move together. + +**Major 4 — the reeval interval must stop keying off the old bar.** The short circuit stamps +`lastReevalAt` only when `overThreshold`, so leaving it as-is while the rebind bar changes +re-scores a thread on every request through the whole 80-99% band. The short circuit follows the +new bar, keeping the once-a-minute ceiling intact. + +**Minor 6, taken — the flag is wrong.** `pool.kernel` is the generic-OAuth strategy-consume +flag introduced in wp2b; reusing it for a Codex affinity rule would overload one switch with two +unrelated meanings and make either one impossible to turn on alone. This uses its own +`pool.cacheAffinity`, defaulting off. + +**Minor 5 recorded.** `tests/codex-integration/codex-routing.test.ts` contains cases that require +the immediate over-threshold switch. They stay green because the flag defaults off, and that is +the check that proves flag-off is byte-identical rather than merely claimed. + +### Major 2 — rebutted, with its limit stated + +The audit is right that today's stickiness is keyed on thread and session identity rather than +on a cache key, and that a thread-keep test therefore proves "identity stickiness outranks +quota", not "a measured cache is consulted". That distinction is real and is exactly what the +deferred unified key would close. + +It does not block c-4. In this codebase the thread/session binding IS the mechanism by which a +warm prompt cache stays reachable: the cache lives on the account that served the conversation, +so keeping the conversation there is what preserves it. c-4 asks that the affine account win +over a higher-headroom one, and after this change it does. What remains open — and is recorded +as open rather than quietly satisfied — is making the binding explicitly cache-derived instead +of identity-derived. The criterion's plural "pools" is likewise honest only because Anthropic +already holds its live sessions; this change brings Codex to the behaviour Anthropic has, rather +than adding a second implementation. + +### The "## Change surface" block above is SUPERSEDED + +It still names `pool.kernel`, `hasCodexQuotaHeadroom` and `reevaluateAffinityQuota` alone. +Implementing it as written fails three of the folded findings and cannot pass the 90% keep case. +The fold is the spec. Concretely, the build is: + +- `src/types/config.ts` and `src/config.ts` — `pool.cacheAffinity?: boolean`, default off. +- `src/codex/routing.ts` `reevaluateAffinityQuota` AND `previewReusableAffinityAccount` — both + copies swap the rebind bar to `!isCodexAccountUsable || (!isUnknownUsage(usage) && usage >= 100)` + when the flag is on, and the `lastReevalAt` short circuit keys off that same bar. + +The pre-audit block stays as the record of what was planned before the audit rather than being +rewritten to look correct. diff --git a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md index f5d42333ff..810e5256db 100644 --- a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md +++ b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md @@ -64,3 +64,332 @@ both paths; a single-key pool is a no-op. No operator-visible surface. Phase 5 owns the management route and GUI; adding fields there from this layer would collide with it. + +## wp4b wiring plan (re-verified against `codex/generic-pool-kernel`) + +#4277 shipped `selectProactiveApiKey` (`src/providers/key-failover.ts`:128) and deliberately +stopped there: the picker exists, is unit-tested, and is called from nowhere in production. So +does `forgetApiKeyRotationCursor` (:112). This unit connects both, and nothing else. + +| Symbol | File | Line | +|---|---|---| +| `selectProactiveApiKey` | `src/providers/key-failover.ts` | 128 | +| `forgetApiKeyRotationCursor` | `src/providers/key-failover.ts` | 112 | +| OAuth-only branch, skipped by key-auth | `src/server/responses/core.ts` | 4322 | +| transport pin, last `route.provider` write before the first send | `src/server/responses/core.ts` | 4450 | +| `activeProvider` bind | `src/server/chat-native.ts` | 238 | +| `PUT /api/providers/keys/active` | `src/server/management/oauth-account-routes.ts` | 674 | + +### Where the call goes, and why there + +`route.provider` is final for a key-auth request at the transport pin on `core.ts`:4450, and all +four first-send consumers read that same object — the image/video bridge (:6570), web search +(:6653), `runTurn` (:6739) and the generic HTTP path (:7174). One call placed after the OAuth +block and before the pin therefore serves every one of them, with no per-path duplication. That +is the exact position the OAuth side already occupies: "prefer the account with known headroom +BEFORE the first attempt" at :4344. + +`chat-native.ts` is a separate entry path and needs its own call, immediately before +`activeProvider` is bound at :238. + +Nothing competes with it. `resolveProviderTransport` never swaps keys, and +`applyCodexAuthContextToProvider` is a no-op outside `authMode: "forward"`. The one pre-send +`apiKey` rewrite that does exist (`core.ts`:4196) re-reads an already committed selection and +does not run on a current first attempt. + +**No new import edge on the core path.** `core.ts` already imports `hasKeyPoolFailover` from +`../../providers/key-failover` at :269, so the picker joins an existing import — which matters +because `core.ts` is one of the three files that must never reach `src/lab`. + +### Cursor invalidation + +`forgetApiKeyRotationCursor` has no production caller, so the round-robin cursor currently +outlives the pool it describes. It joins `clearKeyCooldowns(name)` at the three management +routes that already reset key state: the manual active-key PUT at :674, and the add/remove key +routes at :641 and :714. An operator who just chose a key should not be second-guessed by a +cursor that predates the choice — the same rule wp1b and wp2b applied to the account pools. + +### Scope boundary + +No change to `selectProactiveApiKey` itself, to the reactive 429/401 rotation, or to the +strategy semantics. The picker already refuses to override a healthy committed key and already +returns null when no strategy is configured, so an install that never set `apiKeyPoolStrategy` +executes one predicate and nothing else. + +### Acceptance + +Criterion c-5 is already met by #4277 for the selection logic; this unit adds the evidence that +it reaches a real dispatch. + +- `tests/server/server-key-failover-e2e.test.ts` is the only suite that drives a real + first-attempt key-auth dispatch with an `apiKeyPool`, so it takes the new case: a two-key pool + whose committed key is cooled, with `apiKeyPoolStrategy` set, must send the FIRST request on + the other key. Red control: without the wiring the first attempt goes out on the cooled key and + earns the 429 the runtime could already predict. +- A second case pins the no-op: with no `apiKeyPoolStrategy`, the committed key is used + unchanged even when cooled, because rotation stays reactive-only for that install. +- A cursor case: a manual key selection through `PUT /api/providers/keys/active` clears the + rotation cursor. + +### Plan audit — FAIL, folded + +**Blocker 1 — the picker does not mutate the route.** `selectProactiveApiKey` writes +`config.providers[name]` and RETURNS a clone; it never touches `route.provider`. The plan said +"wire the call" without saying what to do with the return, which is not implementable: a literal +reading leaves the live route on the cooled key and the whole unit is a no-op that still writes +config. The call site is: + +``` +const picked = selectProactiveApiKey(config, route.providerName, now); +if (picked) route.provider = picked; +``` + +**Blocker 2 — the assignment must land before the copies, not merely before the send.** +"One call serves all four consumers" is true only because nothing reassigns `route.provider` +between the pin and each consumer — but they do not all read it late. `adapterProvider` is +copied at `core.ts`:4458 and the adapter is bound at :4477, and the HTTP path captures +`builtInitialRequest` at :7139. So the assignment goes BEFORE :4450, ahead of every copy. The +audit also showed why this cannot be left to self-healing: the HTTP and `runTurn` paths can +re-read a stale selection through `refreshDispatchAdapter` (:4197), but the image bridge +(:6570) and web search (:6655) call `providerFetch(route.provider)` directly and have no such +second chance. Ordering is the entire correctness argument here. + +**Major 1 accepted, with the reason recorded.** Putting the picker on the first-attempt path +means an ordinary request can now perform a persisted config write. It is bounded: the picker +returns null unless a strategy is configured AND the committed key is already cooled, so a +healthy install does one predicate and stops. The write goes through the same +`commitProviderApiKeySelection` / `mutatePersistedConfig` lock the reactive rotation uses, and a +later same-request 429 rotation serializes behind that lock rather than racing it. The cost is +paid exactly once per cooldown, replacing a request that was otherwise spent earning a 429 the +runtime could already predict. + +**Major 2 — two first-send paths this unit does NOT cover, named rather than silently dropped.** +Native compact for `openai-apikey` (`src/server/responses/compact.ts`:669, dispatch at :745-883) +never enters `core.ts`, and the keyed `/v1/images` path (`src/server/images.ts`:701) reads +`candidates.keyed.apiKey` directly rather than a provider object. Each has a different +provider-resolution shape and needs its own dispatch harness, so they become their own +work-phase instead of riding along untested here. `collaboration.ts` and +`encrypted-payload.ts` are NOT affected: they import `rotateProviderTransportOn429` and +dispatch no first attempt. + +**Minors folded.** The web-search fetch is `core.ts`:6655, not :6653 (that line is a comment). +The stale-selection re-read is :4197, not :4196. `src/server/management/provider-routes.ts`:832 +and :931 also `clearKeyCooldowns` on key replace and delete, so the cursor reset belongs there +too — five routes, not three. + +## wp4 plan — quota-aware API key selection + +wp4b wired the picker in; this gives it the third strategy. Today +`apiKeyPoolStrategy` accepts only `round-robin` and `fill-first` +(`src/config.ts`:586, `src/types/provider.ts`:399), so an API key pool cannot do what every +other pool in this codebase already does: prefer the credential with the most room left. + +| Symbol | File | Line | +|---|---|---| +| `apiKeyPoolStrategy` schema | `src/config.ts` | 586 | +| `apiKeyPoolStrategy` type | `src/types/provider.ts` | 399 | +| `selectProactiveApiKey` strategy read | `src/providers/key-failover.ts` | 135 | +| per-key quota cache (private) | `src/providers/quota-key-accounts.ts` | 22 | +| `identity()` cache key | `src/providers/quota-key-accounts.ts` | 50 | +| `readProviderApiKeyQuotas` | `src/providers/quota-key-accounts.ts` | 101 | +| `keyQuotaReaderForProvider` | `src/providers/quota.ts` | 2897 | +| editor field list | `src/server/auth-cors.ts` | 821 | + +### The one real obstacle: the selector is synchronous, the quota reader is not + +Per-key quota already exists — `keyQuotaReaderForProvider` serves seventeen providers — but it +is reached only through `readProviderApiKeyQuotas`, which is `async` and probes the network on a +miss. `selectProactiveApiKey` is synchronous and sits on the first-attempt path, where it must +not await anything. + +So `quota-key-accounts.ts` grows one cache-only, synchronous reader: + +``` +export function cachedApiKeyQuota(name, provider, keyId, key): ProviderQuota | null +``` + +It recomputes the same `identity()` the async path stores under, reads `cache`, and returns +null on a miss. It never probes, never awaits and never schedules one — a selector that could +trigger a network read on the request path would be a worse defect than the one this unit +fixes. A miss is simply "no evidence", which is the same word the OAuth side uses. + +Env-placeholder keys resolve through `resolveProviderApiKey` exactly as the async path does, +inside a try/catch: an unresolvable key is a miss, not a throw on the dispatch path. + +### Ranking, and what happens without evidence + +`quota` ranks the eligible keys by remaining headroom and takes the roomiest. When NO eligible +key has a cached row, it falls back to the first eligible key — which is what `fill-first` +already does, and therefore exactly today's behaviour for a provider whose quota reader does not +exist or has never run. + +That is deliberately NOT the OAuth rule. `preferredInitialAccount` returns null without +evidence because its active account is still perfectly usable. Here the function has already +established that the committed key is cooling, so returning null would mean deliberately +dispatching on a spent key. There is no no-op available; the only question is which replacement. + +### Change surface + +`src/providers/quota-key-accounts.ts` — add `cachedApiKeyQuota` and a +`setCachedProviderApiKeyQuotaForTests` seam mirroring the account-side +`setCachedProviderAccountQuotaForTests`, because a synchronous reader of a private cache is +otherwise untestable without a live probe. + +`src/types/provider.ts`:399 and `src/config.ts`:586 — widen the union to include `quota`. +`src/server/auth-cors.ts`:821 already lists the field as editor-visible and needs no change. + +`src/providers/key-failover.ts` — a third branch in `selectProactiveApiKey`. `round-robin` and +`fill-first` keep their current code paths byte for byte. + +### Acceptance + +- `tests/adapters/key-failover.test.ts`: the roomiest eligible key wins; a cooled roomier key is + skipped; with no cached rows the first eligible key is taken; an unknown strategy value still + degrades to no-op. Red control for each: with the `quota` branch removed the ranking cases must + fail. +- `apiKeyPoolStrategy` is currently undocumented in `docs-site` — no row exists anywhere. It + gains one in `reference/configuration/providers.md` describing all three values, since shipping + a third undocumented value is how the generic pool ended up inert and unexplained. + +### wp4 plan audit — FAIL, folded + +**Blocker 1 — a cache hit is not evidence.** `readEntry` stores `{ unavailable: true, quota: +lastGood }` for up to `LAST_GOOD_MS` (30 minutes) when a probe fails, so the row survives with a +stale measurement attached. A reader that returns `entry.quota` on any hit would rank on a +number taken up to half an hour ago from a probe that has since been failing — and rank it +ABOVE a key with no row at all. `cachedApiKeyQuota` returns null whenever `entry.unavailable` +is set or `entry.quota` is null. Last-good is a display value; it is not a selection input. + +**Blocker 2 — the ranking was not specified, and the obvious formula does not work.** +"Remaining headroom" is undefined for `ProviderQuota`, which carries `fiveHourPercent`, +`weeklyPercent`, `monthlyPercent`, `customWindows[].percent` and `creditsUsd`. The definition +this unit uses, matching `headroomOf` on the OAuth side so the two pools cannot disagree: + +`headroom = 100 - max(fiveHourPercent, weeklyPercent, monthlyPercent, ...customWindows.percent)`, +and null when none of those is a number. `creditsUsd` is deliberately excluded: it is a +currency amount, not a percentage, and mixing the two scales produces an ordering that means +nothing. + +**Mixed evidence needs a rule and now has one**, borrowed from +`rankAccountsByHeadroom`'s three buckets rather than invented: measured-with-headroom first +(most headroom wins), then unmeasured, then measured-and-exhausted, with the stable roster order +breaking ties. An unmeasured key is not assumed spent, and it is not assumed fresh either. + +**Recorded, not fixed — providers whose rows cannot discriminate.** DeepSeek reports every key +at `customWindows.percent: 0`, so all headrooms tie at 100 and the pick falls through to the +stable order, which is exactly today's behaviour. That is the correct outcome for a provider +that publishes no per-key differentiation, and it is why the fallback has to be a real ordering +rather than an error. + +**Major 1 — "unknown strategy is a no-op" was wrong.** Today any truthy value that is not +`round-robin` takes the `eligible[0]` default, which IS fill-first; zod is the only thing +rejecting junk. So the new branch is `else if (strategy === "quota")` placed after the +round-robin block and BEFORE that default. Replacing the default would silently retarget +fill-first. The acceptance bullet claiming a no-op is struck. + +**Major 2 — the test seam cannot mirror the account-side signature.** The key cache is keyed on +`identity(name, provider, id, resolvedKey)`, so the seam takes the provider name, the provider +config, the key id and the raw key, not `(provider, accountId, quota)`. + +**Minors folded.** `keyQuotaReaderForProvider` is at `quota.ts`:2898, not :2897. The e2e helper +added in wp4b types its strategy parameter as `"round-robin" | "fill-first"` and widens with the +union. The provider count is approximate and the claim is dropped. `resolveProviderApiKey` is +synchronous and swallows its own failures, so the try/catch is belt-and-braces rather than +required — kept, and labelled as such. + +**Deliberate:** a `quota` pick still records `keyRotationCursor`. The cursor is where the pool +last was, not a round-robin private; leaving it accurate means switching an operator to +`round-robin` later resumes from the key actually in use instead of the start of the ring. + +## wp4c plan — the two first-send paths that never enter core.ts + +wp4b wired `selectProactiveApiKey` into the Responses core and native chat. The audit that +produced it named two dispatch paths those two call sites do not cover, and they became this +unit rather than riding along untested. + +| Seam | File | Line | Shape | +|---|---|---|---| +| native compact | `src/server/responses/compact.ts` | 745-746 | `compactProvider` object; key applied as a header | +| keyed images | `src/server/images.ts` | 701-703 | `candidates.keyed` destructured to `{ provider, apiKey, providerName }` | + +Both are genuinely independent: native compact runs only when +`supportsNativeResponsesCompactEndpoint` accepts the destination and never reaches +`handleResponses`, and the keyed image path builds its own URL and Authorization header +without a route object at all. + +### One seam per file, and only first sends + +`compact.ts`:745 is the native-compact branch: + +``` +if (compactProvider.authMode !== "forward" && compactProvider.apiKey) { + headers.set("authorization", `Bearer ${resolveProviderApiKey(compactProvider.apiKey)}`); +``` + +The pick goes immediately above it, reassigning `compactProvider` from the returned clone — +the same assign-then-use shape wp4b established, and for the same reason: the picker returns a +clone and never mutates its argument. + +`images.ts`:701 destructures `{ provider, apiKey, providerName }`. The pick runs before the +destructure so the header below is built from the chosen key. + +**Explicitly NOT a seam:** `compact.ts`:446 sits inside `resolveAlternateCompactContext`, which +runs after a failure. It is the compact analogue of the 429 rotation loops and must stay +reactive; putting a proactive pick there would move a retry off the account the retry exists to +replace. + +### What stays out + +No change to `selectProactiveApiKey`, to the reactive rotation, or to the strategies. The picker +already returns null unless a strategy is configured AND the committed key is cooling, so an +install that never set `apiKeyPoolStrategy` evaluates one predicate on each of these paths and +stops — including the persisted-write path, which is never reached. + +### Acceptance + +- A cooled committed key with a configured strategy is replaced on the FIRST native-compact send + and on the FIRST keyed image send, proven end to end rather than by unit-calling the picker. +- Without a configured strategy both paths still use the committed key, so rotation stays + reactive-only for an install that never asked otherwise. +- Red control: with each call site removed, its case must fail with the cooled key on the wire. +- The Lab boundary suite runs, because `compact.ts` imports from the same module family the core + path does. + +### wp4c plan audit — PASS-WITH-FINDINGS, folded + +**Major 1 — the images seam carries a resolved SNAPSHOT, not a live field.** +`candidates.keyed.apiKey` is built once by `selectImagesProvider` (`src/server/openai-sidecar.ts`:237-238, +:282), so the literal "pick, then destructure" would set the Authorization header from the OLD +key while the picker had already persisted the new one — a request on a cooled key plus a config +write, which is strictly worse than doing nothing. The header is rebuilt from the returned clone +through `resolveProviderApiKey` instead. + +This is the same class of mistake wp4b's blocker caught: the picker returns a clone and mutates +nothing, so every seam has to be asked "what does the send actually read?" rather than "did I +call it". + +The call also stays INSIDE the `candidates.keyed` branch rather than moving up next to +`selectImagesProvider`. Higher up it would run — and write config — even on requests that +ChatGPT forward goes on to serve, spending a rotation on a path that never used the key. + +**Minor 2 — gate the compact reassignment.** `compactProvider` starts as `route.provider` and is +overlaid only for `codexAccountMode` or custom reserve-forward. The picker returns null for +forward providers, so an ungated assign would be harmless today, but it stays inside the +existing `authMode !== "forward" && apiKey` branch so a future overlay cannot be clobbered by +accident. The provider name to pass is `route.providerName`. + +**Minor 3 — my lease concern was overstated, corrected.** Key-auth native compact does not hold +host-circuit admission at all: `preAuthUpstreamHostCircuitKey` requires +`codexAccountMode === "pool"` with `authMode === "forward"`. Turn admission is a counter and the +config write is SQLite, so there is no shared mutex to deadlock on and the lease stays valid — +the same situation wp4b already ships at the core seam. The plan's caution was unfounded and is +struck rather than left standing as a vague worry. + +**Minor 4 — confirmed there are no other first-send key applications in either file.** +`compact.ts`:289 and :380 are 401 refresh paths, and :446 is the 429/402 pool alternate. + +**Both paths are e2e-testable**, which is what lets the acceptance claim an end-to-end proof +rather than a unit call: native compact through the openai-apikey harness in +`tests/adapters/openai/openai-api-virtual-models.test.ts`, and the keyed image path through +`tests/server/server-images.test.ts`, whose keyed fallback already asserts a specific Bearer. +The cooled-committed-key setup is the one wp4b built in `server-key-failover-e2e.test.ts`. diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md index 0144d41f82..08953085b7 100644 --- a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -64,3 +64,428 @@ tests/server/account-pool-management-api.test.ts for the unified DTO and the deprecated alias; tests/cli/cli-account-pool-verbs.test.ts for CLI parity; a GUI test that the panel mounts for a generic OAuth provider. A gui-labelled PR needs a screenshot in its description per AGENTS.md. + +## wp5 plan — one pool-settings contract + +## What "three contracts" actually means + +Not three routes with one shape. Three shapes, three storage locations and three +re-implementations of the same validation. + +| Kind | Route | Storage | DTO fields | +|---|---|---|---| +| Codex | `PUT /api/codex-auth/auto-switch`, `PUT\|PATCH /api/codex-auth/pool-strategy` | `runtimeConfig.autoSwitchThreshold`, `.accountPoolStrategy`, `.accountPoolStickyLimit` | threshold; strategy + stickyLimit, split across two routes | +| Anthropic | `GET\|PUT\|PATCH /api/oauth/accounts/pool?provider=anthropic` | `config.anthropicAccountPool` | enabled, autoSwitchThreshold, strategy, stickyLimit, quotaWindow, `experimental: true` | +| generic | same route, other branch | `providers..oauthAccountFailover` | enabled, strategy, autoSwitchThreshold, stickyLimit, `inert` | + +Anchors: `src/codex/auth-api.ts`:2465 and :2478; `src/server/management/oauth-account-routes.ts`:354 +and :379; `src/oauth/pool-settings-capability.ts`:57. + +Three consequences, all observable today. The Codex kind is the only one that cannot be READ +through a pool route at all — the CLI reads `/api/codex-auth/active` instead +(`src/cli/account-extended.ts`:854-887 already documents the asymmetry as a table, which is the +tell). Every kind re-parses `strategy` and `stickyLimit` with its own copy of the same bounds. +And a field that exists for one kind is absent rather than declared-unsupported for the others, +so a dashboard cannot tell "this pool has no quotaWindow" from "this pool forgot to send it". + +## The unit + +**One DTO, one validator, one route. The three existing paths stay as aliases.** + +NEW `src/server/management/pool-settings-contract.ts` — a single `PoolSettingsDto` with every +field the union needs and an explicit `supported` set per kind, plus one validator that owns the +strategy names, the 1..100 sticky bound and the 0..100 threshold bound. The three kinds keep +their own STORAGE; only the shape and the validation are shared. + +NEW route `GET\|PUT /api/pool/settings?provider=` in +`src/server/management/oauth-account-routes.ts`, registered in `route-registry.ts`, serving all +three kinds through `poolSettingsCapability`. + +The three existing paths keep working, unchanged, delegating to the same module. This is +additive on purpose: the management API is a public contract with CLI and GUI clients, and a +breaking change is not what "consolidate" has to mean. The registry marks the old paths +superseded so the next reader knows which one is canonical. + +MODIFY `src/cli/account-extended.ts` — the transport table at :854-887 exists precisely because +the two contracts disagree. It collapses to one path, and the comment explaining the asymmetry +goes with it. + +## Out of scope, and why + +**The GUI half is its own work-phase (wp5b).** `gui/src/codex-auto-switch.ts` and +`gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx` are two separate pool +surfaces, and merging them is a visual change. This repository's `enforce-target` gate requires +a screenshot in the description of any PR whose title or description mentions `gui`, which means +building and running the dashboard to capture one. That is a real deliverable, not a formality, +and bolting it onto a server-side PR would either skip the evidence or stall the server work +behind it. + +## Acceptance + +- One module owns strategy/sticky/threshold validation; a bad value is rejected identically on + every kind, proven by a table-driven test across all three. +- `GET /api/pool/settings?provider=` answers for Codex, Anthropic and a generic provider, and + each response declares which fields that kind supports rather than omitting them. +- The three legacy paths return byte-identical bodies to today, proven by tests that predate this + change and must not be edited. +- Red control: each new shared-validator case must fail if the shared bound is loosened. + +### wp5 plan audit — FAIL, folded + +**Blocker 1 — the compatibility guard this plan leans on does not exist.** "Byte-identical, +proven by tests that predate this change and must not be edited" is false. The Codex and +Anthropic assertions use `toMatchObject`, which passes when extra keys appear, and the Codex +`PUT /api/codex-auth/auto-switch` test checks only status 200, never the body +(`tests/server/account-pool-management-api.test.ts`:42, :187, :266; +`tests/codex-integration/codex-auth-api.test.ts`:3645). Only the generic GET uses a full +`toEqual` (:483). So the refactor would have been guarded by tests that cannot detect the +regression they were cited for. + +The unit therefore starts by WRITING that guard: exact-body assertions for all three legacy +responses, committed and green BEFORE any shared module exists. A characterization test written +after the change proves nothing about what the change did. + +**Blocker 2 — "delegating to the same module" skipped the adapter.** The three routes do not +merely differ in shape, they disagree on every axis: Codex auto-switch takes `{threshold}` and +answers `{ok:true}`; Codex pool-strategy takes `{strategy, stickyLimit}` and answers +`{ok, accountPoolStrategy, accountPoolStickyLimit}`; the OAuth route takes `{provider, ...}` +and answers with different key names again. A shared handler would 400 live CLI and GUI writes. + +What is actually shared is narrower and still worth it: the shared module owns VALUE validation — +the strategy names, the 1..100 sticky bound, the 0..100 threshold bound — while each route keeps +its own request parsing and response shaping as an explicit adapter. "One validator, three +adapters", not "one handler". + +**Major — a new management route is not a one-line registration.** It must appear in +`route-registry.ts` (`tests/server/management-route-registry.test.ts` compares source and +registry as exact pairs), AND in `src/cli/capabilities.ts` or one of the two exemption lists in +`tests/cli/cli-capabilities.test.ts`:174/:344, AND — if capabilities change — the generated +`skills/ocx/references/01_management_surface.md` must be regenerated, which is the gate that +went red on #4289 this session. Also `PATCH` exists on both legacy writes while the proposed +route was `GET|PUT` only. + +**Major — a fourth storage location the plan missed.** Top-level +`config.oauthAccountFailover.enabled` (`src/types/config.ts`:917) participates in generic +activation through `isProactivePreferenceEnabled`, but the generic DTO reads only +`providers..oauthAccountFailover`. So `enabled: null` currently means "nothing stored +here" while the effective answer may be `true` from the global. That is a reporting defect in +its own right and belongs in this unit, since honest per-kind field reporting is the point. + +**Major — more clients than the plan named:** `gui/src/account-pool-strategy.ts`, +`gui/src/components/.../CodexPoolStrategySetting.tsx` and `gui/src/hooks/useCodexAccountPool.ts` +join `codex-auto-switch.ts`, and `cmdAutoSwitch` sends `threshold` where the OAuth route expects +`autoSwitchThreshold`. + +**Recorded:** `docs-site/src/content/docs/reference/management-api.md`:332 already claims the +pool route 400s for non-Anthropic providers, which stopped being true when the generic contract +shipped. Stale before this unit; fixed by it. + +**Minors.** The anchor `pool-settings-capability.ts`:57 points at a comment; the kinds are +:23-28 and `inert` is :63. The kind table omits `provider`/`kind` from the DTO rows. Codex and +Anthropic already share `parseAccountPoolStrategy` from `pool-kernel.ts` while the generic kind +keeps a private copy — that duplication is the smallest true instance of the problem this unit +exists to fix, and is the natural first thing to collapse. + +### Status + +Planned and audited, NOT implemented. The audit turned a one-route consolidation into a +four-part unit: write the missing exact-body guard first, collapse the duplicate validators, +add the route with all four registrations, then fix the `enabled` reporting defect. That is a +larger cycle than it looked, and the sequencing above is the deliverable of this A phase. + +### wp5 cycle scope, after the audit resized it + +The audit turned one route change into four parts. This cycle takes the two that stand alone +and are verifiable on their own; the route and the reporting fix become wp5c, because adding a +management route touches four registration surfaces and is a different kind of risk from +deduplicating a validator. + +**In this cycle** + +1. Write the missing compatibility guard: exact-body assertions for all three legacy pool + responses, green BEFORE anything is shared. This is the test the plan wrongly assumed existed. +2. Collapse the duplicate validators onto one module. Codex and Anthropic already share + `parseAccountPoolStrategy` from `pool-kernel.ts`; the generic kind keeps a private copy in + `pool-settings-capability.ts`. That is the smallest true instance of the problem this phase + exists to fix, and closing it is what makes a bad value behave identically on every kind. + +**Deferred to wp5c** + +3. `GET|PUT|PATCH /api/pool/settings` with its four registrations. +4. The `enabled: null` reporting defect, where the generic DTO ignores the top-level + `oauthAccountFailover.enabled` that actually participates in activation. + +Splitting here is not scope avoidance: part 1 is the precondition for parts 3 and 4 being +checkable at all, and shipping it separately means the guard exists in `dev` before the risky +change is written rather than alongside it. + +### Residuals from the re-audit, folded + +**The three guard targets, named exactly.** Not all four responses are unguarded. Codex +`GET /api/codex-auth/active` already pins its pool fields with a full `toEqual` +(`tests/codex-integration/codex-auth-api.test.ts`:1575). The live holes are precisely: +`PUT /api/codex-auth/auto-switch` (status-only, :3645), `PUT /api/codex-auth/pool-strategy` and +the Anthropic `PUT /api/oauth/accounts/pool` (both `toMatchObject`), and the Anthropic +`GET /api/oauth/accounts/pool` (`toMatchObject`). Those four assertions are the deliverable; +the Codex GET needs nothing. + +**The section above is superseded where it disagrees.** "## The unit" and its Acceptance list +still describe the pre-audit shape — one new route, the CLI transport collapse, and +"pre-existing tests must not be edited". The cycle scope below overrides all three: the route +and the CLI collapse move to wp5c, and writing the guard IS editing the test files, which is the +point rather than a violation. The original text stays as the record of what was planned before +the audit rather than being rewritten to look prescient. + +**Part 1 does not make part 4 checkable by itself.** The generic GET golden already pins +`enabled: null` (`tests/server/account-pool-management-api.test.ts`:483), so wp5c's reporting +fix has to change that assertion deliberately. The guard is an alias-safety net for the route +change in part 3 and only a tripwire for part 4 — it tells wp5c that it is changing a published +answer, which is exactly what a golden should do, but it does not prove the new answer correct. + +## wp5c plan — the unified route and the enabled reporting defect + +Part 3 and part 4 of the unit the wp5 audit resized. Parts 1 and 2 shipped: the exact-body +goldens for the three legacy responses, and one validator for strategy and sticky. + +### The route + +NEW `GET | PUT | PATCH /api/pool/settings?provider=` in +`src/server/management/oauth-account-routes.ts`, serving all three kinds through +`poolSettingsCapability`. The three legacy paths keep working unchanged — the goldens from +part 1 are what proves that, and they were written before any of this precisely so they could. + +**Four registration surfaces, each of which fails CI on its own.** This is the part that went +red on #4289 and is worth stating as a list rather than a sentence: + +1. `src/server/management/route-registry.ts` — `tests/server/management-route-registry.test.ts` + compares source and registry as exact pairs. +2. `src/cli/capabilities.ts` — `tests/cli/cli-capabilities.test.ts` fails on any registry route + that is neither declared, `exempt`, nor in the dated ratchet. The ratchet is NOT an option: + a sibling test asserts it only ever shrinks. +3. `skills/ocx/references/01_management_surface.md` — generated; `bun run skill:surface` must + run and the result must be committed, or `tests/ci-workflows/skill-ocx.test.ts` fails. +4. `docs-site` — `reference/management-api.md`:332 still claims the pool route 400s for + non-Anthropic providers, which stopped being true when the generic contract shipped. Stale + before this unit and fixed by it. + +Declaring the route in `capabilities.ts` rather than exempting it is the honest option only if +the CLI actually uses it, so `src/cli/account-extended.ts` switches its transport table to the +single path. That table exists today only because the two contracts disagreed. + +`PATCH` is included because both legacy writes accept it; a unified route that dropped it would +be a narrower contract wearing a wider name. + +### The enabled reporting defect + +`isProactivePreferenceEnabled` reads the per-provider `enabled` when it is a boolean and falls +back to the global `config.oauthAccountFailover.enabled`. The generic DTO reports only the +per-provider value, so `enabled: null` means "nothing stored here" while the effective answer +may be `true` from the global — a dashboard cannot tell a disabled pool from an inherited one. + +The fix ADDS `enabledEffective: boolean` rather than changing `enabled`. `enabled` is published +as "the stored provider override, `null` means unspecified, not inherited effective state" in +`docs-site/reference/cli/providers-accounts.md` and the CLI surfaces it as `poolEnabled`; +redefining it would break a documented field to fix a missing one. The generic GET golden at +`tests/server/account-pool-management-api.test.ts`:483 pins `enabled: null` and must be +extended deliberately — that is the tripwire firing exactly as intended, not a test to silence. + +### Acceptance + +- `GET /api/pool/settings?provider=` answers for Codex, Anthropic and a generic provider, each + declaring which fields its kind supports. +- The three legacy paths still return byte-identical bodies, proven by the part-1 goldens, which + are not edited. +- `enabledEffective` is true for a provider with no stored override under a global `true`, and + false under a global `false` or absence. +- Registry, capabilities, regenerated surface map and docs all move in the same commit. +- Red control: each new assertion must fail with its production branch removed. + +### wp5c plan audit — PASS-WITH-FINDINGS, folded + +**Major 1 — the acceptance contradicted itself, and the resolution is the safer one.** +Adding `enabledEffective` to `genericPoolSettingsDto` would change the LEGACY +`GET /api/oauth/accounts/pool` too, so the part-1 golden at :483 would have to move — while the +same section promised the goldens stay unedited. Resolution: the new field appears ONLY on +`/api/pool/settings`. The legacy DTO is not touched, every part-1 golden stays byte-identical +and unedited, and the reporting defect is fixed on the surface that is meant to be canonical. +Choosing the other branch would have spent the tripwire on the first cycle that met it. + +**Major 2 — the CLI switch orphans a route's coverage.** Once `account strategy` and +`account sticky` stop driving `PUT /api/codex-auth/pool-strategy`, that route has no capability +declaring it and cannot enter the ratchet, which only shrinks. It gets a registry +`exempt: { reason: "compatibility-alias" }` naming the unified route as its replacement — an +honest description of what it becomes, rather than a capability entry claiming a CLI path that +no longer exists. `GET`/`PUT /api/oauth/accounts/pool` keep their declarations because +`cmdAutoSwitch` still uses them; the transport table this cycle collapses is strategy and sticky +only. + +**Major 3 — `PATCH /api/pool/settings` needs its own answer.** The CLI only PUTs, so the PATCH +verb is declared through the same capability entry as the PUT rather than left to a ratchet that +cannot take it. + +**Major 4 — do not reuse `isProactivePreferenceEnabled` for `enabledEffective`.** It is +unexported, and it additionally requires `hasFailoverAccountQuorum` — two or more eligible +accounts. Folding a roster condition into a settings field would make the DTO answer a different +question than the one it asks: the defect is stored-versus-global CONFIG, so the field resolves +exactly that and nothing else. Confirmed by the audit that no GUI or CLI consumer already +derives effective enablement: the CLI's `poolEnabled` is stored-only and the Anthropic GUI reads +`enabled === true`. + +**Minor 6 — two more locales.** `ko` and `ru` carry the same stale "400 for non-Anthropic" pool +row as the English `reference/management-api.md`. They move with it. + +**Confirmed by the audit, no action:** `poolSettingsCapability("openai") === "codex"` is the +right discriminator; the unified GET must NOT copy the mixed pin+failover+pool DTO that +`GET /api/codex-auth/active` returns; and CORS, the Vite `/api` proxy, OpenAPI and the +management-auth enumeration are not gates for a new path. + +## wp5b plan — one GUI pool client + +The last phase. wp5c gave the server one contract; this points the dashboard at it. + +### What "two surfaces" means in the GUI + +Not two screens. Two independent client implementations of the same idea: + +| Surface | File | Talks to | Reads | +|---|---|---|---| +| Codex threshold | `gui/src/codex-auto-switch.ts` | `PUT /api/codex-auth/auto-switch` | bare `{ threshold }` | +| Codex strategy/sticky | `gui/src/account-pool-strategy.ts` | `PUT /api/codex-auth/pool-strategy` | `accountPoolStrategy`, `accountPoolStickyLimit` | +| Anthropic pool | `gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx` | `GET`/`PUT /api/oauth/accounts/pool` | `strategy`, `stickyLimit`, `quotaWindow` | + +Three fetchers, three response shapes, two prefix conventions for the same two fields. The +components on top are legitimately different — a Codex pool card is not an Anthropic pool card — +so this phase merges the CLIENT, not the presentation. Merging the rendering would be a visual +redesign nobody asked for; merging the transport is the duplication the objective names. + +### Change surface + +NEW `gui/src/pool-settings.ts` — one client for `/api/pool/settings`: +`getPoolSettings(apiBase, provider)` and `putPoolSettings(apiBase, provider, fields)`, both +returning the unified DTO with its `supported` list. The existing normalizers in +`account-pool-strategy.ts` stay where they are and are reused; this adds a transport, not a +second copy of the value rules. + +MODIFY `codex-auto-switch.ts` `putAutoSwitchThreshold` and `account-pool-strategy.ts` +`putCodexPoolStrategy` to delegate, keeping their exported signatures so no component changes +shape. The `accountPoolStrategy`/`accountPoolStickyLimit` response handling disappears with the +prefixed keys — the unified DTO is neutral for every kind. + +MODIFY `AnthropicAccountPoolSettings.tsx` to read and write through the same client. + +### The screenshot + +`enforce-target` requires a screenshot embed in the description of any PR whose title or +description mentions `gui`, waivable only by a maintainer label. So: `bun run build:gui`, start +the proxy, open the dashboard, capture the pool settings, and commit the PNG under the plan unit +so the description can embed it from the branch. A committed asset is the only route that does +not depend on a browser drag-and-drop. + +### Acceptance + +- No GUI file references `/api/codex-auth/auto-switch`, `/api/codex-auth/pool-strategy` or + `/api/oauth/accounts/pool` any more; one grep proves the consolidation rather than an + argument about it. +- `bun run lint:gui` passes and the GUI suites covering these modules pass. +- The three server routes still work — they have their own goldens and are not touched. +- The PR description embeds a real screenshot of the rendered pool settings. + +### wp5b plan audit — FAIL, folded + +**Blocker 1 — the request adapter, again.** This is the third time this exact shape has been +caught in this unit, and it is the most dangerous instance. `putAutoSwitchThreshold` sends +`{ threshold }`; the unified route reads `{ provider, autoSwitchThreshold }`. A URL swap alone +either 400s, or — with `provider` added and `threshold` left alone — returns **200 while writing +nothing**, because the route ignores an unknown field. And the function only inspects +`response.ok`, so the dashboard would report success on every save and change no setting. + +Silent success is worse than a visible failure, so the client owns an explicit request mapping: +`threshold` becomes `autoSwitchThreshold`, `provider` is always sent, and Codex is addressed as +`provider: "openai"`. The strategy body keys already match and need no mapping; only the +response did, which is what the original plan named and why the request side slipped past it. + +**Major 2 — the read path is a different route, and the plan mislabeled it.** The table called +the write bodies "Reads". The GUI actually reads the Codex threshold and strategy from +`GET /api/codex-auth/active` via `extractAutoSwitchThresholdPayload`. That read STAYS: `/active` +is a mixed pin + failover + pool payload the dashboard needs in one request, and wp5c +deliberately did not have the unified GET copy it. Stated rather than left implicit, because a +future reader would otherwise see a half-migrated client and assume it was unfinished. + +This narrows the acceptance grep: no GUI file may reference the three legacy pool WRITE +contracts. `/api/codex-auth/active` legitimately remains, and the grep says so. + +**Major 3 — four GUI test files pin the old URLs and payloads:** +`gui/tests/account-pool-strategy.test.tsx`, `anthropic-pool-quota-window.test.tsx`, +`codex-account-auto-switch.test.tsx` and `codex-auto-switch-controller.test.tsx`. They move with +the client. `CodexPoolStrategySetting` reads `result.strategy`/`stickyLimit` from the wrapper, +so it survives untouched as long as the wrapper maps the DTO; `putAutoSwitchThreshold` callers +never read the body. + +**Minor 4 recorded, not fixed:** `ProviderAuthPanel` still gates the pool card on +`item.name === "anthropic"`, so a generic OAuth provider has a contract and no UI, and the new +`supported`/`enabledEffective` fields are not yet rendered. That is a feature the objective does +not ask for; naming it is better than silently leaving a reader to wonder whether it was missed. + +**Screenshot — the gate is stricter than the plan assumed.** It fires on `gui/` PATH CHANGES, +not on a title cue, so it applies here regardless of wording. A committed PNG alone does not +satisfy it: the description must contain a rendered embed. A relative path passes the regex but +renders nothing on GitHub, so the description uses an absolute `raw.githubusercontent.com` URL +pointing at the committed file on this branch. The waiver is a maintainer COMMENT, not a label. + +### wp5b SPEC — supersedes "Change surface", "The screenshot" and "Acceptance" above + +Those three sections predate the audit and disagree with it. This is the spec. + +**Change surface.** + +NEW `gui/src/pool-settings.ts`, one client for `/api/pool/settings`: + +- `getPoolSettings(apiBase, provider)` — `GET ?provider=`, returns the unified DTO. +- `putPoolSettings(apiBase, provider, fields)` — `PUT`, and it owns an explicit REQUEST + mapping rather than forwarding whatever it is handed: + - `provider` is ALWAYS sent, and Codex is addressed as `provider: "openai"`. + - the Codex threshold field `threshold` becomes `autoSwitchThreshold`. + - `strategy` and `stickyLimit` already match and pass through unmapped. + + Without that mapping a URL swap returns 200 and writes nothing, because the route ignores an + unknown field — and the caller only inspects `response.ok`, so the dashboard would report + success on every save. That is the specific failure this mapping exists to prevent. + +MODIFY `gui/src/codex-auto-switch.ts` `putAutoSwitchThreshold` and +`gui/src/account-pool-strategy.ts` `putCodexPoolStrategy`: same exported signatures, bodies +delegating through the client, and the `accountPoolStrategy`/`accountPoolStickyLimit` response +parsing replaced by the DTO's neutral keys. + +MODIFY `gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx`: read and write +through the client. + +MOVE WITH IT — four test files pin the old URLs and payloads and are part of this change, not +collateral: `gui/tests/account-pool-strategy.test.tsx`, +`gui/tests/anthropic-pool-quota-window.test.tsx`, `gui/tests/codex-account-auto-switch.test.tsx`, +`gui/tests/codex-auto-switch-controller.test.tsx`. + +UNCHANGED ON PURPOSE — `GET /api/codex-auth/active`. The dashboard reads the Codex threshold and +strategy from that mixed pin + failover + pool payload in one request, and wp5c deliberately did +not have the unified GET copy it. This phase migrates the three pool WRITE contracts, not that +read. + +**Acceptance.** + +- `rg` over `gui/` returns no hit for `/api/codex-auth/auto-switch`, + `/api/codex-auth/pool-strategy` or `/api/oauth/accounts/pool` — the three legacy WRITE + contracts. `/api/codex-auth/active` is expected to remain and is not part of this grep. +- The four test files above assert the unified path and the mapped request body, including + `autoSwitchThreshold` rather than `threshold`. +- `bun run lint:gui` passes and the GUI suites pass. +- Red control: with the request mapping removed, the auto-switch save test must fail — the point + is that it would otherwise pass silently. + +**The screenshot.** + +The gate fires on `gui/` PATH CHANGES, not on a title cue, so it applies. A committed PNG alone +does NOT satisfy it. The description must carry a rendered embed — `![alt](url)`, +``, or a reference form — outside comments and fences. A relative path passes the +regex but renders nothing, so the PNG is committed under the plan unit and the description +embeds its absolute `raw.githubusercontent.com` URL on this branch. The only waiver is a +maintainer COMMENT, which is not something this cycle can issue for itself. + diff --git a/devlog/_plan/260911_account_pool_unification/assets/wp5b-pool-settings.png b/devlog/_plan/260911_account_pool_unification/assets/wp5b-pool-settings.png new file mode 100644 index 0000000000..f6c6be4743 Binary files /dev/null and b/devlog/_plan/260911_account_pool_unification/assets/wp5b-pool-settings.png differ 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 b8da2537bb..492548c30f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -38,8 +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 tâches liées et non liées lors de leur prochaine requête ; `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée ou relier de manière proactive une tâche liée à un compte admissible moins utilisé. `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é. | +| `autoSwitchThreshold?` | `number` | `80` | Seuil d'utilisation pour la commutation proactive. `quota` peut réévaluer les requêtes non liées lors de leur prochaine requête et, par défaut, réévalue aussi les tâches liées une fois ce seuil franchi. Avec `pool.cacheAffinity` activé, une tâche liée conserve son compte au-delà du seuil jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir. `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. | +| `pool.cacheAffinity?` | `boolean` | `false` | Ordre d'affinité de cache optionnel pour les threads Codex liés, indépendant de `pool.kernel`. Désactivé par défaut ; une valeur mal formée est lue comme désactivée. Une fois activé, une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a franchi `autoSwitchThreshold`. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, ou réellement épuisé (utilisation connue à 100 %) — l'affinité est donc un réordonnancement, pas un verrouillage. | | `accountPoolStickyLimit?` | `number` | `1` | Nombre d'affectations de tâches nouvelles ou non liées conservées sur une même sélection tournante avant de passer à la suivante ; le compteur avance lorsqu'une tâche est liée, et non après une réponse réussie en amont. Plage : 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Nombre d'échecs transitoires consécutifs avant le basculement des futures nouvelles sessions. Réglez `0` pour désactiver ce mécanisme. Pour les requêtes Responses ordinaires et les envois compacts natifs, les échecs avérés d'accessibilité DNS/TCP avant connexion sont suivis au niveau du couple fournisseur-hôte : ils n'affectent jamais l'état ni la temporisation du compte, l'affinité de tâche ou de session, la sélection du compte actif ou le routage du pool, et ne sont jamais comptabilisés dans ce seuil. | | `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. | @@ -182,8 +183,8 @@ 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é, mais `quota` peut la relier lors de sa requête suivante une fois le seuil d'utilisation -franchi ; 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, `quota` peut la relier lors de sa requête suivante une fois le seuil d'utilisation +franchi ; 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. 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 @@ -203,7 +204,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 ou la requête suivante d'une tâche liée peut être déplacée vers un compte admissible moins utilisé. `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é, et la requête suivante d'une tâche liée aussi 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. | | `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/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 002cedbec0..118ea830b9 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -36,8 +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` は紐付け済み/未紐付けタスクの次のリクエストを再評価でき、`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は、未紐付けリクエストまたは紐付け済みタスクの次のリクエストを usage の低い適格アカウントへ移せます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | +| `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価でき、既定では使用量がこのしきい値を超えると紐付け済みタスクも再評価します。`pool.cacheAffinity` がオンなら、紐付け済みタスクはアカウントが使い切られるか処理できなくなるまでしきい値超過後も同じアカウントを維持します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | +| `pool.cacheAffinity?` | `boolean` | `false` | 紐付け済み Codex スレッド向けのオプトイン cache-affinity 順序。`pool.kernel` とは独立で、既定はオフです。不正な値はオフとして読みます。オンにすると live な紐付けが quota 余裕より優先されます。`quota` は使用量が `autoSwitchThreshold` を超えたという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れるので、affinity は固定ではなく並べ替えです。 | | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 | | `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"` とアカウント修飾セレクターでは動作しません。 | @@ -162,7 +163,8 @@ Clash / Surge / Mihomo 利用者向けの fake-IP DNS 例外は 2 種類あり pool アカウントの追加と quota 更新はダッシュボードの **Codex Auth** ページで処理してください。設定には secret で ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 保管します。Pool routing は新規/未紐付け割り当て、使用量ベースのプロアクティブ切り替え、障害回復に分かれます。 -紐付け済みタスクは通常 affinity を維持しますが、`quota` はしきい値超過後の次のリクエストで再紐付けでき、 +紐付け済みタスクは通常 affinity を維持します。既定では `quota` はしきい値超過後の次のリクエストで再紐付けでき、 +`pool.cacheAffinity` がオンなら、紐付け先アカウントが使い切られるか処理できなくなるまでその再紐付けを延期します。 pause、cooldown、再認証、障害処理も独立して routing を消去または変更できます。未紐付けリクエストには プロキシ再起動や affinity リセット後の既存タスクも含まれます。出力前の **429/402** は使用量ベースの 切り替えがオフでも同じリクエストで適格な代替アカウントへ 1 回再試行できます。アカウント変更後も会話 @@ -176,7 +178,7 @@ pause、cooldown、再認証、障害処理も独立して routing を消去ま 別の適格な Pool アカウントへリクエストを切り替えることがあります。これらの障害回復は `autoSwitchThreshold: 0` でも有効であり、`0` が無効にするのは使用量に基づく予防的な切り替えだけです。 -**割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。`autoSwitchThreshold` 超過後は紐付け済みタスクの次のリクエストも再紐付けできます。`round-robin` は +**割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。`autoSwitchThreshold` 超過後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも再紐付けできます。オンなら cache affinity が quota 余裕より優先され、紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は 未紐付けリクエストを均等分散し、しきい値は通常の rotation を変えません。`accountPoolStickyLimit` (既定 `1`、1–100)は成功応答ではなく割り当て/紐付け数を数えます。`fill-first` は未紐付けリクエストを cooldown、再認証、または drain threshold までアクティブアカウントへ割り当て、正常な紐付け済みタスクは 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 7cfe363870..dfda7297e8 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -36,8 +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`는 바인딩된 작업과 바인딩 없는 작업의 다음 요청을 모두 재평가할 수 있고, `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청이나 바인딩된 작업의 다음 요청을 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | +| `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있고, 기본값에서는 사용량이 이 임계값을 넘으면 바인딩된 작업도 재평가합니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 임계값을 넘어도 계정을 유지합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | +| `pool.cacheAffinity?` | `boolean` | `false` | 바인딩된 Codex 스레드의 선택적 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 꺼짐입니다. 잘못된 값은 꺼진 것으로 읽습니다. 켜면 live 바인딩이 quota 여유보다 우선합니다. `quota`는 사용량이 `autoSwitchThreshold`를 넘었다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 여전히 떠나므로, affinity는 고정이 아니라 재정렬입니다. | | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. | | `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 +163,10 @@ Clash / Surge / Mihomo 사용자를 위한 fake-IP DNS 예외는 두 가지이 pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지에서 처리하세요. 설정에는 secret이 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 보관합니다. Pool 라우팅은 새 작업/바인딩 없는 작업 배정, 사용량 기반 선제 전환, 실패 복구로 -구분됩니다. 바인딩된 작업은 보통 affinity를 유지하지만 `quota`는 사용량 임계값을 넘은 뒤 다음 -요청에서 재바인딩할 수 있고, 일시 중지, cooldown, 재인증, 실패 처리도 독립적으로 라우팅을 -지우거나 바꿀 수 있습니다. 바인딩 없는 요청은 live 계정 바인딩이 없는 요청이며, 프록시 재시작이나 +구분됩니다. 바인딩된 작업은 보통 affinity를 유지합니다. 기본값에서 `quota`는 사용량 임계값을 넘은 뒤 +다음 요청에서 재바인딩할 수 있고, `pool.cacheAffinity`가 켜져 있으면 바인딩된 계정이 소진되었거나 +더 이상 처리할 수 없을 때까지 그 재바인딩을 미룹니다. 일시 중지, cooldown, 재인증, 실패 처리도 +독립적으로 라우팅을 지우거나 바꿀 수 있습니다. 바인딩 없는 요청은 live 계정 바인딩이 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 전 **429/402**는 사용량 기반 선제 전환이 꺼져 있어도 같은 요청에서 적격 대체 계정으로 한 번 재시도할 수 있습니다. 계정이 바뀌어도 대화 문맥은 보존·재생되지만 계정 간 프로바이더 측 prompt cache 재사용은 보장되지 않아 다시 @@ -179,7 +181,7 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 `autoSwitchThreshold: 0`에서도 계속 작동하며, `0`은 사용량 기반 선제 전환만 비활성화합니다. **배정 및 선제 전환 전략:** `quota`(기본)는 활성 계정이 없을 때 최저 usage의 적격 계정을 선택하고, -적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청이나 바인딩된 작업의 다음 요청을 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. +적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. 플래그가 켜져 있으면 cache affinity가 quota 여유보다 우선하며, 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하며 임계값은 기본 순환에 영향을 주지 않습니다. `accountPoolStickyLimit`(기본 `1`, 1–100)은 성공 응답이 아니라 배정/바인딩 횟수를 셉니다. `fill-first`는 바인딩 없는 요청을 cooldown, 재인증 또는 drain threshold까지 활성 계정에 배정하고, diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index 8f6eee14de..1b1491da1e 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -173,7 +173,8 @@ Authorization: Bearer | `POST /api/oauth/logout` | 선택된 provider 자격 증명을 제거합니다 | 400 알 수 없는 provider; `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | 마스킹된 계정을 나열하거나 계정 하나를 제거합니다 | 400 잘못된 provider/id; 404 계정 없음; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | 활성 OAuth 계정을 선택합니다 | 400 잘못된 provider/account; `oauth_mutation_busy` | -| `GET, PUT, PATCH /api/oauth/accounts/pool` | Anthropic OAuth pool policy를 읽거나 업데이트합니다 | 400 Anthropic이 아닌 provider 또는 잘못된 policy | +| `GET, PUT, PATCH /api/pool/settings` | 모든 pool 종류(codex, anthropic, generic)의 policy를 읽거나 업데이트합니다. 세 종류 모두 같은 키로 응답하고, 해당 종류가 실제로 적용하는 필드는 `supported`에 나옵니다 | 400 알 수 없는 provider, 해당 종류가 지원하지 않는 필드, 잘못된 값 | +| `GET, PUT, PATCH /api/oauth/accounts/pool` | Anthropic과 일반 OAuth provider의 기존 pool policy입니다. `/api/pool/settings`로 대체되었고 기존 클라이언트를 위해 유지합니다 | 400 codex 또는 API 키 provider, 잘못된 policy | | `POST /api/oauth/accounts/clear-cooldown` | OAuth 계정 하나의 런타임 cooldown을 지웁니다 | 400 잘못된 provider/account | | `PUT /api/oauth/accounts/alias` | OAuth 계정 alias를 설정하거나 지웁니다 | 400 잘못된 provider/account/alias | | `GET, POST, DELETE /api/providers/keys` | 마스킹된 provider key를 나열, 추가/활성화, 또는 제거합니다 | 400 잘못된 입력; 404 provider/key 없음 | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 39b5bc7dfa..ae274c0b08 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -51,8 +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 both bound and unbound tasks on their next request; `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or proactively rebind a bound task to a lower-usage eligible account. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | +| `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate unbound tasks on their next request, and by default also re-evaluates bound tasks once usage crosses this threshold. With `pool.cacheAffinity` on, a bound task keeps its account past the threshold until that account is exhausted or otherwise cannot serve. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | +| `pool.cacheAffinity?` | `boolean` | `false` | Opt-in cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. Off by default; a malformed value reads as off. With it on, a live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — so affinity is a reordering, not a pin. | | `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. | | `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. | @@ -153,6 +154,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](#storing-keys-in-the-os-keychain). | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | +| `apiKeyPoolStrategy?` | `"round-robin" \| "fill-first" \| "quota"` | How a warm key is chosen **before** the first attempt when the committed key is already cooling. Omitted keeps rotation reactive-only: the pool moves after a 429 or 401 and not before. `round-robin` takes the next key in the pool, `fill-first` keeps the first eligible one, and `quota` prefers the key with the most remaining headroom, falling back to `fill-first` order for a provider whose per-key quota is unknown. A healthy committed key is never overridden, so a manual key selection stands. | | `defaultModel?` | `string` | Model used when this provider is selected without an explicit model. | | `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, a nonempty `models` list is followed by `retainModels`; an empty or omitted `models` list instead seeds `defaultModel` (if configured), then `retainModels`, removing duplicate ids in first-seen order. | | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | @@ -475,9 +477,10 @@ 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, but `quota` may rebind it on its next request after the usage threshold is -crossed, while 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 `quota` may rebind it on its next request after the usage +threshold is crossed; 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 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 @@ -498,7 +501,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 or a bound task's next request can move to a lower-usage eligible account. `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, and a bound task's next request can too 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. | | `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/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 495322fab6..a90c7a7996 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -331,7 +331,8 @@ outcome fields from an older server do not establish successful recovery. | `POST /api/oauth/logout` | Remove the selected provider credential | 400 unknown provider; `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | List masked accounts or remove one account | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | Select the active OAuth account | 400 invalid provider/account; `oauth_mutation_busy` | -| `GET, PUT, PATCH /api/oauth/accounts/pool` | Read or update Anthropic OAuth pool policy | 400 non-Anthropic provider or invalid policy | +| `GET, PUT, PATCH /api/pool/settings` | Read or update pool policy for any kind (codex, anthropic, generic); answers with the same keys for all three and declares in `supported` which the kind honours | 400 unknown provider, a field the kind does not support, or an invalid value | +| `GET, PUT, PATCH /api/oauth/accounts/pool` | Legacy per-pool policy for Anthropic and generic OAuth providers; superseded by `/api/pool/settings` and kept for existing clients | 400 codex or api-key provider, or invalid policy | | `POST /api/oauth/accounts/clear-cooldown` | Clear one OAuth account's runtime cooldown | 400 invalid provider/account | | `PUT /api/oauth/accounts/alias` | Set or clear an OAuth account alias | 400 invalid provider/account/alias | | `GET, POST, DELETE /api/providers/keys` | List masked provider keys, add/activate one, or remove one | 400 invalid input; 404 provider/key missing | 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 5966758e29..6c76dd556b 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -37,8 +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` может повторно оценить следующий запрос как привязанной, так и непривязанной задачи; `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос или следующий запрос привязанной задачи на подходящий аккаунт с меньшим usage. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | +| `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос, а по умолчанию — и привязанную задачу, когда usage пересекает этот порог. При включённом `pool.cacheAffinity` привязанная задача сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | +| `pool.cacheAffinity?` | `boolean` | `false` | Опциональный порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию выключен; некорректное значение читается как выключенное. Когда флаг включён, живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage пересёк `autoSwitchThreshold`. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден или реально исчерпан (известный usage 100%). Affinity меняет порядок, а не закрепляет учётные данные. | | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. | | `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"` и для селекторов с указанием аккаунта схема не активна. | @@ -191,8 +192,9 @@ redirect'ов для обычных provider-request'ов реализована Конфигурация хранит только несекретные метаданные аккаунтов; access- и refresh-токены хранятся в защищённом хранилище учётных данных аккаунтов Codex. Pool routing разделяет назначение новых/непривязанных задач, проактивное переключение по использованию и восстановление после сбоев. -Привязанная задача обычно сохраняет affinity, но `quota` может перепривязать её при следующем -запросе после превышения порога; pause, cooldown, повторная аутентификация и обработка сбоев также +Привязанная задача обычно сохраняет affinity. По умолчанию `quota` может перепривязать её при следующем +запросе после превышения порога; при включённом `pool.cacheAffinity` эта перепривязка ждёт, пока +привязанный аккаунт не будет исчерпан или не сможет обслуживать запрос. Pause, cooldown, повторная аутентификация и обработка сбоев также могут независимо очистить или изменить routing. Непривязанным может стать и существующая задача после перезапуска прокси или сброса affinity. Отказ **429/402** до вывода допускает одну попытку на подходящем альтернативном аккаунте даже при выключенном переключении по использованию. @@ -209,7 +211,7 @@ redirect'ов для обычных provider-request'ов реализована после чего запрос может перейти на другой подходящий аккаунт Pool. Эти переходы восстановления остаются активными при `autoSwitchThreshold: 0`; значение `0` отключает только проактивное переключение по использованию. -**Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос или следующий запрос привязанной задачи на подходящий аккаунт с меньшим usage. `round-robin` равномерно распределяет непривязанные запросы, а порог не +**Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, cache affinity важнее запаса квоты, и привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы, а порог не меняет обычную ротацию. `accountPoolStickyLimit` (по умолчанию `1`, 1–100) считает назначения/bind, а не успешные ответы. `fill-first` назначает непривязанные запросы активному аккаунту до cooldown, reauth или порога исчерпания; здоровые привязанные задачи сохраняют affinity. Эти стратегии не diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 70324c97a7..60543058d9 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -192,7 +192,8 @@ Endpoint'ы storage cleanup могут перемещать или навсег | `POST /api/oauth/logout` | Удалить сохранённый credential выбранного провайдера | 400 unknown provider; `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | Показать список masked-аккаунтов или удалить один аккаунт | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | Выбрать активный OAuth-аккаунт | 400 invalid provider/account; `oauth_mutation_busy` | -| `GET, PUT, PATCH /api/oauth/accounts/pool` | Прочитать или обновить policy Anthropic OAuth pool | 400 non-Anthropic provider or invalid policy | +| `GET, PUT, PATCH /api/pool/settings` | Прочитать или обновить policy пула любого вида (codex, anthropic, generic); все три отвечают одинаковыми ключами, а поля, которые вид действительно применяет, перечислены в `supported` | 400 неизвестный provider, поле, которое вид не поддерживает, или недопустимое значение | +| `GET, PUT, PATCH /api/oauth/accounts/pool` | Прежняя policy пула для Anthropic и обычных OAuth-провайдеров; заменена на `/api/pool/settings` и сохранена для существующих клиентов | 400 codex или api-key provider, либо недопустимая policy | | `POST /api/oauth/accounts/clear-cooldown` | Очистить runtime cooldown одного OAuth-аккаунта | 400 invalid provider/account | | `PUT /api/oauth/accounts/alias` | Задать или очистить alias OAuth-аккаунта | 400 invalid provider/account/alias | | `GET, POST, DELETE /api/providers/keys` | Показать список masked provider-key'ов, добавить/активировать один или удалить один | 400 invalid input; 404 provider/key missing | 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 7b0c14cfcc..9fce4f0ba9 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -38,8 +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`, bir sonraki isteklerinde hem bağlı hem de bağımsız görevleri yeniden değerlendirebilir; `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir veya bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. `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. | +| `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için kullanım eşiği. `quota`, bağımsız görevlerin bir sonraki isteğini yeniden değerlendirebilir ve varsayılan olarak kullanım bu eşiği geçince bağlı görevleri de yeniden değerlendirir. `pool.cacheAffinity` açıkken bağlı bir görev, hesap tükenene veya hizmet veremez hale gelene kadar eşiğin ötesinde hesabını korur. `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. | +| `pool.cacheAffinity?` | `boolean` | `false` | Bağlı Codex iş parçacıkları için isteğe bağlı önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak kapalıdır; hatalı bir değer kapalı okunur. Açıkken canlı bağlama kota payından öndedir: `quota`, kullanımın `autoSwitchThreshold`'u geçmesi nedeniyle iş parçacığını taşımaz. Hesap duraklatılmış, kullanılamaz veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır; bağlılık bir sabitleme değil yeniden sıralamadır. | | `accountPoolStickyLimit?` | `number` | `1` | İlerlemeden önce bir round-robin seçiminde tutulan yeni/bağımsız görev atamaları; sayaç yukarı akış başarısından sonra değil, bir görev bağlandığında ilerler. Aralık 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Gelecekteki yeni oturumların yük devretmesinden önceki ardışık geçici arızalar. Devre dışı bırakmak için `0` ayarlayın. Düzenli Responses ve yerel sıkıştırma gönderimleri için kanıtlanmış bağlantı öncesi DNS/TCP erişilebilirlik arızaları sağlayıcı-ana bilgisayar düzeyinde izlenir: hesap sağlığını, hesap soğuma sürelerini, iş parçacığı/oturum bağlılığını, aktif hesap seçimini veya Havuz yönlendirmesini asla etkilemez ve bu eşiğe asla sayılmaz. | | `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. | @@ -196,10 +197,12 @@ Havuz hesapları eklemek ve kotaları yenilemek için kontrol panelinde **Codex 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, ancak `quota`, -kullanım eşiği aşıldıktan sonraki bir sonraki isteğinde onu yeniden -bağlayabilir; 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 +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 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 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 @@ -227,7 +230,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 veya bağlı bir görevin bir sonraki isteği daha düşük kullanımlı uygun bir hesaba geçebilir. `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 ve `pool.cacheAffinity` kapalıysa bağlı bir görevin bir sonraki isteği de 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. | | `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/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 01f499e454..2e287e14e4 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,8 +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` 可在下一次请求中重新评估已绑定和未绑定任务;`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求或已绑定任务的下一次请求切换到 usage 更低的合格账号。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | +| `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务;默认在用量越过该阈值时也会重新评估已绑定任务。开启 `pool.cacheAffinity` 后,已绑定任务在越过阈值后仍会保留账号,直到该账号耗尽或无法继续服务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | +| `pool.cacheAffinity?` | `boolean` | `false` | 已绑定 Codex 线程的可选 cache-affinity 排序,独立于 `pool.kernel`。默认关闭;非法值视为关闭。开启后,live 绑定优先于 quota 余量:`quota` 不会仅因用量越过 `autoSwitchThreshold` 就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,因此 affinity 是重排而非钉死。 | | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 | | `upstreamHostCircuitThreshold?` | `number` | `0` | 原生 OpenAI forward Responses 与 compact 发送的可选断路器阈值,仅统计已证明的连接前 DNS/TCP 故障。`0` 表示禁用;`1`–`20` 表示在这么多个终止逻辑请求失败后,对 provider-origin 冷却 30 秒。断路期间会在账户选择和上游发送之前返回带 `Retry-After` 的 `503`;冷却结束后只允许一个半开请求。超时和 HTTP 响应不计数,任意 HTTP 响应都会关闭断路器。 仅适用于未固定账户的 Codex Pool 路由;在 `codexAccountMode: "direct"` 或使用账户限定选择器时不会启用。 | @@ -161,8 +162,9 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account metadata;access/refresh token 存放在加固的 Codex account credential store 中。Pool routing -分为新建/未绑定任务分配、基于用量的主动切换和故障恢复。已绑定任务通常保持 affinity,但 `quota` -可在超过阈值后的下一次请求中重新绑定;暂停、cooldown、重新认证和故障处理也能独立清除或改变 +分为新建/未绑定任务分配、基于用量的主动切换和故障恢复。已绑定任务通常保持 affinity。默认情况下 +`quota` 可在超过阈值后的下一次请求中重新绑定;开启 `pool.cacheAffinity` 后,该重新绑定会等到 +绑定账号耗尽或无法继续服务。暂停、cooldown、重新认证和故障处理也能独立清除或改变 routing。未绑定请求没有 live 账号绑定,也可能是代理重启或 affinity 重置后的已有任务。输出前的 **429/402** 即使在关闭基于用量的主动切换时,也可在同一请求中对合格替代账号重试一次。 账号变化后会保留并重放对话上下文,但账号间的 provider prompt cache 不保证复用,可能需要重新预热。 @@ -175,7 +177,7 @@ routing。未绑定请求没有 live 账号绑定,也可能是代理重启或 并可将请求切换到另一个符合条件的 Pool 账户。即使 `autoSwitchThreshold: 0`, 这些故障恢复流程仍然有效;`0` 只会禁用基于用量的主动切换。 -**分配与主动切换策略:** `quota`(默认)在没有活跃账号时选择 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求或已绑定任务的下一次请求切换到 usage 更低的合格账号。`round-robin` 均匀分配未绑定请求,用量 +**分配与主动切换策略:** `quota`(默认)在没有活跃账号时选择 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,cache affinity 优先于 quota 余量,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求,用量 阈值不会改变正常轮换。`accountPoolStickyLimit`(默认 `1`,1–100)统计分配/绑定,而不是成功响应。 `fill-first` 在 cooldown、重新认证或耗尽阈值前把未绑定请求分配给活跃账号;健康的已绑定任务保持 affinity。这些策略不能规避 provider enforcement。 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 b47d92eb48..4303ca73aa 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,8 +34,9 @@ ocx models provider openrouter on | `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 | | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | -| `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在其下一個請求時重新評估綁定與未綁定任務;`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求或主動重新綁定綁定任務到較低用量的合格帳號。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 | +| `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務,且預設在用量越過此閾值時也會重新評估綁定任務。開啟 `pool.cacheAffinity` 後,綁定任務在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 | +| `pool.cacheAffinity?` | `boolean` | `false` | 綁定 Codex 執行緒的選擇性 cache-affinity 排序,獨立於 `pool.kernel`。預設關閉;格式錯誤視為關閉。開啟後,即時綁定優先於配額餘裕:`quota` 不會只因用量越過 `autoSwitchThreshold` 就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,因此親和性是重排而非釘死。 | | `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 | | `upstreamFailoverThreshold?` | `number` | `3` | 未來新 session 容錯移轉前的連續暫時性失敗。設 `0` 停用。 | | `modelCacheTtlMs?` | `number` | `300000` | Per-供應商 `/models` 快取的新鮮度視窗。 | @@ -129,7 +130,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 ## Codex 帳號池 -在儀表板中使用 **Codex Auth** 新增池帳號並重新整理配額。`config.json` 儲存非秘密中繼資料;access 與 refresh token 使用強化的憑證存放。池路由將新/未綁定指派、基於用量的主動切換與失敗復原分開。綁定任務通常保留親和性,但 `quota` 可在其超過用量閾值後的下一個請求時重新綁定它,而暫停、冷卻、重新認證與失敗處理可獨立清除或移動路由。未綁定請求沒有即時帳號綁定;這可包含代理重啟或親和性重置後的既有可見任務。Pre-stream 的 429 或 402 在同一個請求中於一個合格的備用帳號上重試一次,即使基於用量的主動切換關閉。帳號變更保留並重播對話 context,但跨帳號的供應商端 prompt-cache 重用不保證,cache 可能需要重新暖機。 +在儀表板中使用 **Codex Auth** 新增池帳號並重新整理配額。`config.json` 儲存非秘密中繼資料;access 與 refresh token 使用強化的憑證存放。池路由將新/未綁定指派、基於用量的主動切換與失敗復原分開。綁定任務通常保留親和性。預設下 `quota` 可在超過用量閾值後的下一個請求時重新綁定它;開啟 `pool.cacheAffinity` 後,該重新綁定會等到綁定帳號耗盡或無法繼續服務。暫停、冷卻、重新認證與失敗處理可獨立清除或移動路由。未綁定請求沒有即時帳號綁定;這可包含代理重啟或親和性重置後的既有可見任務。Pre-stream 的 429 或 402 在同一個請求中於一個合格的備用帳號上重試一次,即使基於用量的主動切換關閉。帳號變更保留並重播對話 context,但跨帳號的供應商端 prompt-cache 重用不保證,cache 可能需要重新暖機。 在 **401/403** 時,App 登入清除該帳號的行程本地親和性並要求重新認證。 在 **429** 時,opencodex 遵循 `Retry-After`、啟動帳號冷卻、清除親和性,並可能將請求輪換到另一個合格的池帳號。這些失敗轉換在 `autoSwitchThreshold: 0` 時仍然活躍;該設定僅停用基於用量的主動切換。 @@ -138,7 +139,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | 策略 | 行為 | | --- | --- | -| `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則將合格現用帳號保持在 `autoSwitchThreshold` 以下;在超過閾值後,未綁定請求或綁定任務的下一個請求可移至較低用量的合格帳號。`0` 停用此用量驅動的重新評估,而非失敗復原。 | +| `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則將合格現用帳號保持在 `autoSwitchThreshold` 以下;在超過閾值後,未綁定請求可移至較低用量的合格帳號,未開啟 `pool.cacheAffinity` 時綁定任務的下一個請求也可。開啟後,cache affinity 優先於配額餘裕,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`0` 停用此用量驅動的重新評估,而非失敗復原。 | | `round-robin` | 在合格帳號間均勻指派未綁定請求。`autoSwitchThreshold` 不變更一般 round-robin 選擇。`accountPoolStickyLimit`(1–100)計數一次選擇上的指派,而非成功的上游回應。 | | `fill-first` | 將未綁定請求指派到現用帳號直到冷卻、重新認證或設定的排空閾值;未知用量不強制切換。健康的綁定任務保留親和性。 | diff --git a/gui/src/account-pool-strategy.ts b/gui/src/account-pool-strategy.ts index 4dbc7b9e2b..b2532b0fc7 100644 --- a/gui/src/account-pool-strategy.ts +++ b/gui/src/account-pool-strategy.ts @@ -52,35 +52,3 @@ export function parseAccountPoolStickyLimitDraft(value: string): number | null { const n = Number(trimmed); return n >= MIN_ACCOUNT_POOL_STICKY_LIMIT && n <= MAX_ACCOUNT_POOL_STICKY_LIMIT ? n : null; } - -export type PoolStrategyFetch = (input: string, init: RequestInit) => Promise; - -export async function putCodexPoolStrategy( - apiBase: string, - body: { strategy?: AccountPoolStrategy; stickyLimit?: number }, - fetchImpl: PoolStrategyFetch = (input, init) => fetch(input, init), -): Promise<{ ok: true; strategy: AccountPoolStrategy; stickyLimit: number } | { ok: false }> { - if (body.strategy === undefined && body.stickyLimit === undefined) return { ok: false }; - try { - const response = await fetchImpl(`${apiBase}/api/codex-auth/pool-strategy`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...(body.strategy !== undefined ? { strategy: body.strategy } : {}), - ...(body.stickyLimit !== undefined ? { stickyLimit: body.stickyLimit } : {}), - }), - }); - if (!response.ok) return { ok: false }; - const json = await response.json() as { - accountPoolStrategy?: unknown; - accountPoolStickyLimit?: unknown; - }; - return { - ok: true, - strategy: normalizeAccountPoolStrategy(json.accountPoolStrategy ?? body.strategy), - stickyLimit: normalizeAccountPoolStickyLimit(json.accountPoolStickyLimit ?? body.stickyLimit), - }; - } catch { - return { ok: false }; - } -} diff --git a/gui/src/codex-auto-switch.ts b/gui/src/codex-auto-switch.ts index ed7d7168da..eac76dbbfc 100644 --- a/gui/src/codex-auto-switch.ts +++ b/gui/src/codex-auto-switch.ts @@ -1,5 +1,7 @@ export const DEFAULT_AUTO_SWITCH_THRESHOLD = 80; +import { CODEX_POOL_PROVIDER, putPoolSettings } from "./pool-settings"; + const AUTO_SWITCH_PUT_TIMEOUT_MS = 10_000; export type AutoSwitchFetch = (input: string, init: RequestInit) => Promise; @@ -78,15 +80,15 @@ export async function putAutoSwitchThreshold( timeoutMs = AUTO_SWITCH_PUT_TIMEOUT_MS, ): Promise { if (!Number.isInteger(threshold) || threshold < 0 || threshold > 100) return false; - try { - const response = await fetchImpl(`${apiBase}/api/codex-auth/auto-switch`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ threshold }), - signal: AbortSignal.timeout(timeoutMs), - }); - return response.ok; - } catch { - return false; - } + // Through the shared client, which maps `threshold` onto the contract's + // `autoSwitchThreshold` and sends the provider. This function reports only ok/not-ok, so a + // body the route silently ignored would read here as a successful save that changed nothing. + const settings = await putPoolSettings( + apiBase, + CODEX_POOL_PROVIDER, + { threshold }, + (input, init) => fetchImpl(input, init as RequestInit), + { signal: AbortSignal.timeout(timeoutMs) }, + ); + return settings !== null; } diff --git a/gui/src/components/CodexPoolStrategySetting.tsx b/gui/src/components/CodexPoolStrategySetting.tsx index b0acdab3f4..e575baed0c 100644 --- a/gui/src/components/CodexPoolStrategySetting.tsx +++ b/gui/src/components/CodexPoolStrategySetting.tsx @@ -1,3 +1,4 @@ +import { putCodexPoolStrategy } from "../pool-settings"; import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "../i18n/shared"; import { @@ -6,7 +7,7 @@ import { normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimitDraft, - putCodexPoolStrategy, + type AccountPoolStrategy, } from "../account-pool-strategy"; import AccountPoolStrategyControls from "./AccountPoolStrategyControls"; diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx index 4d7c66b970..84120b54c4 100644 --- a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -4,6 +4,7 @@ */ import { useCallback, useEffect, useState } from "react"; import { useT } from "../../i18n/shared"; +import { getPoolSettings, putPoolSettings } from "../../pool-settings"; import { ACCOUNT_POOL_QUOTA_WINDOWS, DEFAULT_ACCOUNT_POOL_QUOTA_WINDOW, @@ -60,16 +61,11 @@ export default function AnthropicAccountPoolSettings({ // mount-then-unmount dropped the request entirely. The abort controller already covers // in-flight cancellation, which is the part that actually needs to be cancellable. void Promise.resolve() - .then(() => fetch(`${apiBase}/api/oauth/accounts/pool?provider=anthropic`, { signal: ac.signal })) - .then(res => { - if (!res.ok) throw new Error("load"); - return res.json() as Promise<{ - enabled?: boolean; - autoSwitchThreshold?: number; - strategy?: unknown; - stickyLimit?: unknown; - quotaWindow?: unknown; - }>; + // Through the shared pool client, which speaks the one contract every kind answers on. + .then(() => getPoolSettings(apiBase, "anthropic", (input, init) => fetch(input, init), { signal: ac.signal })) + .then(settings => { + if (!settings) throw new Error("load"); + return settings; }) .then(json => { if (cancelled) return; @@ -114,24 +110,16 @@ export default function AnthropicAccountPoolSettings({ setSaving(true); setError(null); try { - const res = await fetch(`${apiBase}/api/oauth/accounts/pool`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - provider: "anthropic", - enabled: next.enabled, - autoSwitchThreshold: next.threshold, - strategy: next.strategy, - stickyLimit: next.stickyLimit, - quotaWindow: next.quotaWindow, - }), + // The client owns the field mapping: `threshold` becomes `autoSwitchThreshold` and the + // provider is always sent, so no call site can forget either. + const json = await putPoolSettings(apiBase, "anthropic", { + enabled: next.enabled, + threshold: next.threshold, + strategy: next.strategy, + stickyLimit: next.stickyLimit, + quotaWindow: next.quotaWindow, }); - if (!res.ok) throw new Error("save"); - const json = await res.json().catch(() => null) as { - strategy?: unknown; - stickyLimit?: unknown; - quotaWindow?: unknown; - } | null; + if (!json) throw new Error("save"); const savedStrategy = normalizeAccountPoolStrategy(json?.strategy ?? next.strategy); const savedSticky = normalizeAccountPoolStickyLimit(json?.stickyLimit ?? next.stickyLimit); const savedWindow = normalizeAccountPoolQuotaWindow(json?.quotaWindow ?? next.quotaWindow); diff --git a/gui/src/pool-settings.ts b/gui/src/pool-settings.ts new file mode 100644 index 0000000000..dff50736a8 --- /dev/null +++ b/gui/src/pool-settings.ts @@ -0,0 +1,148 @@ +/** + * One GUI client for the unified pool-settings contract. + * + * Before this, three fetchers spoke three shapes for the same two fields: the Codex threshold + * write, the Codex strategy write with `accountPool`-prefixed response keys, and the Anthropic + * pool read/write. `/api/pool/settings` answers identically for every kind, so the transport + * collapses to this module and the components above keep their own presentation. + */ +import { + normalizeAccountPoolQuotaWindow, + normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, + type AccountPoolQuotaWindow, + type AccountPoolStrategy, +} from "./account-pool-strategy"; + +/** The Codex pool is addressed by its provider id like any other kind. */ +export const CODEX_POOL_PROVIDER = "openai"; + +export type PoolSettingsFetch = (input: string, init?: RequestInit) => Promise; + +export interface PoolSettings { + provider: string; + kind: "codex" | "anthropic" | "generic"; + supported: string[]; + enabled: boolean | null; + enabledEffective: boolean; + strategy: AccountPoolStrategy; + stickyLimit: number; + autoSwitchThreshold: number | null; + quotaWindow: AccountPoolQuotaWindow | null; +} + +/** Fields a caller may write. Named in GUI terms; mapped to the wire below. */ +export interface PoolSettingsWrite { + enabled?: boolean; + strategy?: AccountPoolStrategy; + stickyLimit?: number; + /** GUI callers say "threshold"; the contract says autoSwitchThreshold. */ + threshold?: number; + quotaWindow?: AccountPoolQuotaWindow; +} + +function toDto(json: unknown, provider: string, fallback?: PoolSettingsWrite): PoolSettings { + const raw = (json ?? {}) as Record; + const threshold = raw.autoSwitchThreshold ?? fallback?.threshold; + return { + provider, + kind: raw.kind === "codex" || raw.kind === "anthropic" ? raw.kind : "generic", + supported: Array.isArray(raw.supported) ? raw.supported.filter((f): f is string => typeof f === "string") : [], + enabled: typeof raw.enabled === "boolean" ? raw.enabled : null, + enabledEffective: raw.enabledEffective === true, + // Fall back to what was asked for when the response omits a field. A management write may + // answer 204, and reporting the normalizer default there would silently show the operator + // a different value than the one they just saved. + strategy: normalizeAccountPoolStrategy(raw.strategy ?? fallback?.strategy), + stickyLimit: normalizeAccountPoolStickyLimit(raw.stickyLimit ?? fallback?.stickyLimit), + autoSwitchThreshold: typeof threshold === "number" ? threshold : null, + quotaWindow: (raw.quotaWindow ?? fallback?.quotaWindow) === undefined || raw.quotaWindow === null + ? null + : normalizeAccountPoolQuotaWindow(raw.quotaWindow ?? fallback?.quotaWindow), + }; +} + +/** + * Map GUI field names onto the wire, and ALWAYS send `provider`. + * + * This is not ceremony. The route ignores a field it does not know, so a body that still said + * `threshold` would return 200 and write nothing -- and `putAutoSwitchThreshold` only inspects + * `response.ok`, so every save would report success while changing no setting. Silent success + * is worse than a visible failure, which is why the mapping lives here rather than at each + * call site where one of three could forget it. + */ +export function poolSettingsRequestBody(provider: string, fields: PoolSettingsWrite): Record { + return { + provider, + ...(fields.enabled !== undefined ? { enabled: fields.enabled } : {}), + ...(fields.strategy !== undefined ? { strategy: fields.strategy } : {}), + ...(fields.stickyLimit !== undefined ? { stickyLimit: fields.stickyLimit } : {}), + ...(fields.threshold !== undefined ? { autoSwitchThreshold: fields.threshold } : {}), + ...(fields.quotaWindow !== undefined ? { quotaWindow: fields.quotaWindow } : {}), + }; +} + +export async function getPoolSettings( + apiBase: string, + provider: string, + fetchImpl: PoolSettingsFetch = (input, init) => fetch(input, init), + init?: RequestInit, +): Promise { + try { + const response = await fetchImpl(`${apiBase}/api/pool/settings?provider=${encodeURIComponent(provider)}`, init); + if (!response.ok) return null; + // No empty-body tolerance on the READ. `toDto` fills defaults, so `{}` would render as a + // disabled pool with default values and the panel would treat that as a loaded state -- + // letting the next save overwrite the real configuration from fabricated input. A read + // with no parseable body is a failed read. The write below is the opposite case: there, + // an empty 2xx is a real success and the fallback is the settings just sent. + return toDto(await response.json(), provider); + } catch { + return null; + } +} + +export async function putPoolSettings( + apiBase: string, + provider: string, + fields: PoolSettingsWrite, + fetchImpl: PoolSettingsFetch = (input, init) => fetch(input, init), + init?: RequestInit, +): Promise { + try { + const response = await fetchImpl(`${apiBase}/api/pool/settings`, { + ...init, + method: "PUT", + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + body: JSON.stringify(poolSettingsRequestBody(provider, fields)), + }); + if (!response.ok) return null; + // A 2xx with no parseable body is still a successful write; the old per-route clients + // only inspected response.ok and a management PUT may answer 204. + return toDto(await response.json().catch(() => ({})), provider, fields); + } catch { + return null; + } +} + +/** + * Codex strategy/sticky write, kept as a named helper because three call sites use it. + * + * It lives HERE rather than in `account-pool-strategy.ts` for a structural reason: that module + * owns the value normalizers this one imports, so putting the transport there too would make the + * two modules import each other. The first draft papered over that with a dynamic import and the + * bundler called it out as ineffective, which was the cycle telling on itself. + */ +export async function putCodexPoolStrategy( + apiBase: string, + body: { strategy?: AccountPoolStrategy; stickyLimit?: number }, + fetchImpl: PoolSettingsFetch = (input, init) => fetch(input, init), +): Promise<{ ok: true; strategy: AccountPoolStrategy; stickyLimit: number } | { ok: false }> { + if (body.strategy === undefined && body.stickyLimit === undefined) return { ok: false }; + const settings = await putPoolSettings(apiBase, CODEX_POOL_PROVIDER, { + strategy: body.strategy, + stickyLimit: body.stickyLimit, + }, fetchImpl); + if (!settings) return { ok: false }; + return { ok: true, strategy: settings.strategy, stickyLimit: settings.stickyLimit }; +} diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx index 856c44af8c..5f98969f99 100644 --- a/gui/tests/account-pool-strategy.test.tsx +++ b/gui/tests/account-pool-strategy.test.tsx @@ -1,3 +1,4 @@ +import { putCodexPoolStrategy } from "../src/pool-settings"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; @@ -9,7 +10,7 @@ import { normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimitDraft, - putCodexPoolStrategy, + } from "../src/account-pool-strategy"; import AccountPoolStrategyControls from "../src/components/AccountPoolStrategyControls"; import CodexPoolStrategySetting from "../src/components/CodexPoolStrategySetting"; @@ -133,9 +134,11 @@ describe("account pool strategy helpers", () => { ); expect(result).toEqual({ ok: true, strategy: "round-robin", stickyLimit: 3 }); expect(calls).toHaveLength(1); - expect(calls[0]!.url).toBe("http://proxy/api/codex-auth/pool-strategy"); + expect(calls[0]!.url).toBe("http://proxy/api/pool/settings"); expect(calls[0]!.init.method).toBe("PUT"); expect(JSON.parse(String(calls[0]!.init.body))).toEqual({ + // The Codex pool is addressed by provider id like every other kind now. + provider: "openai", strategy: "round-robin", stickyLimit: 3, }); @@ -281,12 +284,12 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { if (url.endsWith("/api/codex-auth/active") && (!init || init.method === undefined)) { return active.promise; } - if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") { + if (url.endsWith("/api/pool/settings") && init?.method === "PUT") { puts.push(init.body ? JSON.parse(String(init.body)) : null); return new Response(JSON.stringify({ ok: true, - accountPoolStrategy: "round-robin", - accountPoolStickyLimit: 1, + strategy: "round-robin", + stickyLimit: 1, }), { status: 200 }); } throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); @@ -331,7 +334,7 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { accountPoolStickyLimit: 1, }), { status: 200 }); } - if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") { + if (url.endsWith("/api/pool/settings") && init?.method === "PUT") { return put.promise; } throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); @@ -380,7 +383,7 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { accountPoolStickyLimit: 1, }), { status: 200 }); } - if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") { + if (url.endsWith("/api/pool/settings") && init?.method === "PUT") { return new Response("fail", { status: 500 }); } throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); @@ -416,7 +419,7 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") { + if (url.endsWith("/api/pool/settings") && init?.method === "PUT") { return put.promise; } throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); diff --git a/gui/tests/anthropic-pool-quota-window.test.tsx b/gui/tests/anthropic-pool-quota-window.test.tsx index 03043813d8..c8b2a86c8f 100644 --- a/gui/tests/anthropic-pool-quota-window.test.tsx +++ b/gui/tests/anthropic-pool-quota-window.test.tsx @@ -62,7 +62,7 @@ function stubPool(initial: PoolPayload): Record[] { const puts: Record[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url.includes("/api/oauth/accounts/pool") && init?.method === "PUT") { + if (url.includes("/api/pool/settings") && init?.method === "PUT") { const body = init.body ? JSON.parse(String(init.body)) as Record : {}; puts.push(body); return new Response(JSON.stringify({ @@ -71,7 +71,7 @@ function stubPool(initial: PoolPayload): Record[] { quotaWindow: body.quotaWindow, }), { status: 200 }); } - if (url.includes("/api/oauth/accounts/pool")) { + if (url.includes("/api/pool/settings")) { return new Response(JSON.stringify(initial), { status: 200 }); } throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); diff --git a/gui/tests/codex-account-auto-switch.test.tsx b/gui/tests/codex-account-auto-switch.test.tsx index c9b4f19dca..695f62bd62 100644 --- a/gui/tests/codex-account-auto-switch.test.tsx +++ b/gui/tests/codex-account-auto-switch.test.tsx @@ -245,9 +245,12 @@ describe("Codex account auto-switch threshold", () => { }; expect(await putAutoSwitchThreshold("http://localhost:10100", 95, fetchImpl)).toBe(true); - expect(request?.input).toBe("http://localhost:10100/api/codex-auth/auto-switch"); + expect(request?.input).toBe("http://localhost:10100/api/pool/settings"); expect(request?.init.method).toBe("PUT"); - expect(request?.init.body).toBe(JSON.stringify({ threshold: 95 })); + // Mapped, not forwarded: the contract field is autoSwitchThreshold and the provider is + // always sent. A body that still said `threshold` would be ignored and the save would + // report success while changing nothing. + expect(request?.init.body).toBe(JSON.stringify({ provider: "openai", autoSwitchThreshold: 95 })); }); test("reports HTTP and network failures without accepting the write", async () => { diff --git a/gui/tests/codex-auto-switch-controller.test.tsx b/gui/tests/codex-auto-switch-controller.test.tsx index 287ceffdaf..126510a9a2 100644 --- a/gui/tests/codex-auto-switch-controller.test.tsx +++ b/gui/tests/codex-auto-switch-controller.test.tsx @@ -156,9 +156,11 @@ async function mountHarness(): Promise { accountPoolStickyLimit: 1, }); } - if (url.endsWith("/api/codex-auth/auto-switch") && method === "PUT") { - const body = JSON.parse(String(init?.body)) as { threshold: number }; - writes.push(body.threshold); + if (url.endsWith("/api/pool/settings") && method === "PUT") { + // The unified contract field, not the GUI one: the client maps it, and a harness + // still reading `threshold` would record undefined for every save. + const body = JSON.parse(String(init?.body)) as { autoSwitchThreshold: number }; + writes.push(body.autoSwitchThreshold); const response = putResponses.shift(); if (!response) throw new Error("unexpected auto-switch write"); return await response; @@ -321,9 +323,9 @@ describe("Codex auto-switch controller interactions", () => { accountPoolStickyLimit: 1, }); } - if (url.endsWith("/api/codex-auth/auto-switch") && method === "PUT") { - const body = JSON.parse(String(init?.body)) as { threshold: number }; - writes.push(body.threshold); + if (url.endsWith("/api/pool/settings") && method === "PUT") { + const body = JSON.parse(String(init?.body)) as { autoSwitchThreshold: number }; + writes.push(body.autoSwitchThreshold); const response = putResponses.shift(); if (!response) throw new Error("unexpected auto-switch write"); return await response; diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 2ec0e5d166..f9fec70034 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -547,10 +547,9 @@ Show or set how an account pool picks the next account. | Method | Route | |---|---| -| GET | `/api/codex-auth/active` | -| PUT | `/api/codex-auth/pool-strategy` | -| GET | `/api/oauth/accounts/pool` | -| PUT | `/api/oauth/accounts/pool` | +| GET | `/api/pool/settings` | +| PUT | `/api/pool/settings` | +| PATCH | `/api/pool/settings` | | Flag | Value | Meaning | |---|---|---| @@ -561,26 +560,46 @@ JSON mode: `envelope`. - A bare invocation reads and never writes. - The APPLIED value is echoed, not the requested one, so a server-side normalization stays visible. - Values are not re-validated in the CLI: the server owns the strategy names and the 1-100 sticky bound. -- `anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold/sticky); those settings steer selection only while `pool.kernel` is on, which is what the `inert` field reports. `quotaWindow` is still refused for them. +- One route answers for every pool kind and declares which fields that kind honours in `supported`, so an unsupported field is a stated null rather than an absence. `anthropic` alone carries `quotaWindow`. Generic-provider settings steer selection only while `pool.kernel` is on. The legacy per-pool paths still work and are unchanged. ### `ocx account sticky` Show or set how many consecutive requests stay on one account. +| Method | Route | +|---|---| +| GET | `/api/pool/settings` | +| PUT | `/api/pool/settings` | +| PATCH | `/api/pool/settings` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the applied strategy and sticky limit as JSON. | + +JSON mode: `envelope`. + +- Only meaningful under the sticky-capable strategies; the pool strategy is the other half of this setting. + +### `ocx account auto-switch` + +Show or set the usage percentage at which a pool moves to another account. + | Method | Route | |---|---| | GET | `/api/codex-auth/active` | -| PUT | `/api/codex-auth/pool-strategy` | +| PUT | `/api/codex-auth/auto-switch` | | GET | `/api/oauth/accounts/pool` | | PUT | `/api/oauth/accounts/pool` | | Flag | Value | Meaning | |---|---|---| -| `--json` | boolean | Emit the applied strategy and sticky limit as JSON. | +| `--json` | boolean | Emit the stored threshold and whether it is applied. | JSON mode: `envelope`. -- Only meaningful under the sticky-capable strategies; the pool strategy is the other half of this setting. +- A bare invocation reads and never writes. +- `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0-100. +- For a generic OAuth pool, `inert: true` means the threshold is stored but not applied, `inert: false` means the pool is applying it, and an absent `inert` is an unknown capability. ### `ocx storage cleanup` @@ -750,6 +769,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 40 -- of those, state-changing: 19 +- declared capabilities: 41 +- of those, state-changing: 20 - head-resolved invocations: 2 diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index a8484f8b73..18fca00fb7 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -847,21 +847,15 @@ export async function cmdPauseExhausted(args: string[], deps: AccountDeps): Prom } /** - * Two pools expose strategy and sticky, and they are NOT reached the same way: + * One transport, because there is now one contract. * - * | | Codex pool | Anthropic pool | - * |---|---|---| - * | read | `GET /api/codex-auth/active` | `GET /api/oauth/accounts/pool?provider=` | - * | write | `PUT /api/codex-auth/pool-strategy` | `PUT /api/oauth/accounts/pool` | - * | keys | `accountPoolStrategy`/`accountPoolStickyLimit` | `strategy`/`stickyLimit` | - * | body | bare field | field **plus** a mandatory `provider` | + * This used to be a table of the differences between the Codex and Anthropic pools -- different + * read path, different write path, different response keys, and a `provider` field mandatory on + * one body and forbidden on the other. That table existed only because the two contracts + * disagreed; `/api/pool/settings` answers with the same keys for every kind, so the table + * collapses to a single shape and the asymmetry it encoded is gone rather than relocated. * - * Omitting `provider` from the Anthropic write body earns a 400 - * (`oauth-account-routes.ts:344`), so the asymmetry has to be encoded somewhere. Encoding it - * here keeps ONE verb pair working on both pools. The alternative the plan left open -- a second - * `provider-strategy`/`provider-sticky` pair -- would double the surface an operator must learn - * to express one idea, and a CLI that can steer one pool and not the other is exactly the trap - * this unit exists to remove. + * The legacy paths still work and still have their own goldens. Nothing here reads them. */ interface PoolTransport { readPath: string; @@ -873,18 +867,10 @@ interface PoolTransport { writeBody: (field: "strategy" | "stickyLimit", value: unknown) => Record; } -const CODEX_POOL_TRANSPORT: PoolTransport = { - readPath: "/api/codex-auth/active", - writePath: "/api/codex-auth/pool-strategy", - strategyKey: "accountPoolStrategy", - stickyKey: "accountPoolStickyLimit", - writeBody: (field, value) => ({ [field]: value }), -}; - -function anthropicPoolTransport(provider: string): PoolTransport { +function unifiedPoolTransport(provider: string): PoolTransport { return { - readPath: `/api/oauth/accounts/pool?provider=${encodeURIComponent(provider)}`, - writePath: "/api/oauth/accounts/pool", + readPath: `/api/pool/settings?provider=${encodeURIComponent(provider)}`, + writePath: "/api/pool/settings", strategyKey: "strategy", stickyKey: "stickyLimit", writeBody: (field, value) => ({ provider, [field]: value }), @@ -900,8 +886,7 @@ function poolTransportFor( classified: { type: "codex" | "oauth" | "api-key" }, name: string, ): PoolTransport | string { - if (classified.type === "codex") return CODEX_POOL_TRANSPORT; - if (classified.type === "oauth") return anthropicPoolTransport(name); + if (classified.type === "codex" || classified.type === "oauth") return unifiedPoolTransport(name); return `pool settings apply to OAuth account pools, not the API-key provider "${name}"`; } diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index d3770890df..63cf313676 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -337,10 +337,9 @@ export const CAPABILITIES: readonly Capability[] = [ // Both pools, because both have the setting. The Codex pool reads its applied values // from the active payload; the Anthropic pool has its own GET. routes: [ - { method: "GET", path: "/api/codex-auth/active" }, - { method: "PUT", path: "/api/codex-auth/pool-strategy" }, - { method: "GET", path: "/api/oauth/accounts/pool" }, - { method: "PUT", path: "/api/oauth/accounts/pool" }, + { method: "GET", path: "/api/pool/settings" }, + { method: "PUT", path: "/api/pool/settings" }, + { method: "PATCH", path: "/api/pool/settings" }, ], flags: [{ name: "--json", value: "boolean", summary: "Emit the applied strategy and sticky limit as JSON." }], mutates: true, @@ -349,23 +348,45 @@ export const CAPABILITIES: readonly Capability[] = [ "A bare invocation reads and never writes.", "The APPLIED value is echoed, not the requested one, so a server-side normalization stays visible.", "Values are not re-validated in the CLI: the server owns the strategy names and the 1-100 sticky bound.", - "`anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold/sticky); those settings steer selection only while `pool.kernel` is on, which is what the `inert` field reports. `quotaWindow` is still refused for them.", + "One route answers for every pool kind and declares which fields that kind honours in `supported`, so an unsupported field is a stated null rather than an absence. `anthropic` alone carries `quotaWindow`. Generic-provider settings steer selection only while `pool.kernel` is on. The legacy per-pool paths still work and are unchanged.", ], }, { command: ["account", "sticky"], summary: "Show or set how many consecutive requests stay on one account.", + routes: [ + { method: "GET", path: "/api/pool/settings" }, + { method: "PUT", path: "/api/pool/settings" }, + { method: "PATCH", path: "/api/pool/settings" }, + ], + flags: [{ name: "--json", value: "boolean", summary: "Emit the applied strategy and sticky limit as JSON." }], + mutates: true, + json: "envelope", + details: ["Only meaningful under the sticky-capable strategies; the pool strategy is the other half of this setting."], + }, + { + command: ["account", "auto-switch"], + summary: "Show or set the usage percentage at which a pool moves to another account.", + // Declared here rather than riding on `account strategy`, which is what it did before the + // unified route existed. `auto-switch` genuinely drives these three: the Codex pool reads + // its applied threshold from the active payload and writes through its own route, and a + // generic OAuth pool reads and writes the per-provider pool settings. routes: [ { method: "GET", path: "/api/codex-auth/active" }, - { method: "PUT", path: "/api/codex-auth/pool-strategy" }, + { method: "PUT", path: "/api/codex-auth/auto-switch" }, { method: "GET", path: "/api/oauth/accounts/pool" }, { method: "PUT", path: "/api/oauth/accounts/pool" }, ], - flags: [{ name: "--json", value: "boolean", summary: "Emit the applied strategy and sticky limit as JSON." }], + flags: [{ name: "--json", value: "boolean", summary: "Emit the stored threshold and whether it is applied." }], mutates: true, json: "envelope", - details: ["Only meaningful under the sticky-capable strategies; the pool strategy is the other half of this setting."], + details: [ + "A bare invocation reads and never writes.", + "`on` stores 80%, `off` stores 0%, and `threshold ` accepts 0-100.", + "For a generic OAuth pool, `inert: true` means the threshold is stored but not applied, `inert: false` means the pool is applying it, and an absent `inert` is an unknown capability.", + ], }, + { command: ["logs"], summary: "Recent request log rows, filterable by provider, model, conversation, account, and status.", diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 95c99e5d15..01db220236 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2007,7 +2007,9 @@ function previewReusableAffinityAccount( getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now, ); - if (!isUnknownUsage(usage) && usage >= threshold) { + // Preview must agree with resolve: this is the second copy of the same rule, and the + // suite asserts the two answer identically. + if (mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) { const best = pickLowerUsageAccount( config, entry.accountId, @@ -2024,6 +2026,32 @@ function previewReusableAffinityAccount( return entry.accountId; } +/** + * May a LIVE binding be moved for quota reasons? + * + * Default: yes once usage crosses `autoSwitchThreshold`, which is the historical rule. + * + * 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. + */ +function mayRebindAffinityForQuota( + config: OcxConfig, + accountId: string, + usage: number, + threshold: number, + selectionOptions?: CodexAccountUsabilityOptions, +): boolean { + const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; + if (config.pool?.cacheAffinity !== true) 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) + || (!isUnknownUsage(usage) && usage >= 100); +} + /** * Re-evaluate an affined account under the quota strategy. Returns a strictly * cooler replacement, or null when the current binding should remain. @@ -2044,15 +2072,18 @@ function reevaluateAffinityQuota( now, ) : 0; - const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; + // One bar, used for BOTH the rebind decision and the re-score interval. Keying the short + // circuit off the old threshold while the rebind bar moved would re-score a bound thread on + // every request through the whole 80-99% band instead of once a minute. + const mayRebind = mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions); if ( - !overThreshold + !mayRebind && now - entry.lastReevalAt < CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS ) { return null; } entry.lastReevalAt = now; - if (!overThreshold) return null; + if (!mayRebind) return null; const best = pickLowerUsageAccount( config, entry.accountId, diff --git a/src/config.ts b/src/config.ts index 3cdd699549..5c4f12269b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -583,7 +583,7 @@ const providerConfigSchema = z.object({ // Validated rather than left to passthrough: an unrecognized strategy would otherwise // load silently and then be ignored at selection time, which reads as a broken feature // rather than a rejected setting. - apiKeyPoolStrategy: z.enum(["round-robin", "fill-first"]).optional(), + apiKeyPoolStrategy: z.enum(["round-robin", "fill-first", "quota"]).optional(), adapter: z.string().min(1), baseUrl: z.string().min(1), alias: z.string().optional(), @@ -1307,7 +1307,10 @@ const configSchema = z.object({ }).optional().catch(undefined), // Same degrade-to-off rule as the flags above: a hand-edited typo in an opt-in pool // feature must never cost the operator their providers. - pool: z.object({ kernel: z.boolean().optional() }).optional().catch(undefined), + pool: z.object({ + kernel: z.boolean().optional(), + cacheAffinity: z.boolean().optional(), + }).optional().catch(undefined), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts index eb98a0d4dc..946a92d357 100644 --- a/src/oauth/pool-settings-capability.ts +++ b/src/oauth/pool-settings-capability.ts @@ -1,5 +1,6 @@ import { isGenericFailoverProvider } from "./generic-account-failover"; -import type { OcxProviderConfig } from "../types"; +import { parseAccountPoolStickyLimit, parseAccountPoolStrategy } from "./pool-kernel"; +import type { OcxConfig, OcxProviderConfig } from "../types"; /** * Which pool-settings contract a provider speaks (#695, slice 1). @@ -28,9 +29,11 @@ export function poolSettingsCapability(name: string, provider: OcxProviderConfig } export function parseGenericPoolStrategy(value: unknown): GenericPoolStrategy | null { - return typeof value === "string" && (GENERIC_POOL_STRATEGIES as readonly string[]).includes(value) - ? value as GenericPoolStrategy - : null; + // Delegated, not re-implemented. Three pools accepting the same three names from three + // private copies of the same check is how they drift apart: the Codex and Anthropic kinds + // already shared this parser while the generic kind carried its own. The names and the + // 1..100 bound live in pool-kernel.ts, once. + return parseAccountPoolStrategy(value) as GenericPoolStrategy | null; } export function parseGenericAutoSwitchThreshold(value: unknown): number | null { @@ -38,9 +41,46 @@ export function parseGenericAutoSwitchThreshold(value: unknown): number | null { } export function parseGenericStickyLimit(value: unknown): number | null { - return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100 ? value : null; + return parseAccountPoolStickyLimit(value); } +/** Fields the unified pool-settings contract can carry, per kind. */ +export const POOL_SETTINGS_FIELDS = [ + "enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow", +] as const; +export type PoolSettingsField = typeof POOL_SETTINGS_FIELDS[number]; + +/** + * One shape for all three pool kinds. + * + * `supported` is the reason this is a consolidation rather than a fourth contract: a field a + * kind does not honour is DECLARED unsupported instead of being omitted, so a dashboard can + * tell "this pool has no quotaWindow" from "this response forgot to send one". Every kind + * answers with the same keys. + */ +export interface PoolSettingsDto { + provider: string; + kind: PoolSettingsKind; + supported: PoolSettingsField[]; + /** The STORED override. null means nothing is stored here, not "off". */ + enabled: boolean | null; + /** + * What the runtime actually resolves for `enabled`, after the global default. + * + * The generic kind inherits `config.oauthAccountFailover.enabled` when it stores no override + * of its own, so `enabled: null` alone cannot distinguish a disabled pool from an inherited + * one. This resolves exactly that config question and nothing else -- deliberately NOT the + * roster quorum the dispatch predicate also applies, because a settings field that folded in + * "how many accounts are logged in" would be answering a different question than it asks. + */ + enabledEffective: boolean; + strategy: string | null; + stickyLimit: number | null; + autoSwitchThreshold: number | null; + quotaWindow: string | null; +} + + export interface GenericPoolSettingsDto { provider: string; kind: "generic"; @@ -79,3 +119,67 @@ export function genericPoolSettingsDto( inert: kernelEnabled !== true, }; } + +/** Which fields each kind actually honours. Declared, never silently omitted. */ +const SUPPORTED_BY_KIND: Record = { + codex: ["strategy", "stickyLimit", "autoSwitchThreshold"], + anthropic: ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow"], + generic: ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold"], +}; + +/** + * The one projection behind `/api/pool/settings`. + * + * Reads each kind's own storage -- this consolidates the CONTRACT, not the persistence -- and + * answers with identical keys plus a `supported` list, so an unsupported field is a declared + * `null` rather than an absence a caller has to guess about. + */ +export function unifiedPoolSettingsDto( + config: OcxConfig, + provider: string, + kind: PoolSettingsKind, +): PoolSettingsDto { + const base = { provider, kind, supported: SUPPORTED_BY_KIND[kind] }; + if (kind === "codex") { + return { + ...base, + // The Codex pool has no enablement switch: it is on whenever accounts exist, so the + // honest answer is "not a field here" rather than a fabricated true. + enabled: null, + enabledEffective: true, + strategy: parseGenericPoolStrategy(config.accountPoolStrategy) ?? "quota", + stickyLimit: parseGenericStickyLimit(config.accountPoolStickyLimit) ?? 1, + autoSwitchThreshold: parseGenericAutoSwitchThreshold(config.autoSwitchThreshold) ?? 80, + quotaWindow: null, + }; + } + if (kind === "anthropic") { + const pool = config.anthropicAccountPool ?? {}; + const enabled = typeof pool.enabled === "boolean" ? pool.enabled : null; + return { + ...base, + enabled, + enabledEffective: enabled === true, + strategy: parseGenericPoolStrategy(pool.strategy) ?? "quota", + stickyLimit: parseGenericStickyLimit(pool.stickyLimit) ?? 1, + autoSwitchThreshold: parseGenericAutoSwitchThreshold(pool.autoSwitchThreshold) ?? 80, + quotaWindow: typeof pool.quotaWindow === "string" ? pool.quotaWindow : "five-hour", + }; + } + const failover = config.providers?.[provider]?.oauthAccountFailover ?? {}; + const stored = typeof failover.enabled === "boolean" ? failover.enabled : null; + return { + ...base, + enabled: stored, + // The defect this field exists to close: a generic provider with no stored override + // inherits the global, so `enabled: null` alone cannot tell a disabled pool from an + // inherited one. Config only -- the roster quorum the dispatch predicate also applies is a + // different question and stays out of a settings field. + enabledEffective: stored ?? (config.oauthAccountFailover?.enabled === true), + strategy: parseGenericPoolStrategy(failover.strategy), + stickyLimit: parseGenericStickyLimit(failover.stickyLimit), + autoSwitchThreshold: parseGenericAutoSwitchThreshold(failover.autoSwitchThreshold), + quotaWindow: null, + }; +} + diff --git a/src/providers/key-failover.ts b/src/providers/key-failover.ts index bce8f3368d..d5ccd759e3 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -15,6 +15,10 @@ import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetry import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport"; import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; +// quota-key-accounts imports only node:crypto, the key store and the quota types -- NOT +// providers/quota.ts -- so the cached reader reaches the dispatch path without dragging the +// probe machinery onto it. +import { cachedApiKeyQuota } from "./quota-key-accounts"; // ---- cooldown state (in-memory, same as codex/routing.ts) ---- @@ -108,11 +112,71 @@ export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean { */ const keyRotationCursor = new Map(); -/** Forget a provider's cursor so an operator's manual key selection is not second-guessed. */ -export function forgetApiKeyRotationCursor(providerName: string): void { +/** + * Forget a provider's cursor so an operator's manual key selection is not second-guessed. + * + * Optional name, mirroring `clearKeyCooldowns`, because the batch provider PUT rewrites the + * entire roster: a cursor that survives a reorder still names a real id, so round-robin + * resumes after the pre-edit position and can skip the first eligible key in the new pool. + */ +export function forgetApiKeyRotationCursor(providerName?: string): void { + if (!providerName) { + keyRotationCursor.clear(); + return; + } keyRotationCursor.delete(providerName); } +/** The pool entry shape is inline on OcxProviderConfig; name it once rather than re-spelling it. */ +type ApiKeyPoolEntry = NonNullable[number]; + +/** + * Remaining headroom for one key, or null when nothing current measures it. + * + * Same definition as `headroomOf` on the OAuth side, so the two pools cannot disagree about + * what "more room" means. `creditsUsd` is deliberately excluded: it is a currency amount, not + * a percentage, and ranking one against the other produces an order that means nothing. + */ +function keyHeadroom(providerName: string, provider: OcxProviderConfig, entry: ApiKeyPoolEntry): number | null { + const quota = cachedApiKeyQuota(providerName, provider, entry.id, entry.key); + if (!quota) return null; + const percents = [ + quota.fiveHourPercent, + quota.weeklyPercent, + quota.monthlyPercent, + ...(quota.customWindows ?? []).map((window: { percent?: number }) => window.percent), + ].filter((value): value is number => typeof value === "number"); + if (percents.length === 0) return null; + return 100 - Math.max(...percents); +} + +/** + * Order eligible keys best-first, in the same three buckets `rankAccountsByHeadroom` uses: + * measured-with-headroom, then unmeasured, then measured-and-spent. Ties keep the roster order. + * + * An unmeasured key is NOT assumed spent, and not assumed fresh either -- it sits between the + * two, which is the only honest position for a key nothing has looked at. A provider that + * publishes no per-key differentiation (DeepSeek reports every key at the same percent) ties + * across the board and falls through to the roster order, which is exactly today's behaviour. + */ +function rankKeysByHeadroom( + providerName: string, + provider: OcxProviderConfig, + eligible: readonly ApiKeyPoolEntry[], +): ApiKeyPoolEntry[] { + return eligible + .map((entry, index) => { + const headroom = keyHeadroom(providerName, provider, entry); + const bucket = headroom === null ? 1 : headroom <= 0 ? 2 : 0; + return { entry, bucket, headroom: headroom ?? 0, index }; + }) + .sort((left, right) => (left.bucket - right.bucket) + || (right.headroom - left.headroom) + || (left.index - right.index)) + .map(row => row.entry); +} + + /** * Pick a better key BEFORE the first attempt when the committed one is already cooling. * @@ -124,6 +188,14 @@ export function forgetApiKeyRotationCursor(providerName: string): void { * * Returning null is the common path, so the persisted-selection transaction is not on * the per-request hot path. + * + * Like `rotateKeyAfterFailure`, the returned object is a snapshot of the PERSISTED config + * and carries none of the registry backfills `routedProviderConfig` merges in at request + * time. A request path must not assign it to an active route wholesale -- for a built-in + * provider stored in its valid minimal form that would drop the adapter id, the base URL and + * the static headers, so `resolveAdapter()` throws `Unknown adapter: undefined` and a + * hand-built URL dereferences a missing `baseUrl`. Use + * `selectProactiveApiKeyTransport`, the pre-dispatch twin of `rotateProviderTransportOn429`. */ export function selectProactiveApiKey( config: OcxConfig, @@ -154,6 +226,10 @@ export function selectProactiveApiKey( chosen = candidate; break; } + } else if (strategy === "quota") { + // else-if, deliberately. `fill-first` is not a named branch here -- it is the eligible[0] + // default above, so replacing that default would silently retarget it. + chosen = rankKeysByHeadroom(providerName, provider, eligible)[0] ?? chosen; } if (chosen.key === provider.apiKey) return null; @@ -178,6 +254,27 @@ export function selectProactiveApiKey( return structuredClone(committed); } +/** + * Pre-dispatch twin of `rotateProviderTransportOn429`: pick a warm key, then rebuild the + * active route from the committed row through the same seam the 429 path uses, so the + * registry backfills survive and only explicit runtime transport state (`fetch` and a + * generated OpenCode session header) is carried over from the route being replaced. + * + * Every request path that assigns the result to a live route must call THIS, not + * `selectProactiveApiKey`, which answers with a persisted snapshot. + */ +export function selectProactiveApiKeyTransport( + config: OcxConfig, + providerName: string, + routedProvider: OcxProviderTransport, + promptCacheKey?: string, + now = Date.now(), +): OcxProviderTransport | null { + const committed = selectProactiveApiKey(config, providerName, now); + if (!committed) return null; + return applyRotatedTransport(providerName, routedProvider, committed, promptCacheKey); +} + /** * Normalize a provider's `retryOn429` policy, or return null when the knob is absent, * explicitly disabled, or the provider is not key-auth (OAuth/forward credentials must not be diff --git a/src/providers/quota-key-accounts.ts b/src/providers/quota-key-accounts.ts index 7f406e0bed..21d4124201 100644 --- a/src/providers/quota-key-accounts.ts +++ b/src/providers/quota-key-accounts.ts @@ -29,6 +29,56 @@ export function clearProviderApiKeyQuotaCache(): void { flights.clear(); } +/** + * Cached-only, synchronous per-key quota. Never probes, never awaits, never schedules a read. + * + * The selector that calls this sits on the first-attempt path, where a network read would be a + * worse defect than the one it is there to fix. A miss is simply "no evidence". + * + * An `unavailable` row is a miss too, and that is the whole point of the check. `readEntry` + * keeps a last-good quota attached for up to LAST_GOOD_MS after a probe starts failing, so + * returning `entry.quota` on any hit would rank on a number up to half an hour stale -- and + * rank it ABOVE a key with no row at all. Last-good is a display value, not a selection input. + * + * A SUCCESSFUL row expires too, on exactly `readEntry`'s freshness predicate. Checking only + * `unavailable` was not enough: nothing on the selection path probes or sweeps, so once a + * dashboard or CLI read had populated the cache, a row could outlive ACCOUNT_QUOTA_TTL_MS and + * keep a "roomy" ten-minute-old measurement ranked above a key with no evidence at all -- + * until some unrelated write happened to sweep it. Expired is no evidence, same as absent. + */ +export function cachedApiKeyQuota( + name: string, + provider: OcxProviderConfig, + keyId: string, + key: string, +): ProviderQuota | null { + let resolved: string | undefined; + // resolveProviderApiKey swallows its own failures; the catch is belt-and-braces because this + // runs on the dispatch path and must not throw there under any future change. + try { resolved = resolveProviderApiKey(key)?.trim(); } catch { return null; } + if (!resolved) return null; + const entry = cache.get(identity(name, provider, keyId, resolved)); + if (!entry || entry.unavailable || !entry.quota) return null; + const now = Date.now(); + if (now - entry.ts >= ACCOUNT_QUOTA_TTL_MS) return null; + if (now - entry.quota.updatedAt >= LAST_GOOD_MS) return null; + return entry.quota; +} + +/** Test seam: keyed on identity(), so it takes the raw key rather than an account id. */ +export function setCachedProviderApiKeyQuotaForTests( + name: string, + provider: OcxProviderConfig, + keyId: string, + key: string, + quota: ProviderQuota | null, + unavailable?: true, +): void { + const resolved = resolveProviderApiKey(key)?.trim(); + if (!resolved) return; + remember(identity(name, provider, keyId, resolved), { ts: Date.now(), quota, ...(unavailable ? { unavailable } : {}) }); +} + /** Four workers per roster, not a process-wide network limit. */ export async function mapQuotaRoster(rows: readonly T[], read: (row: T) => Promise): Promise { const out = new Array(rows.length); diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index d08582249e..7f3c97c10e 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -33,6 +33,7 @@ import { } from "../lib/translator-budget"; import { hasKeyPoolFailover, + selectProactiveApiKeyTransport, rateLimitRetryDelayMs, rateLimitRetryPolicyFor, rotateProviderTransportOn429, @@ -235,6 +236,14 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio unregisterTurn(upstream); }; const connectMs = config.connectTimeoutMs ?? 200_000; + // Native chat is its own entry path -- chat-completions.ts routes here directly and never + // through the Responses core -- so the pre-dispatch key preference is applied again here + // rather than inherited. Assigned before the adapter binds below, for the same reason it is + // assigned before the transport pin in core.ts. + // Transport variant: the bare picker answers with the persisted row, which for a built-in + // provider carries no adapter id or base URL until routedProviderConfig backfills it. + const proactiveKeyProvider = selectProactiveApiKeyTransport(config, route.providerName, route.provider); + if (proactiveKeyProvider) route.provider = proactiveKeyProvider; let activeProvider: OcxProviderConfig = route.provider; let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider); let activeRequest: AdapterRequest; diff --git a/src/server/images.ts b/src/server/images.ts index 5642038474..25ff02f3d4 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -28,6 +28,7 @@ import { readBoundedResponseBytes, type BoundedBytesResult } from "../lib/bounde import { sidecarEnter } from "../lib/sidecar-tracker"; import type { OcxConfig } from "../types"; import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar"; +import { selectProactiveApiKeyTransport } from "../providers/key-failover"; import { getProviderRegistryEntry } from "../providers/registry"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; @@ -698,7 +699,33 @@ export async function handleImages( // Do not hide a broken/expired pool behind separately billed API-key image generation. return forwardAuthError; } else if (candidates.keyed) { - const { provider, apiKey, providerName } = candidates.keyed; + const { providerName } = candidates.keyed; + // The keyed image path builds its own URL and Authorization header and never enters + // handleResponses, so the pre-dispatch key pick happens here. + // + // Two things about the placement. It stays INSIDE this branch because higher up it would + // also run for requests ChatGPT forward goes on to serve, spending a rotation on a path + // that never used the key. And the header is rebuilt from the returned route rather than + // from candidates.keyed.apiKey, which is a snapshot resolved earlier: reusing it would + // send the OLD key while the picker had already persisted the new one. + // + // Transport variant: this branch reads `provider.baseUrl` and `provider.headers` to build + // the URL and the request, and it comes back with the credential already resolved. + const warmKeyProvider = selectProactiveApiKeyTransport(config, providerName, candidates.keyed.provider); + const provider = warmKeyProvider ?? candidates.keyed.provider; + // No fall back to the earlier snapshot once a pick has happened. The picker COMMITS its + // choice before returning, so if the chosen reference will not resolve -- revoked keychain + // entry, unset env var -- sending the previous key would authenticate a non-idempotent + // POST with a credential the config no longer considers active, and the previous key is + // the one that was cooling. Fail loudly instead. + if (warmKeyProvider && !warmKeyProvider.apiKey?.trim()) { + return formatErrorResponse( + 500, + "configuration_error", + `image generation selected an API key for "${providerName}" that cannot be resolved`, + ); + } + const apiKey = warmKeyProvider?.apiKey ?? candidates.keyed.apiKey; if (provider.headers) Object.assign(headers, provider.headers); headers["authorization"] = `Bearer ${apiKey}`; logCtx.provider = providerName; diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 9fdff91daf..89f80e5f88 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -358,6 +358,91 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); } + // The unified pool-settings contract (#695 wp5c). The three legacy paths keep working and + // keep their own shapes -- goldens pin them -- but this is the one an operator or a dashboard + // should read, because it answers with the same keys for every kind and DECLARES which of + // them that kind honours. + if (url.pathname === "/api/pool/settings" && (req.method === "GET" || req.method === "PUT" || req.method === "PATCH")) { + const { + poolSettingsCapability, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold, parseGenericStickyLimit, + unifiedPoolSettingsDto, + } = await import("../../oauth/pool-settings-capability"); + const rawBody = req.method === "GET" ? {} : await readManagementJsonBodyOr(req, {}); + if (req.method !== "GET" && !isPlainRecord(rawBody)) { + return jsonResponse({ error: "body must be an object" }, 400); + } + const fields = rawBody as { provider?: unknown; enabled?: unknown; strategy?: unknown; stickyLimit?: unknown; autoSwitchThreshold?: unknown; quotaWindow?: unknown }; + const provider = req.method === "GET" + ? (url.searchParams.get("provider") ?? "").trim().toLowerCase() + : (typeof fields.provider === "string" ? fields.provider.trim().toLowerCase() : ""); + const kind = provider ? poolSettingsCapability(provider, config.providers?.[provider]) : null; + if (!provider || !kind) { + return jsonResponse({ error: "pool settings are only available for the codex, anthropic and generic OAuth pools" }, 400); + } + // Validated by the SHARED parsers before any kind-specific write, so a bad strategy or + // sticky limit is refused identically whichever pool is addressed. + let strategy: string | undefined; + if (fields.strategy !== undefined) { + const parsed = parseGenericPoolStrategy(fields.strategy); + if (parsed === null) return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400); + strategy = parsed; + } + let stickyLimit: number | undefined; + if (fields.stickyLimit !== undefined) { + const parsed = parseGenericStickyLimit(fields.stickyLimit); + if (parsed === null) return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400); + stickyLimit = parsed; + } + let autoSwitchThreshold: number | undefined; + if (fields.autoSwitchThreshold !== undefined) { + const parsed = parseGenericAutoSwitchThreshold(fields.autoSwitchThreshold); + if (parsed === null) return jsonResponse({ error: "autoSwitchThreshold must be an integer 0-100" }, 400); + autoSwitchThreshold = parsed; + } + if (fields.quotaWindow !== undefined && kind !== "anthropic") { + return jsonResponse({ error: "quotaWindow is only part of the anthropic pool contract" }, 400); + } + let quotaWindow: string | undefined; + if (fields.quotaWindow !== undefined) { + const parsed = parseAccountPoolQuotaWindow(fields.quotaWindow); + if (parsed === null) return jsonResponse({ error: "quotaWindow must be one of: five-hour, weekly, max-utilization" }, 400); + quotaWindow = parsed; + } + if (fields.enabled !== undefined) { + if (kind === "codex") return jsonResponse({ error: "enabled is not part of the codex pool contract" }, 400); + if (typeof fields.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400); + } + + if (req.method !== "GET") { + if (kind === "codex") { + if (strategy !== undefined) config.accountPoolStrategy = strategy as never; + if (stickyLimit !== undefined) config.accountPoolStickyLimit = stickyLimit; + if (autoSwitchThreshold !== undefined) config.autoSwitchThreshold = autoSwitchThreshold; + } else if (kind === "anthropic") { + const pool = { ...(config.anthropicAccountPool ?? {}) }; + if (fields.enabled !== undefined) pool.enabled = fields.enabled as boolean; + if (strategy !== undefined) pool.strategy = strategy as never; + if (stickyLimit !== undefined) pool.stickyLimit = stickyLimit; + if (autoSwitchThreshold !== undefined) pool.autoSwitchThreshold = autoSwitchThreshold; + if (quotaWindow !== undefined) pool.quotaWindow = quotaWindow as never; + config.anthropicAccountPool = pool; + } else { + const prov = config.providers[provider]!; + const next = { ...(prov.oauthAccountFailover ?? {}) }; + if (fields.enabled !== undefined) next.enabled = fields.enabled as boolean; + if (strategy !== undefined) next.strategy = strategy as never; + if (stickyLimit !== undefined) next.stickyLimit = stickyLimit; + if (autoSwitchThreshold !== undefined) next.autoSwitchThreshold = autoSwitchThreshold; + if (Object.keys(next).length > 0) prov.oauthAccountFailover = next; + else delete prov.oauthAccountFailover; + } + saveConfigPreservingClaudeCode(config); + reconcileLiveStateStores(); + } + return jsonResponse(unifiedPoolSettingsDto(config, provider, kind)); + } + + // Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown. if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") { const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); @@ -646,8 +731,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< clearModelCache(name); const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); - const { clearKeyCooldowns } = await import("../../providers/key-failover"); + const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover"); clearKeyCooldowns(name); // manual key management resets 429 cooldown state + // ...and the rotation cursor with it. A cursor that predates the operator's choice would + // hand the next proactive pick straight back to whichever key the pool had reached. + forgetApiKeyRotationCursor(name); return jsonResponse({ ok: true, id: result.id }, 201); } // Opt-in OS keychain storage (#1221): move the active key and pool into the OS credential @@ -690,8 +778,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< clearModelCache(name); const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); - const { clearKeyCooldowns } = await import("../../providers/key-failover"); + const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover"); clearKeyCooldowns(name); // manual key management resets 429 cooldown state + // ...and the rotation cursor with it. A cursor that predates the operator's choice would + // hand the next proactive pick straight back to whichever key the pool had reached. + forgetApiKeyRotationCursor(name); return jsonResponse({ ok: true, name, activeId: body.id }); } if (url.pathname === "/api/providers/keys/alias" && req.method === "PUT") { @@ -719,8 +810,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< clearModelCache(name); const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); - const { clearKeyCooldowns } = await import("../../providers/key-failover"); + const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover"); clearKeyCooldowns(name); // manual key management resets 429 cooldown state + // ...and the rotation cursor with it. A cursor that predates the operator's choice would + // hand the next proactive pick straight back to whichever key the pool had reached. + forgetApiKeyRotationCursor(name); return jsonResponse({ ok: true }); } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index d2cb40291a..c500d7c611 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -57,7 +57,7 @@ import { import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { clearKeyCooldowns } from "../../providers/key-failover"; +import { clearKeyCooldowns, forgetApiKeyRotationCursor } from "../../providers/key-failover"; import { providerRequestPacingStatus } from "../../providers/request-pacing"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; @@ -830,6 +830,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { expect(getKeyCooldownUntil("p", "k1", now)).toBeGreaterThan(now); }); + /** + * The picker answers with the PERSISTED row, so a request path that assigns it to a live + * route wholesale loses everything `routedProviderConfig` backfills at request time and + * every piece of explicit runtime transport state the route was carrying. The most + * load-bearing of those is the credential: a stored `\${VAR}` reference is resolved in + * `routedProviderConfig` and nowhere in the adapter, so the snapshot's `apiKey` is the + * reference itself. + * + * `selectProactiveApiKeyTransport` is the pre-dispatch twin of + * `rotateProviderTransportOn429` and goes through the same rebuild seam. Red control: + * return the snapshot from it and both the resolved credential and the retained `fetch` + * assertions below fail. + */ + test("the Transport twin rebuilds the route the picker only snapshots", () => { + process.env.OCX_KEYFAILOVER_WARM = "resolved-warm-key"; + try { + const config = makeConfig({ + apiKey: "key-alpha-000111222333", + apiKeyPool: [ + { id: "k1", key: "key-alpha-000111222333", addedAt: 1 }, + { id: "k2", key: "\${OCX_KEYFAILOVER_WARM}", addedAt: 2 }, + ], + apiKeyPoolStrategy: "round-robin", + }); + forgetApiKeyRotationCursor("p"); + rotateKeyOn429(config, "p", null, now); + setActiveProviderApiKey(config, "p", "k1"); + const sentinelFetch = (async () => new Response("")) as typeof fetch; + const routed = { ...routedProviderConfig("p", config.providers.p!), fetch: sentinelFetch }; + const transport = selectProactiveApiKeyTransport(config, "p", routed, undefined, now); + expect(transport).not.toBeNull(); + // The route gets the RESOLVED credential. + expect(transport?.apiKey).toBe("resolved-warm-key"); + // Explicit runtime transport state survives the rebuild, exactly as it does on 429. + expect(transport?.fetch).toBe(sentinelFetch); + // And the persisted row still holds the reference, which is what made the wholesale + // assignment wrong in the first place. + expect(loadConfig().providers.p!.apiKey).toBe("\${OCX_KEYFAILOVER_WARM}"); + } finally { + delete process.env.OCX_KEYFAILOVER_WARM; + } + }); + test("returns null when every key is cooling", () => { const config = makeConfig({ apiKey: "key-alpha-000111222333", @@ -517,5 +563,81 @@ describe("rotateKeyOn401", () => { forgetApiKeyRotationCursor("p"); expect(selectProactiveApiKey(config, "p", now)).toBeNull(); }); + + /** Quota rows live in a private cache keyed on the resolved secret; seed it through the seam. */ + function seedQuota(config: OcxConfig, keyId: string, key: string, percent: number | null, unavailable?: true) { + setCachedProviderApiKeyQuotaForTests( + "p", config.providers.p!, keyId, key, + percent === null ? null : { weeklyPercent: percent, updatedAt: Date.now() } as never, + unavailable, + ); + } + + function cooledFirstKey(strategy: "round-robin" | "quota") { + const config = makeConfig({ apiKey: "key-alpha-000111222333", apiKeyPool: pool3(), apiKeyPoolStrategy: strategy }); + forgetApiKeyRotationCursor("p"); + clearProviderApiKeyQuotaCache(); + rotateKeyOn429(config, "p", null, now); + setActiveProviderApiKey(config, "p", "k1"); + return config; + } + + test("quota picks the roomiest eligible key", () => { + const config = cooledFirstKey("quota"); + // beta is nearly spent, gamma is barely touched. Round-robin would have taken beta simply + // because it is next; ranking is the entire difference. + seedQuota(config, "k2", "key-beta-444555666777", 80); + seedQuota(config, "k3", "key-gamma-888999000111", 10); + expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-gamma-888999000111"); + }); + + test("an unmeasured key outranks a measured-and-spent one", () => { + const config = cooledFirstKey("quota"); + // beta is provably at its limit; gamma has never been measured. Unmeasured is not assumed + // fresh, but it is not assumed spent either -- and "spent" is the one thing we know here. + seedQuota(config, "k2", "key-beta-444555666777", 100); + expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-gamma-888999000111"); + }); + + test("a stale unavailable row is not evidence", () => { + const config = cooledFirstKey("quota"); + // beta carries a roomy last-good measurement attached to a FAILING probe, which the cache + // keeps for half an hour. Ranking on it would prefer a number nothing currently supports. + seedQuota(config, "k2", "key-beta-444555666777", 0, true); + seedQuota(config, "k3", "key-gamma-888999000111", 70); + expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-gamma-888999000111"); + }); + + test("with nothing measured the first eligible key is taken", () => { + const config = cooledFirstKey("quota"); + // A provider with no per-key quota reader must land on exactly today's behaviour. + expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-beta-444555666777"); + }); + + /** + * A SUCCESSFUL row expires too. Nothing on the selection path probes or sweeps, so once a + * dashboard or CLI read populated the cache, a ten-minute-old measurement could keep + * outranking a key with no evidence until some unrelated write swept it. + * + * gamma is the roomier key, so a cache that still counts as evidence picks gamma. Expired, + * neither row is evidence and the roster order decides -- beta, the same answer the + * nothing-measured case above gets. Red control: drop the two freshness checks in + * `cachedApiKeyQuota` and this returns gamma. + */ + test("a successful row past its TTL is not evidence either", () => { + const config = cooledFirstKey("quota"); + seedQuota(config, "k2", "key-beta-444555666777", 90); + seedQuota(config, "k3", "key-gamma-888999000111", 10); + const realNow = Date.now; + // Only the CACHE clock moves. The cooldown clock is the `now` the selector is handed, and + // the two are independent on purpose -- otherwise this would also un-cool k1. + Date.now = () => realNow() + ACCOUNT_QUOTA_TTL_MS + 1; + try { + expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-beta-444555666777"); + } finally { + Date.now = realNow; + } + }); + }); }); diff --git a/tests/adapters/openai/openai-api-virtual-models.test.ts b/tests/adapters/openai/openai-api-virtual-models.test.ts index dc72ea8313..9a30f3b718 100644 --- a/tests/adapters/openai/openai-api-virtual-models.test.ts +++ b/tests/adapters/openai/openai-api-virtual-models.test.ts @@ -13,7 +13,8 @@ import { } from "../../../src/providers/openai-virtual-models"; import { PROVIDER_REGISTRY } from "../../../src/providers/registry"; import { resolveWireProtocolOverride } from "../../../src/server/adapter-resolve"; -import { saveConfig } from "../../../src/config"; +import { loadConfig, saveConfig } from "../../../src/config"; +import { clearKeyCooldowns, rotateKeyOn429 } from "../../../src/providers/key-failover"; import { startServer } from "../../../src/server"; import { usageLogPath } from "../../../src/usage/log"; @@ -176,6 +177,64 @@ describe("validateOpenAiVirtualModelDefinition", () => { }); describe("OpenAI API compact transport", () => { + + test("a cooled committed key is replaced before the first native compact send", async () => { + const originalFetch = globalThis.fetch; + const home = mkdtempSync(join(tmpdir(), "ocx-openai-api-compact-pool-")); + process.env.OPENCODEX_HOME = home; + clearKeyCooldowns(); + saveConfig({ + port: 0, + defaultProvider: "openai-apikey", + openaiProviderTierVersion: 2, + providers: { + "openai-apikey": { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + apiKey: "sk-platform", + apiKeyPoolStrategy: "round-robin", + apiKeyPool: [ + { id: "first", key: "sk-platform" }, + { id: "second", key: "sk-warm" }, + ], + }, + }, + } as never); + + // Cool the committed key the way a real 429 does, then point the stored selection back at + // it. Native compact never enters handleResponses, so nothing else would move it. + const live = loadConfig(); + rotateKeyOn429(live, "openai-apikey", null, Date.now(), "sk-platform"); + const restored = loadConfig(); + restored.providers["openai-apikey"]!.apiKey = "sk-platform"; + saveConfig(restored); + + const seen: Array = []; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url !== "https://api.openai.com/v1/responses/compact") throw new Error(`unexpected upstream URL: ${url}`); + seen.push(new Headers(init?.headers).get("authorization")); + return new Response(JSON.stringify({ output: [] }), { headers: { "content-type": "application/json" } }); + }; + + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses/compact", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "openai-apikey/gpt-5.6-sol", input: [] }), + }); + expect(response.status).toBe(200); + expect(seen).toEqual(["Bearer sk-warm"]); + } finally { + globalThis.fetch = originalFetch; + await server.stop(true); + clearKeyCooldowns(); + removeTreeWithRetry(home); + } + }); + test("maps every Pro id to base, strips reasoning, buffers failures, caps bodies, and logs exactly once", async () => { const originalFetch = globalThis.fetch; const home = mkdtempSync(join(tmpdir(), "ocx-openai-api-compact-")); diff --git a/tests/cli/cli-account-pool-verbs.test.ts b/tests/cli/cli-account-pool-verbs.test.ts index 0eacd8e1bf..afab5fea2f 100644 --- a/tests/cli/cli-account-pool-verbs.test.ts +++ b/tests/cli/cli-account-pool-verbs.test.ts @@ -168,7 +168,7 @@ describe("ocx account strategy / sticky", () => { const out = capture(); try { await cmdStrategy(["openai"], deps(() => ({ - json: { accountPoolStrategy: "round-robin", accountPoolStickyLimit: 4 }, + json: { strategy: "round-robin", stickyLimit: 4 }, }), calls)); } finally { out.restore(); } expect(calls.every(call => call.method === "GET")).toBe(true); @@ -180,14 +180,16 @@ describe("ocx account strategy / sticky", () => { const stickyCalls: Captured[] = []; const out = capture(); try { - await cmdStrategy(["openai", "fill-first"], deps(() => ({ json: { accountPoolStrategy: "fill-first", accountPoolStickyLimit: 1 } }), strategyCalls)); - await cmdSticky(["openai", "7"], deps(() => ({ json: { accountPoolStrategy: "fill-first", accountPoolStickyLimit: 7 } }), stickyCalls)); + await cmdStrategy(["openai", "fill-first"], deps(() => ({ json: { strategy: "fill-first", stickyLimit: 1 } }), strategyCalls)); + await cmdSticky(["openai", "7"], deps(() => ({ json: { strategy: "fill-first", stickyLimit: 7 } }), stickyCalls)); } finally { out.restore(); } - expect(strategyCalls[0]?.path).toBe("/api/codex-auth/pool-strategy"); - expect(stickyCalls[0]?.path).toBe("/api/codex-auth/pool-strategy"); - expect(strategyCalls[0]?.body).toEqual({ strategy: "fill-first" }); + expect(strategyCalls[0]?.path).toBe("/api/pool/settings"); + expect(stickyCalls[0]?.path).toBe("/api/pool/settings"); + // Every kind now carries `provider`, including Codex. The bare-field body was the other + // half of the asymmetry the unified route removes. + expect(strategyCalls[0]?.body).toEqual({ provider: "openai", strategy: "fill-first" }); // Sent as a number so the server sees the type it validates. - expect(stickyCalls[0]?.body).toEqual({ stickyLimit: 7 }); + expect(stickyCalls[0]?.body).toEqual({ provider: "openai", stickyLimit: 7 }); }); test("the APPLIED value is echoed, not the requested one", async () => { @@ -195,7 +197,7 @@ describe("ocx account strategy / sticky", () => { // should see. const out = capture(); try { - await cmdSticky(["openai", "9"], deps(() => ({ json: { accountPoolStrategy: "quota", accountPoolStickyLimit: 3 } }), [])); + await cmdSticky(["openai", "9"], deps(() => ({ json: { strategy: "quota", stickyLimit: 3 } }), [])); } finally { out.restore(); } expect(out.lines.join("\n")).toContain("3"); expect(out.lines.join("\n")).not.toContain("9"); @@ -257,7 +259,7 @@ describe("ocx account strategy / sticky on the anthropic pool", () => { await cmdStrategy(["anthropic"], anthropicDeps(() => ({ json: { strategy: "round-robin", stickyLimit: 5 } }), calls)); } finally { out.restore(); } expect(calls[0]?.method).toBe("GET"); - expect(calls[0]?.path).toBe("/api/oauth/accounts/pool?provider=anthropic"); + expect(calls[0]?.path).toBe("/api/pool/settings?provider=anthropic"); // Unprefixed keys: this route spells the same settings without `accountPool`. expect(out.lines.join("\n")).toContain("round-robin"); }); @@ -269,7 +271,7 @@ describe("ocx account strategy / sticky on the anthropic pool", () => { await cmdSticky(["anthropic", "6"], anthropicDeps(() => ({ json: { ok: true, strategy: "quota", stickyLimit: 6 } }), calls)); } finally { out.restore(); } expect(calls[0]?.method).toBe("PUT"); - expect(calls[0]?.path).toBe("/api/oauth/accounts/pool"); + expect(calls[0]?.path).toBe("/api/pool/settings"); // Omitting `provider` here earns a 400 from the real route, so it is asserted exactly. expect(calls[0]?.body).toEqual({ provider: "anthropic", stickyLimit: 6 }); expect(out.lines.join("\n")).toContain("6"); @@ -286,7 +288,7 @@ describe("ocx account strategy / sticky on the anthropic pool", () => { test("the codex pool keeps its own prefixed keys mapped onto the same neutral output", async () => { const out = capture(); try { - await cmdStrategy(["openai", "--json"], deps(() => ({ json: { accountPoolStrategy: "quota", accountPoolStickyLimit: 1 } }), [])); + await cmdStrategy(["openai", "--json"], deps(() => ({ json: { strategy: "quota", stickyLimit: 1 } }), [])); } finally { out.restore(); } expect(JSON.parse(out.lines.join("\n"))).toMatchObject({ provider: "openai", strategy: "quota", stickyLimit: 1 }); }); @@ -342,7 +344,7 @@ describe("generic OAuth pool-settings contract (#695)", () => { try { await cmdStrategy(["google-antigravity", "round-robin"], genericDeps(() => ({ json: { ok: true, strategy: "round-robin", stickyLimit: null } }), calls)); } finally { out.restore(); } - expect(calls[0]).toMatchObject({ method: "PUT", path: "/api/oauth/accounts/pool", body: { provider: "google-antigravity", strategy: "round-robin" } }); + expect(calls[0]).toMatchObject({ method: "PUT", path: "/api/pool/settings", body: { provider: "google-antigravity", strategy: "round-robin" } }); }); test("auto-switch on a generic provider writes autoSwitchThreshold through the pool route", async () => { diff --git a/tests/cli/cli-capabilities.test.ts b/tests/cli/cli-capabilities.test.ts index 9a3191804a..4210a0d595 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -310,7 +310,6 @@ const UNDECLARED_ROUTES_2026_08_28: readonly string[] = [ "PUT /api/codex-auth/accounts/alias", "PUT /api/codex-auth/accounts/priority", "PUT /api/codex-auth/active", - "PUT /api/codex-auth/auto-switch", "PUT /api/codex-auth/failover", "PUT /api/combos", "PUT /api/custom-models/{id}", diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 5594d44a59..0206c8e54a 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -250,6 +250,11 @@ describe("headless GUI parity CLI", () => { // skipping the endpoint. ["/api/github/star", "(none — GUI-only)"], ["/api/oauth", "ocx account"], + // The unified pool-settings route (#695 wp5c). One path answers for every pool + // kind, and `ocx account strategy` / `ocx account sticky` / `ocx account auto-switch` + // are what drive it headlessly — they declare it in src/cli/capabilities.ts rather + // than the retired per-namespace paths. + ["/api/pool/settings", "ocx account strategy/sticky/auto-switch"], ["/api/accounts/events", "(none — dashboard invalidation; ocx account reads current selection)"], ["/api/providers/keys", "ocx account"], ["/api/providers", "ocx provider"], diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index f5a5c38c49..1f7905a16f 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -20,6 +20,7 @@ import { clearCodexUpstreamHealthForAccount, clearThreadAccountMap, CODEX_TRANSIENT_SOFT_AVOID_MS, + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS, previewCodexAccountForRequest, getEffectiveActiveCodexAccountId, isCodexAccountInCooldown, @@ -1043,6 +1044,84 @@ describe("selection order across rotation strategies", () => { }); describe("an operator selection outranks the pool cursor", () => { + + test.each([true, false])( + "cache affinity outranks quota when the flag is %s", + (cacheAffinity) => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + ...(cacheAffinity ? { pool: { cacheAffinity: true } } : {}), + } as Partial); + const threadId = "cache-affine-thread"; + // Bind the thread while "a" is the natural quota pick, which is how a real conversation + // acquires its affinity in the first place. + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + // Now "a" is past the threshold but NOT spent, and the siblings have far more room. + updateAccountQuota("a", 90); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const later = Date.now() + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + const served = resolveCodexAccountForThread(threadId, config, later); + if (cacheAffinity) { + // c-4: the cache-affine account is chosen over the higher-headroom one. The prompt + // cache lives on "a"; crossing a threshold is a hint, not evidence "a" cannot serve. + expect(served).toBe("a"); + } else { + // Flag off is byte-identical to today: the thread moves at the threshold. + expect(served).not.toBe("a"); + } + }, + ); + + test("a bound thread still leaves an account that is genuinely spent", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + pool: { cacheAffinity: true }, + } as Partial); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const threadId = "spent-account-thread"; + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + // Fully spent, not merely busy. This is the half that keeps the change a REORDERING rather + // than a pin: affinity outranks quota, it does not outrank exhaustion. + updateAccountQuota("a", 100); + const later = Date.now() + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + expect(resolveCodexAccountForThread(threadId, config, later)).not.toBe("a"); + }); + + test("preview and resolve agree under cache affinity", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + pool: { cacheAffinity: true }, + } as Partial); + const threadId = "preview-agrees-thread"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + updateAccountQuota("a", 90); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + const later = Date.now() + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + // Two copies of the same rule live in this file; a preview that disagreed with the final + // answer would hand subagent fallback a different account than the request actually uses. + expect(previewCodexAccountForRequest(threadId, config, later)).toBe("a"); + expect(resolveCodexAccountForThread(threadId, config, later)).toBe("a"); + }); + test("the pool moves, then a manual pick wins the next unbound dispatch", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin", diff --git a/tests/providers/provider-config-batch-management.test.ts b/tests/providers/provider-config-batch-management.test.ts index c0646c98f9..8c594fd8c3 100644 --- a/tests/providers/provider-config-batch-management.test.ts +++ b/tests/providers/provider-config-batch-management.test.ts @@ -9,6 +9,8 @@ import { safeConfigDTO } from "../../src/server/auth-cors"; import { handleManagementAPI } from "../../src/server/management-api"; import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { clearKeyCooldowns, forgetApiKeyRotationCursor, rotateKeyOn429, selectProactiveApiKey } from "../../src/providers/key-failover"; +import { setActiveProviderApiKey } from "../../src/providers/api-keys"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { ManagementRequest as Request } from "../helpers/management-auth"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -360,4 +362,61 @@ describe("atomic provider editor batch", () => { error: "Full config PUT is disabled. Use /api/providers POST for provider changes.", }); }); + + /** + * The batch PUT rewrites the whole roster, which is why it already clears every key cooldown + * without naming a provider. The rotation cursor is the other half of that state and was + * being left behind, so round-robin resumed after the pre-edit position instead of at the + * head of the roster the operator had just saved. + * + * Red control: drop `forgetApiKeyRotationCursor()` from the PUT success path and the pick + * below returns `sk-alpha-three`, continuing after the stale cursor instead of taking the + * first eligible key. + */ + test("a batch PUT forgets the rotation cursor along with the cooldowns", async () => { + const liveConfig = seededConfig(); + liveConfig.providers.alpha!.apiKeyPoolStrategy = "round-robin"; + liveConfig.providers.alpha!.apiKeyPool = [ + { id: "one", key: "sk-alpha-one" }, + { id: "two", key: "sk-alpha-two" }, + { id: "three", key: "sk-alpha-three" }, + ]; + liveConfig.providers.alpha!.apiKey = "sk-alpha-one"; + saveConfig(liveConfig); + clearKeyCooldowns(); + forgetApiKeyRotationCursor(); + + // Establish a cursor the honest way: cool the committed key, point the stored selection + // back at it -- which is the state a restart or a config reload leaves -- and let the pool + // advance. Cooling alone is not enough, because rotateKeyOn429 already commits the next + // key and the picker refuses to second-guess a healthy committed one. + const t0 = Date.now(); + rotateKeyOn429(loadConfig(), "alpha", null, t0, "sk-alpha-one"); + setActiveProviderApiKey(loadConfig(), "alpha", "one"); + const first = selectProactiveApiKey(loadConfig(), "alpha", t0); + expect(first?.apiKey).toBe("sk-alpha-two"); + + const baseline = editorBaseline(loadConfig()); + // apiKeyPoolStrategy is a public editor field, so it has to appear in the baseline or the + // deep-equal staleness check rejects the PUT. + baseline.providers.alpha!.apiKeyPoolStrategy = "round-robin"; + const next = structuredClone(baseline); + next.providers.alpha!.defaultModel = "alpha-new"; + // Same seam the other successful-PUT cases use: the destination check would otherwise do a + // real DNS lookup for alpha.example.test on the commit path. + const destinationSpy = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const response = await putBatch(loadConfig(), { baseline, next }); + expect(response?.status).toBe(200); + } finally { + destinationSpy.mockRestore(); + } + + // The PUT cleared the cooldowns, so key one is eligible again. Cool only the committed key + // and point the selection back at it, the same way as above. + rotateKeyOn429(loadConfig(), "alpha", null, t0, "sk-alpha-two"); + setActiveProviderApiKey(loadConfig(), "alpha", "two"); + const second = selectProactiveApiKey(loadConfig(), "alpha", t0); + expect(second?.apiKey).toBe("sk-alpha-one"); + }); }); diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index 502062c1a5..feec9a8151 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleCodexAuthAPI } from "../../src/codex/auth-api"; -import { saveConfig } from "../../src/config"; +import { loadConfig, saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -519,3 +519,258 @@ describe("generic OAuth pool-settings contract (#695)", () => { } }); }); + +describe("legacy pool contract goldens (#wp5)", () => { + /** + * Exact-body pins for the three pool contracts, written BEFORE anything is shared between + * them. The existing coverage could not serve as the compatibility net it was assumed to be: + * the Codex and Anthropic assertions use toMatchObject, which passes when extra keys appear, + * and PUT /api/codex-auth/auto-switch checked only the status code. A refactor guarded by + * those would not have noticed the regression it was supposed to catch. + * + * GET /api/codex-auth/active is deliberately absent: it already carries a full toEqual in + * tests/codex-integration/codex-auth-api.test.ts. + */ + test("PUT /api/codex-auth/auto-switch answers exactly { ok: true }", async () => { + const req = new Request("http://localhost/api/codex-auth/auto-switch", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ threshold: 70 }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ ok: true }); + }); + + test("PUT /api/codex-auth/pool-strategy answers exactly its three keys", async () => { + const req = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ strategy: "round-robin", stickyLimit: 5 }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ + ok: true, + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 5, + }); + }); + + test("GET /api/oauth/accounts/pool answers exactly the anthropic shape", async () => { + const server = startServer(0); + try { + const res = await fetch(new URL("/api/oauth/accounts/pool?provider=anthropic", server.url)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + provider: "anthropic", + enabled: false, + autoSwitchThreshold: 80, + strategy: "quota", + stickyLimit: 1, + quotaWindow: "five-hour", + experimental: true, + }); + } finally { + await server.stop(true); + } + }); + + test("PUT /api/oauth/accounts/pool answers exactly the anthropic shape", async () => { + const server = startServer(0); + try { + const res = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", enabled: true, autoSwitchThreshold: 70, + strategy: "round-robin", stickyLimit: 4, + }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + ok: true, + provider: "anthropic", + enabled: true, + autoSwitchThreshold: 70, + strategy: "round-robin", + stickyLimit: 4, + quotaWindow: "five-hour", + experimental: true, + }); + } finally { + await server.stop(true); + } + }); + + test("a bad strategy and a bad stickyLimit are rejected identically on every kind", async () => { + // One validator, three adapters. The kinds keep their own request and response shapes -- + // that is what the goldens above pin -- but the VALUE rules are now a single implementation, + // so "quota, round-robin, fill-first" and the 1..100 sticky bound cannot drift apart per + // kind. Before this, the generic kind carried a private copy of both. + const codex = async (payload: Record) => { + const req = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); + return resp!.status; + }; + const server = startServer(0); + try { + const oauth = async (payload: Record) => { + const res = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), + }); + return res.status; + }; + for (const strategy of ["weighted", "", 3, null]) { + expect(await codex({ strategy })).toBe(400); + expect(await oauth({ provider: "anthropic", strategy })).toBe(400); + expect(await oauth({ provider: "google-antigravity", strategy })).toBe(400); + } + // 0 and 101 sit just outside the shared bound; 1 and 100 are the edges that must pass. + for (const stickyLimit of [0, 101, 1.5]) { + expect(await codex({ stickyLimit })).toBe(400); + expect(await oauth({ provider: "anthropic", stickyLimit })).toBe(400); + expect(await oauth({ provider: "google-antigravity", stickyLimit })).toBe(400); + } + for (const stickyLimit of [1, 100]) { + expect(await codex({ stickyLimit })).toBe(200); + } + } finally { + await server.stop(true); + } + }); + +}); + +describe("unified pool-settings contract (#695 wp5c)", () => { + let previousHome2: string | undefined; + let dir = ""; + beforeEach(() => { + previousHome2 = process.env.OPENCODEX_HOME; + dir = mkdtempSync(join(tmpdir(), "ocx-pool-unified-")); + process.env.OPENCODEX_HOME = dir; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }, + deepseek: { adapter: "openai-chat", baseUrl: "https://api.deepseek.com/v1", apiKey: "deepseek-key-fixture" }, + }, + } as OcxConfig); + }); + afterEach(() => { + if (previousHome2 === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome2; + if (dir) removeTreeWithRetry(dir); + }); + + test("every kind answers with the same keys and declares what it supports", async () => { + const server = startServer(0); + try { + for (const [provider, kind, supported] of [ + ["openai", "codex", ["strategy", "stickyLimit", "autoSwitchThreshold"]], + ["anthropic", "anthropic", ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow"]], + ["google-antigravity", "generic", ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold"]], + ] as const) { + const res = await fetch(new URL(`/api/pool/settings?provider=${provider}`, server.url)); + expect(res.status).toBe(200); + const dto = await res.json() as Record; + // Same key set for every kind. An unsupported field is a declared null, not an absence, + // which is the whole difference between a consolidation and a fourth contract. + expect(Object.keys(dto).sort()).toEqual([ + "autoSwitchThreshold", "enabled", "enabledEffective", "kind", "provider", + "quotaWindow", "stickyLimit", "strategy", "supported", + ]); + expect(dto.kind).toBe(kind); + expect(dto.supported).toEqual([...supported]); + // quotaWindow belongs to anthropic alone; the others state null rather than omitting it. + if (kind !== "anthropic") expect(dto.quotaWindow).toBeNull(); + } + // An API-key provider has no pool at all and is refused rather than answered with nulls. + expect((await fetch(new URL("/api/pool/settings?provider=deepseek", server.url))).status).toBe(400); + } finally { + await server.stop(true); + } + }); + + test("a generic pool with no stored override reports the inherited global", async () => { + const config = loadConfig(); + config.oauthAccountFailover = { enabled: true }; + saveConfig(config); + const server = startServer(0); + try { + const dto = await (await fetch(new URL("/api/pool/settings?provider=google-antigravity", server.url))).json() as Record; + // The defect this field closes: `enabled: null` means "nothing stored here", which alone + // cannot distinguish a disabled pool from one inheriting a global true. + expect(dto.enabled).toBeNull(); + expect(dto.enabledEffective).toBe(true); + } finally { + await server.stop(true); + } + }); + + test("a global false leaves an unset generic pool effectively off", async () => { + const config = loadConfig(); + config.oauthAccountFailover = { enabled: false }; + saveConfig(config); + const server = startServer(0); + try { + const dto = await (await fetch(new URL("/api/pool/settings?provider=google-antigravity", server.url))).json() as Record; + expect(dto.enabled).toBeNull(); + expect(dto.enabledEffective).toBe(false); + } finally { + await server.stop(true); + } + }); + + test("a stored provider override beats the global in both directions", async () => { + const config = loadConfig(); + config.oauthAccountFailover = { enabled: true }; + config.providers["google-antigravity"]!.oauthAccountFailover = { enabled: false }; + saveConfig(config); + const server = startServer(0); + try { + const dto = await (await fetch(new URL("/api/pool/settings?provider=google-antigravity", server.url))).json() as Record; + expect(dto.enabled).toBe(false); + expect(dto.enabledEffective).toBe(false); + } finally { + await server.stop(true); + } + }); + + test("a write reaches each kind's own storage and is refused identically on bad values", async () => { + const server = startServer(0); + try { + const put = async (payload: Record) => { + const res = await fetch(new URL("/api/pool/settings", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), + }); + return { status: res.status, body: await res.json() as Record }; + }; + // Consolidating the contract does not consolidate the persistence: each kind still lands + // in its own place, which is what keeps the legacy paths answering byte-identically. + expect((await put({ provider: "openai", strategy: "round-robin", stickyLimit: 5 })).body).toMatchObject({ strategy: "round-robin", stickyLimit: 5 }); + expect((await put({ provider: "anthropic", strategy: "fill-first", quotaWindow: "weekly" })).body).toMatchObject({ strategy: "fill-first", quotaWindow: "weekly" }); + expect((await put({ provider: "google-antigravity", strategy: "round-robin", enabled: true })).body).toMatchObject({ strategy: "round-robin", enabled: true, enabledEffective: true }); + const saved = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")); + expect(saved.accountPoolStrategy).toBe("round-robin"); + expect(saved.anthropicAccountPool.strategy).toBe("fill-first"); + expect(saved.providers["google-antigravity"].oauthAccountFailover.strategy).toBe("round-robin"); + + for (const provider of ["openai", "anthropic", "google-antigravity"]) { + expect((await put({ provider, strategy: "weighted" })).status).toBe(400); + expect((await put({ provider, stickyLimit: 0 })).status).toBe(400); + } + // quotaWindow and enabled are declared unsupported for the kinds that lack them, and the + // route says so instead of silently dropping the field. + expect((await put({ provider: "openai", quotaWindow: "weekly" })).status).toBe(400); + expect((await put({ provider: "openai", enabled: true })).status).toBe(400); + expect((await put({ provider: "google-antigravity", quotaWindow: "weekly" })).status).toBe(400); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/server/server-images.test.ts b/tests/server/server-images.test.ts index 371aa264c0..f08dfac858 100644 --- a/tests/server/server-images.test.ts +++ b/tests/server/server-images.test.ts @@ -9,7 +9,8 @@ import { join } from "node:path"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { clearAccountNeedsReauth, clearAccountQuota } from "../../src/codex/auth-api"; import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth } from "../../src/codex/routing"; -import { saveConfig } from "../../src/config"; +import { loadConfig, saveConfig } from "../../src/config"; +import { clearKeyCooldowns, rotateKeyOn429 } from "../../src/providers/key-failover"; import { selectImagesProvider } from "../../src/providers/openai-sidecar"; import { startServer } from "../../src/server"; import { handleImages, IMAGES_RESPONSE_MAX_BYTES, readImageResponseBytes, setXaiResultPinnedDownloadForTests } from "../../src/server/images"; @@ -685,6 +686,144 @@ test("zstd-compressed request bodies are decoded before the relay", async () => } }); + +test("a cooled committed key is replaced before the first keyed image send", async () => { + const captured: CapturedRequest[] = []; + const upstream = fakeImagesUpstream(captured); + clearKeyCooldowns(); + const pooled = { + ...keyedProvider(upstream.url.toString().replace(/\/$/, "")), + apiKeyPoolStrategy: "round-robin", + apiKeyPool: [ + { id: "first", key: "sk-platform-key" }, + { id: "second", key: "sk-warm-key" }, + ], + }; + saveConfig({ + port: 0, + defaultProvider: "openai-apikey", + openaiProviderTierVersion: 2, + providers: { openai: disabledOpenAiProvider, "openai-apikey": pooled }, + } as unknown as OcxConfig); + + // Cool the committed key the way a real 429 does, then point the stored selection back at it. + // This is the state an operator lands in after a rotation plus a restart or a config reload. + const live = loadConfig(); + rotateKeyOn429(live, "openai-apikey", null, Date.now(), "sk-platform-key"); + const restored = loadConfig(); + restored.providers["openai-apikey"]!.apiKey = "sk-platform-key"; + saveConfig(restored); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}` }, + body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), + }); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + // The warm key, on the FIRST send. This path builds its own Authorization header from a + // snapshot resolved before the pick, so a naive wiring would have sent sk-platform-key here + // while the picker had already committed sk-warm-key to config. + expect(captured[0].headers.get("authorization")).toBe("Bearer sk-warm-key"); + } finally { + await server.stop(true); + await upstream.stop(true); + clearKeyCooldowns(); + } +}); + +/** + * The pick COMMITS its choice before returning, so an unresolvable selection is not a reason to + * quietly reuse the previous key: that would authenticate a non-idempotent image POST with a + * credential the config no longer treats as active, and the previous key is the one that was + * cooling. Raised by CodeRabbit on #4292. + */ +test("an unresolvable selected key fails the keyed image send instead of reusing the old one", async () => { + const captured: CapturedRequest[] = []; + const upstream = fakeImagesUpstream(captured); + clearKeyCooldowns(); + delete process.env.OCX_IMAGES_MISSING_KEY; + const pooled = { + ...keyedProvider(upstream.url.toString().replace(/\/$/, "")), + apiKeyPoolStrategy: "round-robin", + apiKeyPool: [ + { id: "first", key: "sk-platform-key" }, + // An env reference that is deliberately not set: a revoked keychain entry looks the same. + { id: "second", key: "\${OCX_IMAGES_MISSING_KEY}" }, + ], + }; + saveConfig({ + port: 0, + defaultProvider: "openai-apikey", + openaiProviderTierVersion: 2, + providers: { openai: disabledOpenAiProvider, "openai-apikey": pooled }, + } as unknown as OcxConfig); + const live = loadConfig(); + rotateKeyOn429(live, "openai-apikey", null, Date.now(), "sk-platform-key"); + const restored = loadConfig(); + restored.providers["openai-apikey"]!.apiKey = "sk-platform-key"; + saveConfig(restored); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}` }, + body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), + }); + expect(response.status).toBe(500); + // Nothing was sent. Red control: restore the `?? candidates.keyed.apiKey` fallback and this + // becomes a 200 carrying Bearer sk-platform-key -- the cooled key the pool had left. + expect(captured).toHaveLength(0); + } finally { + await server.stop(true); + await upstream.stop(true); + clearKeyCooldowns(); + } +}); + +test("without a configured strategy the keyed image send keeps the cooled key", async () => { + const captured: CapturedRequest[] = []; + const upstream = fakeImagesUpstream(captured); + clearKeyCooldowns(); + const pooled = { + ...keyedProvider(upstream.url.toString().replace(/\/$/, "")), + apiKeyPool: [ + { id: "first", key: "sk-platform-key" }, + { id: "second", key: "sk-warm-key" }, + ], + }; + saveConfig({ + port: 0, + defaultProvider: "openai-apikey", + openaiProviderTierVersion: 2, + providers: { openai: disabledOpenAiProvider, "openai-apikey": pooled }, + } as unknown as OcxConfig); + const live = loadConfig(); + rotateKeyOn429(live, "openai-apikey", null, Date.now(), "sk-platform-key"); + const restored = loadConfig(); + restored.providers["openai-apikey"]!.apiKey = "sk-platform-key"; + saveConfig(restored); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}` }, + body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), + }); + expect(response.status).toBe(200); + // Rotation stays reactive-only for an install that never asked for a strategy. + expect(captured[0].headers.get("authorization")).toBe("Bearer sk-platform-key"); + } finally { + await server.stop(true); + await upstream.stop(true); + clearKeyCooldowns(); + } +}); + test("falls back to a keyed openai-responses provider when no forward provider exists", async () => { const captured: CapturedRequest[] = []; const upstream = fakeImagesUpstream(captured); diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index d23bf848dd..418ef993ca 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig, saveConfig } from "../../src/config"; -import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearKeyCooldowns, rotateKeyOn429 } from "../../src/providers/key-failover"; import { deriveXaiConvId } from "../../src/providers/xai-transport"; import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; import { startServer } from "../../src/server"; @@ -664,3 +664,126 @@ describe("server 429 key failover (end-to-end)", () => { } }); }); + + /** + * Both cases land on the same state: the committed key is already cooling when a request + * arrives. That is not exotic -- it is what an operator has after the pool rotated and a + * restart, a manual edit or a config reload pointed `apiKey` back at the spent key. + */ + async function cooledCommittedKeySetup(strategy?: "round-robin" | "fill-first") { + const seen: string[] = []; + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + seen.push(req.headers.get("authorization") ?? ""); + return Response.json({ id: "chatcmpl-warm", object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "warm" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + } }); + saveConfig({ port: 0, hostname: "127.0.0.1", defaultProvider: "pooled", providers: { pooled: { + adapter: "openai-chat", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, + authMode: "key", apiKey: "synthetic-first", + ...(strategy ? { apiKeyPoolStrategy: strategy } : {}), + apiKeyPool: [{ id: "first", key: "synthetic-first" }, { id: "second", key: "synthetic-second" }], + } } } as OcxConfig); + // Cool the committed key exactly the way a real 429 does, then point the stored selection + // back at it. Cooldowns are process-local, so the server started below shares this state. + const live = loadConfig(); + rotateKeyOn429(live, "pooled", null, Date.now(), "synthetic-first"); + const restored = loadConfig(); + restored.providers.pooled!.apiKey = "synthetic-first"; + saveConfig(restored); + return seen; + } + + test("a cooled committed key is replaced before the first attempt", async () => { + const seen = await cooledCommittedKeySetup("round-robin"); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pooled/test", stream: false, messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(200); + // ONE attempt, on the warm key. Reactive rotation alone cannot produce this: it needs a + // 429 first, so without the pre-dispatch pick the upstream would see the cooled key here + // and the request would be spent earning a refusal the runtime could already predict. + expect(seen).toEqual(["Bearer synthetic-second"]); + } finally { + await server.stop(true); + } + }); + + test("without a configured strategy the cooled key is still used", async () => { + const seen = await cooledCommittedKeySetup(); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pooled/test", stream: false, messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(200); + // The other half of the contract: rotation stays reactive-only for an install that never + // asked for a strategy, so the committed key is honoured even when it is cooling. + expect(seen).toEqual(["Bearer synthetic-first"]); + } finally { + await server.stop(true); + } + }); + + /** + * The two cases above pin the behaviour but not the PATH: an `openai-chat` provider sends + * /v1/chat/completions through `handleNativeChatCompletions`, so the pick in + * `responses/core.ts` never runs in either of them. This one goes through /v1/responses, so + * the independently changed core call site is actually covered. + * + * The pool keys are stored as `\${VAR}` references on purpose. Reference resolution is one of + * the backfills `routedProviderConfig` applies and the adapter does not, so the upstream + * bearer proves the route the core path dispatched was a rebuilt one rather than the + * picker's persisted snapshot. + * + * Red control: remove the pick from core.ts and the upstream sees `Bearer resolved-cooled`, + * because the committed selection still points at the cooled key. + * + * What this case does NOT prove is the Transport-vs-snapshot distinction on this path: + * `refreshDispatchAdapter` re-derives the transport from config before dispatch, so the + * Responses core self-heals a wholesale assignment. That contract is pinned as a unit in + * tests/adapters/key-failover.test.ts, where it has a red control that actually fails. + */ + test("the Responses core pick reaches the warm key through /v1/responses", async () => { + const seen: string[] = []; + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + seen.push(req.headers.get("authorization") ?? ""); + return Response.json({ id: "chatcmpl-warm", object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "warm" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + } }); + process.env.OCX_KEYFAIL_COOLED = "resolved-cooled"; + process.env.OCX_KEYFAIL_WARM = "resolved-warm"; + saveConfig({ port: 0, hostname: "127.0.0.1", defaultProvider: "env-pooled", providers: { "env-pooled": { + adapter: "openai-chat", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, + authMode: "key", apiKey: "\${OCX_KEYFAIL_COOLED}", apiKeyPoolStrategy: "round-robin", + apiKeyPool: [ + { id: "cooled", key: "\${OCX_KEYFAIL_COOLED}" }, + { id: "warm", key: "\${OCX_KEYFAIL_WARM}" }, + ], + } } } as OcxConfig); + const live = loadConfig(); + rotateKeyOn429(live, "env-pooled", null, Date.now(), "\${OCX_KEYFAIL_COOLED}"); + const restored = loadConfig(); + restored.providers["env-pooled"]!.apiKey = "\${OCX_KEYFAIL_COOLED}"; + saveConfig(restored); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "env-pooled/test", input: "hi", stream: false }), + }); + expect(response.status).toBe(200); + expect(seen).toEqual(["Bearer resolved-warm"]); + } finally { + await server.stop(true); + delete process.env.OCX_KEYFAIL_COOLED; + delete process.env.OCX_KEYFAIL_WARM; + } + });