From a1ab7f3cb645bb38486ed8d0b775f541e86c437a Mon Sep 17 00:00:00 2001 From: Abhishek Sharma Date: Wed, 23 Sep 2026 22:23:48 -0700 Subject: [PATCH 1/2] feat(combos): explicit last-resort cooldown policy for failover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brief cooldown on a preferred target routes straight to whatever comes next in the list — including a target the operator only ever wanted used in an emergency. There is no way to say "this one is a last resort", so transient cooldown state dispatches it. `cooldownWaitPolicy: "before-last-resort"` plus `lastResort: true` on a target makes selection try the normal targets first. If they are only cooling and the earliest cooldown expires inside the combo's existing `waitForCooldownMs` budget, the request waits for that instead of dispatching the last resort. **The policy only ever defers, and that is the property the tests are built around.** When no normal target can be reached — every one cooling past the budget, already attempted, or ruled out by the caller — the last-resort target is dispatched exactly as today. A policy that could withhold it would turn a fallback into an outage, which is strictly worse than the premature routing it prevents. Five tests cover that one way each: cooling past the budget, excluded, ruled out by the caller's own predicate, a combo whose targets are all last-resort, and a zero wait budget. The deferral wait is scoped to normal targets. A short cooldown on the last-resort target must not make the request sleep on behalf of the very target the policy is avoiding — though the ordinary wait below the policy branch may still wait for it, and should, once it is the only candidate left. The test asserts which branch does the waiting rather than whether any wait happens. Both fields are omitted by default and only the exact literal `before-last-resort` opts in, matching the rule `reasoningEffortMode` already follows. A truthy non-boolean `lastResort` normalizes to false, so a config that fails validation cannot still change routing if it is loaded anyway. The normalizer's null is dropped by `sparseComboConfig`, so stored combos do not gain a meaningless key. Scoped to src/combos/resolve.ts, which #5716 does not touch — that PR changes cooldown *duration* in failover.ts, this one changes *selection*. They merge in either order. Eight mutations, seven caught, including the safety one: withholding the last resort when no normal target is reachable fails immediately. The survivor is an equivalent mutant — the `targets.some(t => !t.lastResort)` guard is a short-circuit that only avoids one wasted selection pass, since the fall-through already handles an all-last-resort combo identically. Recorded rather than papered over with a contrived assertion. Closes #5691 --- docs-site/src/content/docs/guides/combos.md | 34 ++++ src/combos/resolve.ts | 56 ++++++ src/combos/types.ts | 19 +- src/server/management/combo-routes.ts | 10 +- src/types.ts | 1 + src/types/config.ts | 21 ++ .../combo-last-resort.test.ts | 190 ++++++++++++++++++ tests/codex-integration/combos.test.ts | 30 ++- 8 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 tests/codex-integration/combo-last-resort.test.ts diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 166ed292f56..4da9ffe4ff0 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -232,6 +232,40 @@ fallback for upstream rate-limit codes `1302`/`1305` → the 60-second default. `Retry-After: 0` remains an immediate upstream directive rather than being replaced by a configured cooldown. +### Last-resort targets + +A brief cooldown on a preferred target otherwise routes straight to whatever +comes next in the list — including a target you only ever wanted used in an +emergency. Mark it, and tell the combo to wait first: + +```json +{ + "strategy": "failover", + "cooldownWaitPolicy": "before-last-resort", + "waitForCooldownMs": 10000, + "targets": [ + { "provider": "provider-a", "model": "model-a" }, + { "provider": "provider-b", "model": "model-b" }, + { "provider": "provider-c", "model": "model-c", "lastResort": true } + ] +} +``` + +With the policy set, selection tries the normal targets first. If they are only +cooling and the earliest cooldown expires inside `waitForCooldownMs`, the +request waits for that instead of dispatching the last-resort target. + +**The policy only ever defers.** When no normal target can be reached — every +one cooling past the budget, already attempted, or ruled out — the last-resort +target is dispatched as usual. A policy that could withhold it would turn a +fallback into an outage, which is worse than the premature routing it prevents. +The same applies to a combo whose targets are *all* marked `lastResort`: it +dispatches normally. + +`lastResort` is inert unless `cooldownWaitPolicy` is set, and both are omitted +by default, so existing combos are unaffected. Only the exact string +`before-last-resort` opts in. + The current request never retries the same attempted target. Later requests skip a cooled target until its cooldown expires; request-local compatibility rejections do not cool the target. A `Retry-After` HTTP-date that is already in the past is also preserved as an immediate upstream directive, just like `Retry-After: 0`. Set `waitForCooldownMs` to allow a later diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index 13fd4ccf6d3..88fbc066699 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -385,6 +385,62 @@ export async function pickComboTargetWithWait( const eligible = (target: Required): boolean => !isComboTargetInCooldown(comboId, target, now) && (customEligible?.(target) ?? true); + + // #5691: `cooldownWaitPolicy: "before-last-resort"` makes one extra attempt + // over the normal targets alone, so a *brief* cooldown on a preferred target + // waits rather than dispatching a target the operator marked emergency-only. + // + // It only ever defers. Every exit below falls through to the unchanged + // selection, which still sees the last-resort target — a policy that could + // withhold it when no normal target is reachable would turn a fallback into + // an outage, which is worse than the premature routing it prevents. + const policyCombo = getCombo(config, comboId); + const defersLastResort = policyCombo?.cooldownWaitPolicy === "before-last-resort" + && policyCombo.targets.some(target => !target.lastResort); + if (defersLastResort && !options.abortSignal?.aborted) { + const normalOnly = (target: Required): boolean => + !target.lastResort && eligible(target); + const normalPick = pickComboTarget(config, comboId, { + exclude: excluded, + eligible: normalOnly, + now, + }); + if (normalPick) return normalPick; + + const waitable = policyCombo.targets.filter(target => + !target.lastResort + && targetProviderIsUsable(config, target, now) + && !excluded.has(targetKey(target)) + && isComboTargetInCooldown(comboId, target, now) + && (customEligible?.(target) ?? true), + ); + const soonest = earliestComboCooldown(comboId, waitable, now); + const normalDelay = soonest === undefined ? undefined : soonest.expiry - now; + if (normalDelay !== undefined && normalDelay <= options.waitForCooldownMs) { + console.warn( + `[combo] ${comboId}: deferring last resort, waiting ${normalDelay}ms for ${targetKey(soonest!.target)}`, + ); + try { + await (options.sleep ?? sleepWithAbort)(normalDelay, options.abortSignal); + } catch (error) { + if (options.abortSignal?.aborted) return null; + throw error; + } + if (options.abortSignal?.aborted) return null; + // The combo can be deleted or renamed while this request sleeps. + if (!getCombo(config, comboId)) return null; + const waited = pickComboTarget(config, comboId, { + exclude: excluded, + now: now + normalDelay, + eligible: target => !target.lastResort + && !isComboTargetInCooldown(comboId, target, now + normalDelay) + && (customEligible?.(target) ?? true), + }); + if (waited) return waited; + } + // No normal target is reachable. Fall through; the last resort is eligible. + } + const pick = pickComboTarget(config, comboId, { exclude: excluded, eligible, now }); if (pick || options.waitForCooldownMs <= 0 || options.abortSignal?.aborted) return pick; const combo = getCombo(config, comboId); diff --git a/src/combos/types.ts b/src/combos/types.ts index f4b3e26b2fa..56df19417a6 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -1,6 +1,6 @@ import { isCodexReasoningEffort } from "../reasoning-effort"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; -import type { OcxComboConfig, OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxProviderConfig } from "../types"; +import type { OcxComboConfig, OcxComboCooldownWaitPolicy, OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxProviderConfig } from "../types"; import { COMBO_NAMESPACE, isValidComboId, targetKey } from "./identifiers"; export const COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS = 0; @@ -25,6 +25,8 @@ export interface NormalizedComboConfig { stickyLimit: number; cooldownMs?: number; waitForCooldownMs: number; + /** `before-last-resort` defers lastResort targets while a normal one can be waited out (#5691). */ + cooldownWaitPolicy: OcxComboCooldownWaitPolicy | null; defaultEffort: OcxComboDefaultEffort | null; /** Client-precedence policy; `fallback` preserves legacy behavior. */ defaultEffortMode: OcxComboDefaultEffortMode; @@ -161,6 +163,13 @@ export function comboConfigIssues( || body.waitForCooldownMs > 600_000)) { issues.push({ path: ["waitForCooldownMs"], message: "waitForCooldownMs must be an integer from 0 to 600000" }); } + if (body.cooldownWaitPolicy !== undefined && body.cooldownWaitPolicy !== null + && body.cooldownWaitPolicy !== "before-last-resort") { + issues.push({ + path: ["cooldownWaitPolicy"], + message: 'cooldownWaitPolicy must be "before-last-resort" when set', + }); + } if (body.defaultEffort !== undefined && body.defaultEffort !== null && (typeof body.defaultEffort !== "string" || !isCodexReasoningEffort(body.defaultEffort))) { @@ -277,6 +286,12 @@ export function comboConfigIssues( message: `targets[${i}].weight must be an integer from 1 to 10000`, }); } + if (target.lastResort !== undefined && typeof target.lastResort !== "boolean") { + issues.push({ + path: ["targets", i, "lastResort"], + message: `targets[${i}].lastResort must be a boolean`, + }); + } if (provider && model) { const key = targetKey({ provider, model }); @@ -318,6 +333,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig stickyLimit: raw.stickyLimit ?? 1, cooldownMs: raw.cooldownMs, waitForCooldownMs: raw.waitForCooldownMs ?? COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS, + cooldownWaitPolicy: raw.cooldownWaitPolicy === "before-last-resort" ? "before-last-resort" : null, defaultEffort, defaultEffortMode: raw.defaultEffortMode === "force" && defaultEffort !== null ? "force" : "fallback", reasoningEffortMode: raw.reasoningEffortMode === "adaptive" ? "adaptive" : "strict", @@ -329,6 +345,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig provider: target.provider.trim(), model: target.model.trim(), weight: target.weight ?? 1, + lastResort: target.lastResort === true, })), }; } diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index e990252538e..a2524181d76 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -52,7 +52,7 @@ import { setDebugSettings, type DebugFlag, } from "../../lib/debug-settings"; -import type { OcxClaudeCodeConfig, OcxComboConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; +import type { OcxClaudeCodeConfig, OcxComboConfig, OcxConfig, OcxCustomModel, OcxProviderConfig, OcxComboCooldownWaitPolicy } from "../../types"; import { drainAndShutdown } from "../lifecycle"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; @@ -78,12 +78,14 @@ import { COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS } from "../../combos"; function sparseComboConfig(combo: T): Omit & { +}>(combo: T): Omit & { cooldownMs?: number; waitForCooldownMs?: number; + cooldownWaitPolicy?: OcxComboCooldownWaitPolicy; imageInput?: "disabled"; reasoningEffortMode?: "adaptive"; defaultEffortMode?: "force"; @@ -91,6 +93,7 @@ function sparseComboConfig = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "a", + providers: { + a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", apiKey: "ka", models: ["m1"] }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb", models: ["m2"] }, + c: { adapter: "openai-chat", baseUrl: "https://c.example/v1", apiKey: "kc", models: ["m3"] }, + }, + combos: { + free: { + strategy: "failover", + cooldownWaitPolicy: "before-last-resort", + waitForCooldownMs: 10_000, + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + { provider: "c", model: "m3", lastResort: true }, + ], + ...overrides, + }, + }, + } as unknown as OcxConfig; +} + +const NOW = 1_000_000; +const noSleep = async () => {}; + +beforeEach(() => { + clearComboTargetCooldowns(); + clearComboSelectionState(); +}); + +describe("last-resort cooldown policy", () => { + test("a healthy normal target is picked, as before", async () => { + const cfg = config(); + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, sleep: noSleep, + }); + expect(pick?.target.provider).toBe("a"); + }); + + test("a brief cooldown on the preferred target waits instead of taking the last resort", async () => { + const cfg = config(); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 4_000 }); + + const sleeps: number[] = []; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { sleeps.push(ms); }, + }); + expect(sleeps).toEqual([3_000]); + expect(pick?.target.provider).toBe("a"); + expect(pick?.target.provider).not.toBe("c"); + } finally { + warn.mockRestore(); + } + }); + + test("without the policy the last resort is taken immediately, as today", async () => { + const cfg = config({ cooldownWaitPolicy: undefined }); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 4_000 }); + + const sleeps: number[] = []; + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { sleeps.push(ms); }, + }); + expect(sleeps).toEqual([]); + expect(pick?.target.provider).toBe("c"); + }); +}); + +describe("the policy never causes an outage", () => { + test("every normal target cooling beyond the wait budget releases the last resort", async () => { + const cfg = config(); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 600_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 600_000 }); + + const sleeps: number[] = []; + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { sleeps.push(ms); }, + }); + expect(sleeps).toEqual([]); + expect(pick?.target.provider).toBe("c"); + }); + + test("every normal target excluded releases the last resort", async () => { + const cfg = config(); + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, sleep: noSleep, + exclude: ["a/m1", "b/m2"], + }); + expect(pick?.target.provider).toBe("c"); + }); + + test("every normal target ruled out by the caller releases the last resort", async () => { + const cfg = config(); + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, sleep: noSleep, + eligible: target => target.provider === "c", + }); + expect(pick?.target.provider).toBe("c"); + }); + + test("a combo of only last-resort targets still dispatches", async () => { + // Degenerate, but an operator can write it, and "defer the last resort + // until a normal target is available" must not mean "never dispatch". + const cfg = config({ + targets: [{ provider: "c", model: "m3", lastResort: true }], + }); + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, sleep: noSleep, + }); + expect(pick?.target.provider).toBe("c"); + }); + + test("the deferral never waits on the last-resort target's own cooldown", async () => { + // The deferral wait exists to give a *normal* target time to recover. If + // the last-resort target were in that waitable set, a short cooldown on it + // would make the request sleep on behalf of the very target the policy is + // trying not to use yet. + // + // The ordinary wait below the policy branch may still wait for it, and + // should: once no normal target is reachable, the last resort is the only + // candidate, and a one-second cooldown on it is worth waiting out. The + // assertion is therefore about which branch does the waiting, not whether + // any wait happens. + const cfg = config(); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 600_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 600_000 }); + coolComboTarget("free", targets[2]!, { now: NOW, cooldownMs: 1_000 }); + + const warnings: string[] = []; + const warn = spyOn(console, "warn").mockImplementation((message: string) => { + warnings.push(String(message)); + }); + try { + await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, sleep: noSleep, + }); + expect(warnings.some(line => line.includes("deferring last resort"))).toBe(false); + } finally { + warn.mockRestore(); + } + }); + + test("a zero wait budget still releases the last resort rather than failing", async () => { + const cfg = config({ waitForCooldownMs: 0 }); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 3_000 }); + + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 0, now: NOW, sleep: noSleep, + }); + expect(pick?.target.provider).toBe("c"); + }); +}); diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 00cbf4a28fd..f675304c722 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -1547,6 +1547,9 @@ describe("combo validation and normalization", () => { stickyLimit: 1, cooldownMs: undefined, waitForCooldownMs: 0, + // #5691: null unless explicitly configured, so an absent or malformed + // value can never silently opt a combo into deferring its last resort. + cooldownWaitPolicy: null, defaultEffort: "high", defaultEffortMode: "fallback", reasoningEffortMode: "strict", @@ -1554,9 +1557,34 @@ describe("combo validation and normalization", () => { alias: null, nativeAlias: false, displayName: null, - targets: [{ provider: "a", model: "m1", weight: 2 }], + targets: [{ provider: "a", model: "m1", weight: 2, lastResort: false }], }); expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).defaultEffort).toBeNull(); + // #5691: both new fields default to the inert value, and only the exact + // literal opts in — the same rule reasoningEffortMode follows below. + expect(normalizeComboConfig({ + cooldownWaitPolicy: "eventually" as never, + targets: [{ provider: "a", model: "m1" }], + }).cooldownWaitPolicy).toBeNull(); + expect(normalizeComboConfig({ + cooldownWaitPolicy: "before-last-resort", + targets: [{ provider: "a", model: "m1", lastResort: true }], + })).toMatchObject({ + cooldownWaitPolicy: "before-last-resort", + targets: [{ provider: "a", model: "m1", lastResort: true }], + }); + expect(comboConfigIssues("free", { + cooldownWaitPolicy: "eventually" as never, + targets: [{ provider: "a", model: "m1" }], + }, baseConfig().providers).some(issue => issue.path[0] === "cooldownWaitPolicy")).toBe(true); + expect(comboConfigIssues("free", { + targets: [{ provider: "a", model: "m1", lastResort: "yes" as never }], + }, baseConfig().providers).some(issue => issue.path[2] === "lastResort")).toBe(true); + // …and a truthy non-boolean normalizes to false rather than opting in, so a + // config that fails validation cannot still change routing if it is loaded. + expect(normalizeComboConfig({ + targets: [{ provider: "a", model: "m1", lastResort: "yes" as never }], + }).targets[0]!.lastResort).toBe(false); // Anything that is not the literal "adaptive" normalizes to today's behavior, so a // malformed or absent value can never silently opt a user in. expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).reasoningEffortMode).toBe("strict"); From a57f4195cf32fc16cbc01a6a2cf62cbd98872897 Mon Sep 17 00:00:00 2001 From: Abhishek Sharma Date: Wed, 23 Sep 2026 23:16:51 -0700 Subject: [PATCH 2/2] fix(combos): address review on the last-resort cooldown policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the review on #5736, all reproduced before changing anything. **The deferral wait and the ordinary wait now share one budget.** The worst of the four and a bug I introduced. `waitForCooldownMs` is documented as a cap per *selection attempt*, but the fall-through kept the original clock and the full budget, so a 3s deferral followed by a 9s ordinary wait spent 12s against a 10s cap — close to double in the worst case. Both the remaining budget and the clock now advance by whatever the deferral slept, and they are identical to the old values when it did not, so the non-policy path is untouched. The clock half needs its own test: sharing the budget alone still measures the second wait from the original `now`, so a target whose cooldown lapses during the deferral reads as cooling for longer than it is. Pinned by asserting the second sleep is 500ms rather than 3,500ms. **`lastResort: false` is no longer persisted.** The normalizer gives every target an explicit `false`, and the management route wrote normalized targets straight into stored config — so saving any combo added a noise key to every target, including combos that never use the policy. Only the opt-in value is stored now, matching how the combo-level policy is already handled by `sparseComboConfig`. **An omitted policy no longer deletes the stored one.** The management route preserves `cooldownMs`, `waitForCooldownMs` and `defaultEffortMode` when a request omits them; `cooldownWaitPolicy` was missing from that list, so a GUI round-trip would have dropped it. `lastResort` rides on each target and had the same problem, so it is carried over per target, matched on provider and model. **Docs.** The English config table gained rows for both keys, and the four translated guides that carry that table (ja, ko, ru, zh-cn) gained the same two rows. Those translations are mine and should be checked by a native speaker. Two mutations added for the budget fix — not counting the deferral sleep, and not advancing the clock — and both are caught. The re-anchored safety mutation still fails immediately. --- docs-site/src/content/docs/guides/combos.md | 4 +- .../src/content/docs/ja/guides/combos.md | 2 + .../src/content/docs/ko/guides/combos.md | 2 + .../src/content/docs/ru/guides/combos.md | 2 + .../src/content/docs/zh-cn/guides/combos.md | 2 + src/combos/resolve.ts | 39 ++++++++----- src/server/management/combo-routes.ts | 25 ++++++++ .../combo-last-resort.test.ts | 57 +++++++++++++++++++ tests/codex-integration/combos.test.ts | 11 ++++ 9 files changed, 130 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 4da9ffe4ff0..26b53d0771b 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -450,12 +450,14 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | Field | Required | Default | Rules | | --- | --- | --- | --- | -| `targets` | Yes | — | Non-empty ordered array of configured `{ provider, model, weight? }` targets. Duplicate provider/model pairs are rejected. | +| `targets` | Yes | — | Non-empty ordered array of configured `{ provider, model, weight?, lastResort? }` targets. Duplicate provider/model pairs are rejected. | | `targets[].weight` | No | `1` | Integer from 1 to 10,000. Used by round-robin and random; ignored by failover, least-used, and reset-window. | +| `targets[].lastResort` | No | `false` | Marks an emergency-only target. Inert unless `cooldownWaitPolicy` is set. Never makes a target permanently ineligible: when no normal target can be reached it is dispatched as usual. | | `strategy` | No | `"failover"` | `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, or `"reset-window"`. | | `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. Applies only to round-robin. | | `cooldownMs` | No | unset → upstream fallback (5 s for request-rate 429 codes `1302`/`1305`, otherwise 60 s) | Integer from 1 to 600000. When set, applies as the per-target cooldown whenever no usable upstream `Retry-After` or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. | | `waitForCooldownMs` | No | `0` | Integer from 0 to 600000. Maximum time to wait for the earliest eligible cooling target before returning `combo_unavailable`; abort cancels the wait. | +| `cooldownWaitPolicy` | No | unset | `"before-last-resort"` defers targets marked `lastResort` while a normal target is cooling and that cooldown fits inside `waitForCooldownMs`. Only that exact string opts in. The deferral wait and the ordinary wait share one `waitForCooldownMs` budget per selection attempt. | | `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; resolved against each target's advertised ladder. | | `defaultEffortMode` | No | `"fallback"` | `"fallback"` preserves explicit caller effort. `"force"` overrides valid caller effort, requires a valid non-null default, and can increase cost and latency. | | `reasoningEffortMode` | No | `"strict"` | `"strict"` intersects every known target ladder, so one target advertising no effort control empties the combo's picker. `"adaptive"` excludes those empty ladders from the published intersection. At dispatch, explicit empty or adaptive unknown ladders remove unsupported effort/thinking controls while preserving supported non-effort reasoning fields such as `reasoning.summary`; known non-empty targets keep existing effort resolution. | diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index e77bba80380..929711402cc 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -236,10 +236,12 @@ ocx combo remove --yes | --- | --- | --- | --- | | `targets` |はい | — |構成された `{ provider, model, weight? }` ターゲットの空でない順序付けされた配列。重複するプロバイダーとモデルのペアは拒否されます。 | | `targets[].weight` |いいえ | `1` | 1 ~ 10,000 の整数。`round-robin` と `random` で使用され、`failover`、`least-used`、`reset-window` では無視されます。 | +| `targets[].lastResort` | いいえ | `false` | 緊急時専用のターゲットを示します。`cooldownWaitPolicy` を設定しない限り無効です。ターゲットを恒久的に除外することはありません。通常のターゲットに到達できない場合は通常どおりディスパッチされます。 | | `strategy` |いいえ | `"failover"` | `"failover"`、`"round-robin"`、`"random"`、`"least-used"`、`"reset-window"`。 | | `stickyLimit` |いいえ | `1` | `round-robin` の 1 回の選択あたり、成功したリクエスト数を指定する 1 ~ 100 の整数。`round-robin` にのみ適用されます。 | | `cooldownMs` |いいえ | 未設定 → アップストリーム フォールバック(リクエストレート 429 コード `1302`/`1305` では 5 秒、それ以外では 60 秒) | 1 ~ 600000 の整数。設定時は、使用可能なアップストリーム `Retry-After` または Codex リセットシグナルがない場合に、リクエストレート 429 を含むターゲットごとのクールダウンとして適用されます。未設定時はアップストリーム フォールバックを使用します。 | | `waitForCooldownMs` |いいえ | `0` | 0 ~ 600000 の整数。最も早く利用可能になる冷却中のターゲットを待ってから `combo_unavailable` を返すまでの最大待機時間。中止すると待機はキャンセルされます。 | +| `cooldownWaitPolicy` | いいえ | 未設定 | `"before-last-resort"` は、通常のターゲットがクールダウン中で、その残り時間が `waitForCooldownMs` に収まる間、`lastResort` を付けたターゲットを後回しにします。この文字列のみが有効です。後回しの待機と通常の待機は、1 回の選択につき同じ `waitForCooldownMs` の予算を共有します。 | | `defaultEffort` |いいえ | `null` | `low`、`medium`、`high`、`xhigh`、`max`、または `ultra`;呼び出し元が努力を省略し、ターゲットがサポートをアドバタイズした場合にのみ適用されます。 | | `reasoningEffortMode` | いいえ | `"strict"` | `strict` または `adaptive`。混在する capability の共通部分と対象別の制御正規化を選択します。 | | `alias` |いいえ |なし |オプションのトリミングされたパブリック モデル ID。上記のエイリアス ルールを使用します。空の値はエイリアスなしで保存されます。 | diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index b49eb1ae35a..3d630995d50 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -240,10 +240,12 @@ ocx combo remove --yes | --- | --- | --- | --- | | `targets` | 예 | — | 설정된 `{ provider, model, weight? }` 대상의 비어 있지 않은 순서가 있는 배열이어야 합니다. 중복된 provider/model 쌍은 거부됩니다. | | `targets[].weight` | 아니요 | `1` | 1에서 10,000 사이의 정수입니다. `round-robin`과 `random`에서 사용되며, `failover`, `least-used`, `reset-window`에서는 무시됩니다. | +| `targets[].lastResort` | 아니요 | `false` | 비상용 대상임을 표시합니다. `cooldownWaitPolicy`를 설정하지 않으면 아무 효과가 없습니다. 대상을 영구히 제외하지는 않습니다. 일반 대상에 도달할 수 없으면 평소대로 디스패치됩니다. | | `strategy` | 아니요 | `"failover"` | 허용되는 값은 `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, `"reset-window"`입니다. | | `stickyLimit` | 아니요 | `1` | 한 번의 `round-robin` 선택에 유지되는 성공 요청 수로, 1에서 100 사이의 정수입니다. `round-robin`에만 적용됩니다. | | `cooldownMs` | 아니요 | 미설정 → 업스트림 폴백(요청 속도 제한 429 코드 `1302`/`1305`는 5초, 그 외는 60초) | 1에서 600000 사이의 정수입니다. 설정하면 사용 가능한 업스트림 `Retry-After` 또는 Codex 재설정 신호가 없을 때 요청 속도 제한 429를 포함한 대상별 쿨다운으로 적용됩니다. 설정하지 않으면 업스트림 폴백을 사용합니다. | | `waitForCooldownMs` | 아니요 | `0` | 0에서 600000 사이의 정수입니다. `combo_unavailable`을 반환하기 전에 가장 먼저 적합해지는 쿨다운 중인 대상을 기다리는 최대 시간입니다. 중단하면 대기가 취소됩니다. | +| `cooldownWaitPolicy` | 아니요 | 미설정 | `"before-last-resort"`는 일반 대상이 쿨다운 중이고 그 잔여 시간이 `waitForCooldownMs` 안에 들어올 때 `lastResort` 대상을 뒤로 미룹니다. 이 문자열만 적용됩니다. 미루는 대기와 일반 대기는 선택 시도마다 같은 `waitForCooldownMs` 한도를 함께 씁니다. | | `defaultEffort` | 아니요 | `null` | `low`, `medium`, `high`, `xhigh`, `max`, 또는 `ultra`입니다. 호출자가 effort를 생략하고 대상이 지원을 광고할 때만 적용됩니다. | | `reasoningEffortMode` | 아니요 | `"strict"` | `strict` 또는 `adaptive`; 혼합 capability의 교집합과 대상별 제어 정규화를 선택합니다. | | `alias` | 아니요 | 없음 | 선택적으로 앞뒤 공백을 제거한 공개 모델 ID입니다. 위의 alias 규칙을 따릅니다. 빈 값은 alias 없음으로 저장됩니다. | diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index db3e632e09d..f1664a0d159 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -289,10 +289,12 @@ Combo хранятся в объекте верхнего уровня `combos`, | --- | --- | --- | --- | | `targets` | Yes | — | Непустой упорядоченный массив настроенных целей `{ provider, model, weight? }`. Дубли пар provider/model запрещены. | | `targets[].weight` | No | `1` | Целое число от 1 до 10 000. Используется стратегиями `round-robin` и `random`; игнорируется стратегиями `failover`, `least-used` и `reset-window`. | +| `targets[].lastResort` | Нет | `false` | Помечает цель как резервную, только для аварийных случаев. Не действует, пока не задан `cooldownWaitPolicy`. Никогда не исключает цель навсегда: если ни одна обычная цель недоступна, она используется как обычно. | | `strategy` | No | `"failover"` | `"failover"`, `"round-robin"`, `"random"`, `"least-used"` или `"reset-window"`. | | `stickyLimit` | No | `1` | Целое число от 1 до 100 успешных запросов на один выбор `round-robin`. Применяется только к `round-robin`. | | `cooldownMs` | No | не задано → fallback upstream (5 с для rate-limit 429 с кодами `1302`/`1305`, иначе 60 с) | Целое число от 1 до 600000. Если задано, применяется как cooldown каждой цели, когда нет пригодного upstream `Retry-After` или сигнала сброса Codex, включая rate-limit 429; если не задано, используется fallback upstream. | | `waitForCooldownMs` | No | `0` | Целое число от 0 до 600000. Максимальное время ожидания самой ранней подходящей цели в cooldown перед возвратом `combo_unavailable`; отмена запроса отменяет ожидание. | +| `cooldownWaitPolicy` | Нет | не задан | `"before-last-resort"` откладывает цели с `lastResort`, пока обычная цель находится в остывании и это остывание укладывается в `waitForCooldownMs`. Включает только эта строка. Отложенное ожидание и обычное ожидание делят один бюджет `waitForCooldownMs` на одну попытку выбора. | | `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max` или `ultra`; применяется только когда вызывающая сторона не указала effort, а цель объявляет поддержку. | | `reasoningEffortMode` | Нет | `"strict"` | `strict` или `adaptive`; задаёт пересечение возможностей и нормализацию параметров конкретной цели. | | `alias` | No | none | Необязательный обрезанный публичный id модели; используйте правила alias выше. Пустое значение хранится как отсутствие alias. | diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index dae57bd773f..acf12482a57 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -265,10 +265,12 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键: | --- | --- | --- | --- | | `targets` | 是 | — | 非空、有顺序的数组,元素为已配置的 `{ provider, model, weight? }` 目标。重复的 provider/model 对会被拒绝。 | | `targets[].weight` | 否 | `1` | 1 到 10,000 的整数。`round-robin` 和 `random` 会使用它;`failover`、`least-used` 和 `reset-window` 会忽略它。 | +| `targets[].lastResort` | 否 | `false` | 标记为仅在紧急情况下使用的目标。未设置 `cooldownWaitPolicy` 时不生效。它不会永久排除该目标:当没有普通目标可用时,仍会照常派发。 | | `strategy` | 否 | `"failover"` | `"failover"`、`"round-robin"`、`"random"`、`"least-used"` 或 `"reset-window"`。 | | `stickyLimit` | 否 | `1` | 每次 `round-robin` 选择可连续处理 1 到 100 个成功请求。仅适用于 `round-robin`。 | | `cooldownMs` | 否 | 未设置 → 上游回退值(请求速率限制代码为 `1302`/`1305` 的 429 为 5 秒,否则为 60 秒) | 1 到 600000 的整数。设置后,只要没有可用的上游 `Retry-After` 或 Codex 重置信号,就会作为每个目标的冷却时间应用,包括请求速率限制 429;未设置时使用上游回退值。 | | `waitForCooldownMs` | 否 | `0` | 0 到 600000 的整数。在返回 `combo_unavailable` 前等待最早恢复资格的冷却中目标的最长时间;请求中止会取消等待。 | +| `cooldownWaitPolicy` | 否 | 未设置 | `"before-last-resort"` 会在普通目标处于冷却中、且其剩余时间在 `waitForCooldownMs` 之内时,推迟使用标记为 `lastResort` 的目标。仅此字符串生效。推迟等待与普通等待在每次选择中共用同一个 `waitForCooldownMs` 预算。 | | `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;仅当调用方省略 effort 且目标声明支持时才会应用。 | | `reasoningEffortMode` | 否 | `"strict"` | `strict` 或 `adaptive`;选择混合能力交集和目标级控制归一化。 | | `imageInput` | 否 | `"auto"` | `"auto"` 或 `"disabled"`。`"auto"` 仅在每个目标都支持图片时发布图片能力;`"disabled"` 强制仅文本(从对外能力中去掉图片,并在分发前拒绝带图请求)。 | diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index 88fbc066699..3d433a634d0 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -382,9 +382,14 @@ export async function pickComboTargetWithWait( const now = options.now ?? Date.now(); const excluded = new Set(options.exclude ?? []); const customEligible = options.eligible; - const eligible = (target: Required): boolean => - !isComboTargetInCooldown(comboId, target, now) + const eligibleAt = (target: Required, at: number): boolean => + !isComboTargetInCooldown(comboId, target, at) && (customEligible?.(target) ?? true); + const eligible = (target: Required): boolean => eligibleAt(target, now); + // Milliseconds already slept inside this call. `waitForCooldownMs` is documented as a cap + // per *selection attempt*, so a deferral wait and the ordinary wait below must share it — + // otherwise a 3s deferral followed by a 9s ordinary wait spends 12s against a 10s budget. + let spentWaitMs = 0; // #5691: `cooldownWaitPolicy: "before-last-resort"` makes one extra attempt // over the normal targets alone, so a *brief* cooldown on a preferred target @@ -426,6 +431,7 @@ export async function pickComboTargetWithWait( if (options.abortSignal?.aborted) return null; throw error; } + spentWaitMs += normalDelay; if (options.abortSignal?.aborted) return null; // The combo can be deleted or renamed while this request sleeps. if (!getCombo(config, comboId)) return null; @@ -441,20 +447,29 @@ export async function pickComboTargetWithWait( // No normal target is reachable. Fall through; the last resort is eligible. } - const pick = pickComboTarget(config, comboId, { exclude: excluded, eligible, now }); - if (pick || options.waitForCooldownMs <= 0 || options.abortSignal?.aborted) return pick; + // Both advance when the deferral above slept; they are identical to `now` and + // `options.waitForCooldownMs` when it did not, so the non-policy path is unchanged. + const clock = now + spentWaitMs; + const remainingWaitMs = options.waitForCooldownMs - spentWaitMs; + + const pick = pickComboTarget(config, comboId, { + exclude: excluded, + eligible: target => eligibleAt(target, clock), + now: clock, + }); + if (pick || remainingWaitMs <= 0 || options.abortSignal?.aborted) return pick; const combo = getCombo(config, comboId); if (!combo) throw new UnknownComboError(comboId); const waitingTargets = combo.targets.filter(target => - targetProviderIsUsable(config, target, now) + targetProviderIsUsable(config, target, clock) && !excluded.has(targetKey(target)) - && isComboTargetInCooldown(comboId, target, now) + && isComboTargetInCooldown(comboId, target, clock) && (customEligible?.(target) ?? true), ); - const earliest = earliestComboCooldown(comboId, waitingTargets, now); + const earliest = earliestComboCooldown(comboId, waitingTargets, clock); if (earliest === undefined) return null; - const delay = earliest.expiry - now; - if (delay > options.waitForCooldownMs) return null; + const delay = earliest.expiry - clock; + if (delay > remainingWaitMs) return null; // The expiry computation above is the single source of truth for the wait budget. // Its target preserves configured order for ties. const target = earliest.target; @@ -472,10 +487,8 @@ export async function pickComboTargetWithWait( if (!getCombo(config, comboId)) return null; return pickComboTarget(config, comboId, { exclude: excluded, - now: now + delay, - eligible: targetCandidate => - !isComboTargetInCooldown(comboId, targetCandidate, now + delay) - && (customEligible?.(targetCandidate) ?? true), + now: clock + delay, + eligible: targetCandidate => eligibleAt(targetCandidate, clock + delay), }); } diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index a2524181d76..5dd0a31ecea 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -180,6 +180,24 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise>).map(target => { + if (Object.hasOwn(target, "lastResort")) return target; + const before = previous.targets.find( + candidate => candidate.provider === target.provider && candidate.model === target.model, + ); + return before?.lastResort ? { ...target, lastResort: true } : target; + }), + } + : {}), // The dashboard does not expose this advanced CLI/API policy. Preserve it when // a GUI round-trip omits the field instead of silently downgrading to fallback. ...(!Object.hasOwn(requestedCombo, "defaultEffortMode") && previous?.defaultEffortMode !== undefined @@ -204,6 +222,13 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise + lastResort ? { ...target, lastResort: true } : target, + ), ...(normalizedAlias ? { alias: normalizedAlias } : {}), ...(normalizedNativeAlias ? { nativeAlias: true } : {}), ...(normalizedDisplayName ? { displayName: normalizedDisplayName } : {}), diff --git a/tests/codex-integration/combo-last-resort.test.ts b/tests/codex-integration/combo-last-resort.test.ts index d8ec18f3172..5267631c30a 100644 --- a/tests/codex-integration/combo-last-resort.test.ts +++ b/tests/codex-integration/combo-last-resort.test.ts @@ -176,6 +176,63 @@ describe("the policy never causes an outage", () => { } }); + test("a deferral wait and the ordinary wait share one budget", async () => { + // Reported on #5736. `waitForCooldownMs` is a cap per *selection attempt*, so the two + // waits inside one call must not each spend it. Here the normal target's cooldown ends + // at 3s but it re-cools immediately, and the last resort frees at 9s: waiting 3s and + // then a further 9s spends 12s against a 10s budget. + const cfg = config(); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 600_000 }); + coolComboTarget("free", targets[2]!, { now: NOW, cooldownMs: 9_000 }); + + const sleeps: number[] = []; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { + sleeps.push(ms); + // The normal target re-cools the moment its first cooldown lapses, which is what + // sends the call on to the ordinary wait with budget already spent. + coolComboTarget("free", targets[0]!, { now: NOW + 3_000, cooldownMs: 600_000 }); + }, + }); + const total = sleeps.reduce((sum, ms) => sum + ms, 0); + expect(total).toBeLessThanOrEqual(10_000); + } finally { + warn.mockRestore(); + } + }); + + test("the ordinary wait measures from after the deferral slept, not before it", async () => { + // Sharing the budget is not enough on its own: the clock has to move too. The normal + // target frees at 3s (and immediately re-cools); the last resort frees at 3.5s. After + // sleeping 3s the remaining wait is 500ms, not the 3,500ms it would be if the fall-through + // still measured from the original `now`. + const cfg = config(); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 600_000 }); + coolComboTarget("free", targets[2]!, { now: NOW, cooldownMs: 3_500 }); + + const sleeps: number[] = []; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { + sleeps.push(ms); + coolComboTarget("free", targets[0]!, { now: NOW + 3_000, cooldownMs: 600_000 }); + }, + }); + expect(sleeps).toEqual([3_000, 500]); + } finally { + warn.mockRestore(); + } + }); + test("a zero wait budget still releases the last resort rather than failing", async () => { const cfg = config({ waitForCooldownMs: 0 }); const targets = cfg.combos!.free!.targets; diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index f675304c722..039bcc5cbae 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -1585,6 +1585,17 @@ describe("combo validation and normalization", () => { expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1", lastResort: "yes" as never }], }).targets[0]!.lastResort).toBe(false); + // #5736: the normalizer's explicit `false` must not reach stored config. Targets that + // never opt in keep exactly the keys they had, so enabling this feature does not add a + // noise key to every target of every combo in the file. + const sparseTargets = normalizeComboConfig({ + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: true }, + ], + }).targets.map(({ lastResort, ...target }) => (lastResort ? { ...target, lastResort: true } : target)); + expect(Object.hasOwn(sparseTargets[0]!, "lastResort")).toBe(false); + expect(sparseTargets[1]).toMatchObject({ lastResort: true }); // Anything that is not the literal "adaptive" normalizes to today's behavior, so a // malformed or absent value can never silently opt a user in. expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).reasoningEffortMode).toBe("strict");