From 5bd616e55bd765f147ca66783aea9637b943484e Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 05:03:15 +0800 Subject: [PATCH 1/2] fix(combos): name per-target exclusion reasons on combo_unavailable A zero-attempt "No available targets for combo: " said nothing about why every target was skipped. The message now lists one coarse label per target from the picker's own checks: provider missing, provider disabled, quota exhausted, cooldown s, or ineligible for this request. Only the configured provider/model is named, never account or request data. Co-Authored-By: Claude Code --- docs-site/src/content/docs/guides/combos.md | 5 +- src/combos/index.ts | 1 + src/combos/resolve.ts | 46 ++++++++++++++++--- src/server/responses/core-combo.ts | 8 ++-- src/server/responses/core-errors.ts | 6 +-- src/server/responses/request-prepare.ts | 13 ++++-- structure/runtime.md | 2 +- .../responses-combo-exhausted-error.test.ts | 42 +++++++++++++++++ ...combo-reasoning-replay-eligibility.test.ts | 2 +- 9 files changed, 104 insertions(+), 21 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 942feb374b4..7673c56b727 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -873,7 +873,10 @@ running opencodex instance that receives model requests. Every target is currently ineligible: for example, its provider is disabled, it is cooling down, it has already been attempted for this request, or an encrypted v2 task excludes it. Check target -provider state and recent upstream errors. For cooldowns, follow an observed `Retry-After` value first; +provider state and recent upstream errors. When no target was attempted, the error message lists +why each target was skipped, for example +`No available targets for combo: fast (openai/gpt-6: quota exhausted; anthropic/claude-opus-5-5: cooldown 42s)`. +For cooldowns, follow an observed `Retry-After` value first; Codex reset headers also take precedence over `cooldownMs`. If neither upstream signal is usable, the configured `cooldownMs` applies, or the upstream fallback applies when it is unset (5 seconds for request-rate codes `1302`/`1305`, 10 minutes for a spent usage diff --git a/src/combos/index.ts b/src/combos/index.ts index 6db073f0a79..0b22c99d814 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -22,6 +22,7 @@ export { export { advanceComboAfterFailure, clearComboSelectionState, + comboExclusionReasons, NoAvailableComboTargetsError, noteComboFailure, noteComboSuccess, diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index 59559154706..c576cac87d4 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -55,17 +55,49 @@ export class UnknownComboError extends Error { export class NoAvailableComboTargetsError extends Error { readonly code = "combo_unavailable"; - constructor(readonly comboId: string) { - super(`No available targets for combo: ${comboId}`); + constructor(readonly comboId: string, readonly reasons = "") { + super(`No available targets for combo: ${comboId}${reasons ? ` (${reasons})` : ""}`); this.name = "NoAvailableComboTargetsError"; } } +function providerExclusionReason(config: OcxConfig, target: OcxComboTarget, now: number): string | undefined { + const provider = Object.hasOwn(config.providers, target.provider) ? config.providers[target.provider] : undefined; + if (!provider) return "provider missing"; + if (provider.disabled === true) return "provider disabled"; + return cachedProviderQuotaIsExhausted(getCachedProviderRoutingQuota(target.provider, provider, now), now, target.model) + ? "quota exhausted" + : undefined; +} + function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget, now: number): boolean { - if (!Object.hasOwn(config.providers, target.provider)) return false; - const provider = config.providers[target.provider]; - if (!provider || provider.disabled === true) return false; - return !cachedProviderQuotaIsExhausted(getCachedProviderRoutingQuota(target.provider, provider, now), now, target.model); + return providerExclusionReason(config, target, now) === undefined; +} + +/** + * Why each target of a combo cannot be picked right now, for a zero-attempt refusal. Only the + * configured provider/model and a coarse label are named, never account or request data. + */ +export function comboExclusionReasons( + config: OcxConfig, + comboId: string, + eligible?: (target: NormalizedComboTarget) => boolean, + now = Date.now(), +): string { + return (getCombo(config, comboId)?.targets ?? []).flatMap(target => { + let reason = providerExclusionReason(config, target, now); + const cooldown = reason ? undefined : earliestComboCooldown(comboId, [target], now); + if (cooldown) reason = `cooldown ${Math.max(1, Math.ceil((cooldown.expiry - now) / 1000))}s`; + if (!reason && eligible) { + try { + if (!eligible(target)) reason = "ineligible for this request"; + } catch { + // An eligibility check can exhaust the translator budget; the target still was not picked. + reason = "ineligible for this request"; + } + } + return reason ? [`${targetKey(target)}: ${reason}`] : []; + }).join("; "); } /** Capture usable providers' quota cooldowns; request eligibility is deferred to exhaustion. */ @@ -612,6 +644,6 @@ export function tryPickComboModel(config: OcxConfig, modelId: string, preview = if (!comboId) return null; if (!getCombo(config, comboId)) throw new UnknownComboError(comboId); const picked = pickComboTarget(config, comboId, { preview }); - if (!picked) throw new NoAvailableComboTargetsError(comboId); + if (!picked) throw new NoAvailableComboTargetsError(comboId, comboExclusionReasons(config, comboId)); return picked; } diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index f4714f0c9b7..c212e846c45 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -31,6 +31,7 @@ import { comboDefaultEffort, isComboTargetInCooldown, snapshotComboQuotaCooldowns, + comboExclusionReasons, noteComboSuccess, comboFailureDecision, advanceComboAfterFailure, @@ -549,7 +550,8 @@ export async function executeComboResponses( ); return options.abortSignal?.aborted ? clientCancelledResponse() - : comboUnavailable(comboId); + : comboUnavailable(comboId, undefined, + comboExclusionReasons(config, comboId, target => meteredAllowed || !target.metered)); } if (!(await recoverUnreadableEncryptedTask())) { return options.abortSignal?.aborted @@ -565,13 +567,13 @@ export async function executeComboResponses( if (onlyReplayIncompatibleTargetsRemain()) return targetIncompatibleResponse(); return options.abortSignal?.aborted ? clientCancelledResponse() - : comboUnavailable(comboId); + : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible)); } let jevDecision: JevDecision | undefined; if (combo.strategy === "jev") { const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now()); const first = choices[0]; - if (!first) return comboUnavailable(comboId); + if (!first) return comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible)); const resolvedFailOpenEffort = resolveEffortAtOrBelow( "medium", first.candidate.reasoningEfforts, diff --git a/src/server/responses/core-errors.ts b/src/server/responses/core-errors.ts index 8e067cad317..32fa78e6ef9 100644 --- a/src/server/responses/core-errors.ts +++ b/src/server/responses/core-errors.ts @@ -8,7 +8,7 @@ import { DecompressedBodyTooLargeError, describeInboundBodyRefusal, } from "../request-decompress"; -import { comboCooldownRetryAfterSeconds } from "../../combos"; +import { comboCooldownRetryAfterSeconds, NoAvailableComboTargetsError } from "../../combos"; import type { AgentTaskRecoveryFailureReason } from "./agent-task-recovery"; /** @@ -115,8 +115,8 @@ export function comboUnavailableResponse( } -export function comboUnavailable(comboId: string, now = Date.now()): Response { - return comboUnavailableResponse(`No available targets for combo: ${comboId}`, { +export function comboUnavailable(comboId: string, now = Date.now(), reasons = ""): Response { + return comboUnavailableResponse(new NoAvailableComboTargetsError(comboId, reasons).message, { retryAfter: comboCooldownRetryAfterSeconds(comboId, now), }); } diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 35871ccfc70..3b7010d4d2f 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -17,7 +17,7 @@ import { unreadableEncryptedAgentTaskResponse, } from "./core-errors"; import { parseSyntheticRowId } from "../fast-row"; -import { resolveComboId, comboIdFromRawBody, NoAvailableComboTargetsError, pickComboTarget } from "../../combos"; +import { resolveComboId, comboIdFromRawBody, comboExclusionReasons, NoAvailableComboTargetsError, pickComboTarget } from "../../combos"; import { INTERCEPT_TARGET_UNAVAILABLE_CODE, interceptTargetUnavailableResponse, resolveChosenTarget, resolveShadowCallTarget } from "./shadow-target-availability"; import { MEMORY_MODEL_TARGET_UNAVAILABLE_CODE, @@ -645,7 +645,10 @@ export async function prepareResponsesRequest( config, options.admission, config.combos?.[candidate.combo.comboId], )) { const pick = pickComboTarget(config, candidate.combo.comboId, { eligible: target => !target.metered }); - if (!pick) throw new NoAvailableComboTargetsError(candidate.combo.comboId); + if (!pick) { + throw new NoAvailableComboTargetsError(candidate.combo.comboId, + comboExclusionReasons(config, candidate.combo.comboId, target => !target.metered)); + } const routed = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`); candidate = { ...routed, combo: pick, routeKind: "combo", routeReason: routed.routeReason === "blocked-model-redirect" ? "blocked-model-redirect" : "combo-pick" }; } @@ -788,7 +791,7 @@ export async function prepareResponsesRequest( if (policyRefusal) return policyRefusal; if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { - return comboUnavailable(err.comboId); + return comboUnavailable(err.comboId, undefined, err.reasons); } if (err instanceof NoEligiblePolicyCandidateError) { // Persist the evaluation trace (per-candidate exclusions + the @@ -1006,7 +1009,7 @@ export async function prepareResponsesRequest( if (policyRefusal) return policyRefusal; if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { - return comboUnavailable(err.comboId); + return comboUnavailable(err.comboId, undefined, err.reasons); } if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; @@ -1223,7 +1226,7 @@ export async function prepareResponsesRequest( if (policyRefusal) return policyRefusal; if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { - return comboUnavailable(err.comboId); + return comboUnavailable(err.comboId, undefined, err.reasons); } if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; diff --git a/structure/runtime.md b/structure/runtime.md index dc720c31f82..a8f450b95df 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -504,7 +504,7 @@ This is also why the classifier cannot duplicate visible output. Native byte str Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, `tests/routing/routing-policy-fallback.test.ts`, `tests/helpers/combo-context-overflow-cases.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. -`src/combos/failover.ts` uses a 10-minute fallback for a spent account usage window (codes `usage_limit_exceeded`, `usage_limit_reached`, `1308`, or `usage limit reached` / `usage limit has been reached` / `token-plan quota has been exhausted` prose, including HTTP 502) and for provider-scoped credential or billing failure codes such as `invalid_api_key` and `insufficient_quota`. This duration does not change failure classification or cooldown scope; upstream retry/reset signals and configured durations retain precedence. It caps explicit upstream `Retry-After` target cooldowns at 24 hours while reset-derived, configured, and fallback cooldowns remain capped at 10 minutes. `src/combos/resolve.ts` omits a target cooldown for a 429 only when the failed child identifies one account in an Anthropic OAuth or canonical Codex pool and that account has a live pool cooldown. `src/server/responses/core-combo.ts` checks the pool's health record before passing that attribution; unknown accounts, ambiguous labels, API-key and generic OAuth providers, and other failures keep their ordinary target cooldown. The Anthropic OAuth pool's own all-accounts-cooled 429 is the one unattributed exception: `isAnthropicPoolLocalRefusal` in `core-combo.ts` requires the `anthropic` provider with `authMode: "oauth"`, status 429, no identified failed account, no eligible pool account and a live pool retry-after. The target then still cools, but for the local fallback instead of the restated per-account `Retry-After`, so a resumed or newly added account is not ignored until a 24-hour target cooldown expires. An upstream 429 on an API-key provider or on an identified pool account keeps its full `Retry-After`. Regression coverage: `tests/routing/always-on-429-failover.test.ts`. Cooldown entries in `src/combos/failover.ts` retain the failure status. The quota-only snapshot query records target/expiry pairs for unexpired 429/402 cooldowns using only cooldown map lookups. Its config-aware wrapper in `src/combos/resolve.ts` applies the picker's provider availability and cached quota checks before dispatch; request eligibility runs lazily only when exhaustion considers a 503, with throwing checks treated as ineligible. Responses exhaustion uses this evidence under the [failover contract](transports/responses-failover.md); ordinary cooldown selection and timing stay unchanged. +`src/combos/failover.ts` uses a 10-minute fallback for a spent account usage window (codes `usage_limit_exceeded`, `usage_limit_reached`, `1308`, or `usage limit reached` / `usage limit has been reached` / `token-plan quota has been exhausted` prose, including HTTP 502) and for provider-scoped credential or billing failure codes such as `invalid_api_key` and `insufficient_quota`. This duration does not change failure classification or cooldown scope; upstream retry/reset signals and configured durations retain precedence. It caps explicit upstream `Retry-After` target cooldowns at 24 hours while reset-derived, configured, and fallback cooldowns remain capped at 10 minutes. `src/combos/resolve.ts` omits a target cooldown for a 429 only when the failed child identifies one account in an Anthropic OAuth or canonical Codex pool and that account has a live pool cooldown. `src/server/responses/core-combo.ts` checks the pool's health record before passing that attribution; unknown accounts, ambiguous labels, API-key and generic OAuth providers, and other failures keep their ordinary target cooldown. The Anthropic OAuth pool's own all-accounts-cooled 429 is the one unattributed exception: `isAnthropicPoolLocalRefusal` in `core-combo.ts` requires the `anthropic` provider with `authMode: "oauth"`, status 429, no identified failed account, no eligible pool account and a live pool retry-after. The target then still cools, but for the local fallback instead of the restated per-account `Retry-After`, so a resumed or newly added account is not ignored until a 24-hour target cooldown expires. An upstream 429 on an API-key provider or on an identified pool account keeps its full `Retry-After`. Regression coverage: `tests/routing/always-on-429-failover.test.ts`. Cooldown entries in `src/combos/failover.ts` retain the failure status. The quota-only snapshot query records target/expiry pairs for unexpired 429/402 cooldowns using only cooldown map lookups. Its config-aware wrapper in `src/combos/resolve.ts` applies the picker's provider availability and cached quota checks before dispatch; request eligibility runs lazily only when exhaustion considers a 503, with throwing checks treated as ineligible. Responses exhaustion uses this evidence under the [failover contract](transports/responses-failover.md); ordinary cooldown selection and timing stay unchanged. A zero-attempt `combo_unavailable` names why each target was skipped. `comboExclusionReasons` in `src/combos/resolve.ts` derives one coarse label per target from the picker's own checks, in picker order: `provider missing`, `provider disabled`, `quota exhausted`, `cooldown s`, then `ineligible for this request` when the caller's eligibility predicate refuses it or throws. `NoAvailableComboTargetsError` and `comboUnavailable` append them to the message; the label carries only the configured provider/model, never account, credential or request data. Coverage: `tests/responses/responses-combo-exhausted-error.test.ts`. ## Combo default effort precedence diff --git a/tests/responses/responses-combo-exhausted-error.test.ts b/tests/responses/responses-combo-exhausted-error.test.ts index de4494c5e98..c0c1410004c 100644 --- a/tests/responses/responses-combo-exhausted-error.test.ts +++ b/tests/responses/responses-combo-exhausted-error.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { clearComboSelectionState, clearComboTargetCooldowns, coolComboTarget, snapshotComboQuotaCooldowns } from "../../src/combos"; import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearCachedProviderQuotas, setCachedProviderQuotaForTests } from "../../src/providers/quota-routing-cache"; +import type { ProviderQuota } from "../../src/providers/quota-types"; import { handleComboResponses, handleResponses } from "../../src/server/responses/core"; import { executeComboResponses } from "../../src/server/responses/core-combo"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; @@ -288,3 +290,43 @@ describe("combo exhaustion reports the primary's quota refusal", () => { expect(response.status).toBe(502); }); }); + +describe("zero-attempt combo refusal names why each target was excluded", () => { + afterEach(() => clearCachedProviderQuotas()); + + test("missing, disabled, quota-spent, cooling and request-ineligible targets each carry a reason", async () => { + setCachedProviderQuotaForTests("spent", { updatedAt: Date.now(), weeklyPercent: 100 } as ProviderQuota); + coolComboTarget("all", { provider: "cooling", model: "model-cooling" }, { retryAfter: "42", status: 429 }); + const cfg = { + defaultProvider: "spent", + providers: { + off: { ...provider("off"), disabled: true }, + spent: provider("spent"), + cooling: provider("cooling"), + metered: provider("metered"), + }, + combos: { + all: { + strategy: "failover", + targets: [ + { provider: "gone", model: "model-gone" }, + { provider: "off", model: "model-off" }, + { provider: "spent", model: "model-spent" }, + { provider: "cooling", model: "model-cooling" }, + { provider: "metered", model: "model-metered", metered: true }, + ], + }, + }, + } as unknown as OcxConfig; + globalThis.fetch = (async () => { throw new Error("no target may be dispatched"); }) as unknown as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/all", stream: false, input: "hello" }), + }), cfg, { model: "", provider: "" } as RequestLogContext); + expect(response.status).toBe(503); + const { error } = await response.json() as { error: { code: string; message: string } }; + expect(error.code).toBe("combo_unavailable"); + expect(error.message).toMatch(/^No available targets for combo: all \(gone\/model-gone: provider missing; off\/model-off: provider disabled; spent\/model-spent: quota exhausted; cooling\/model-cooling: cooldown 4[12]s; metered\/model-metered: ineligible for this request\)$/); + }); +}); diff --git a/tests/server/server-combo-reasoning-replay-eligibility.test.ts b/tests/server/server-combo-reasoning-replay-eligibility.test.ts index 38451a7f950..c9a9a2d2539 100644 --- a/tests/server/server-combo-reasoning-replay-eligibility.test.ts +++ b/tests/server/server-combo-reasoning-replay-eligibility.test.ts @@ -175,7 +175,7 @@ describe("combo mandatory reasoning replay failover", () => { expect(response.status).toBe(503); expect(response.headers.get("content-type")).toBe("application/json"); expect(await response.text()).toBe( - '{"error":{"message":"No available targets for combo: free","type":"server_error","code":"combo_unavailable"}}', + '{"error":{"message":"No available targets for combo: free (missing/bad-model: provider missing)","type":"server_error","code":"combo_unavailable"}}', ); }); From e63e160fb4ad404e955b394ff5dccbcb2562dbad Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 05:11:48 +0800 Subject: [PATCH 2/2] fix(combos): name unsupported JEV effort allowlists in a zero-choice 503 eligibleJevComboChoices drops a target whose explicit effort allowlist has no supported effort, a filter targetEligible does not apply, so the exclusion reasons came back empty. The diagnostic predicate now applies the same check via the extracted jevChoiceEfforts; selection is unchanged. Co-Authored-By: Claude Code --- src/server/responses/core-combo.ts | 48 ++++++++++++------- .../responses/jev-initial-effort-wire.test.ts | 14 ++++++ 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index c212e846c45..c47b243e45b 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -252,6 +252,30 @@ interface JevComboChoice { candidate: JevCandidate; } +/** A target's JEV effort choices, or undefined when an explicit allowlist has none still supported. */ +function jevChoiceEfforts( + config: OcxConfig, + target: NonNullable>["targets"][number], +): OcxComboDefaultEffort[] | undefined { + let ladder: string[] | undefined; + try { + const route = routeConcreteModel(config, targetKey(target)); + ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId }); + } catch { + // Preserve the existing routing-failure surface. Unknown capability becomes the explicit + // no-effort choice rather than broadening JEV's effort allowlist. + ladder = undefined; + } + const supportedEfforts = (ladder ?? []).filter(isCodexReasoningEffort) as OcxComboDefaultEffort[]; + const configuredEfforts = target.reasoningEfforts; + const reasoningEfforts = configuredEfforts === undefined + ? supportedEfforts + : configuredEfforts.filter(effort => supportedEfforts.includes(effort)); + // An explicit allowlist is restrictive. If catalog capabilities drift until no configured + // effort remains supported, omit the target instead of silently broadening JEV's choices. + return configuredEfforts !== undefined && reasoningEfforts.length === 0 ? undefined : reasoningEfforts; +} + /** Enumerate the current ordinary Combo eligibility set without retaining attempted picks. */ function eligibleJevComboChoices( config: OcxConfig, @@ -270,23 +294,8 @@ function eligibleJevComboChoices( excluded.add(key); // The TypeSafe row owns a decision credential, not an inference transport. if (pick.target.provider === JEV_PROVIDER_ID) continue; - let ladder: string[] | undefined; - try { - const route = routeConcreteModel(config, key); - ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId }); - } catch { - // Preserve the existing routing-failure surface. Unknown capability becomes the explicit - // no-effort choice rather than broadening JEV's effort allowlist. - ladder = undefined; - } - const supportedEfforts = (ladder ?? []).filter(isCodexReasoningEffort) as OcxComboDefaultEffort[]; - const configuredEfforts = pick.target.reasoningEfforts; - const reasoningEfforts = configuredEfforts === undefined - ? supportedEfforts - : configuredEfforts.filter(effort => supportedEfforts.includes(effort)); - // An explicit allowlist is restrictive. If catalog capabilities drift until no configured - // effort remains supported, omit the target instead of silently broadening JEV's choices. - if (configuredEfforts !== undefined && reasoningEfforts.length === 0) continue; + const reasoningEfforts = jevChoiceEfforts(config, pick.target); + if (!reasoningEfforts) continue; choices.push({ pick: { ...pick, attempted: [key] }, candidate: { @@ -573,7 +582,10 @@ export async function executeComboResponses( if (combo.strategy === "jev") { const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now()); const first = choices[0]; - if (!first) return comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible)); + if (!first) { + return comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, target => + targetEligible(target) && target.provider !== JEV_PROVIDER_ID && jevChoiceEfforts(config, target) !== undefined)); + } const resolvedFailOpenEffort = resolveEffortAtOrBelow( "medium", first.candidate.reasoningEfforts, diff --git a/tests/responses/jev-initial-effort-wire.test.ts b/tests/responses/jev-initial-effort-wire.test.ts index 513aeb406df..1886db30755 100644 --- a/tests/responses/jev-initial-effort-wire.test.ts +++ b/tests/responses/jev-initial-effort-wire.test.ts @@ -230,6 +230,20 @@ test("ordinary force invariant is not relaxed", () => { .toThrow("force combo default effort requires a valid defaultEffort"); }); +test("a JEV combo whose only allowlist is unsupported names that target in its 503", async () => { + const f = fixture("responses", "model", "high"); + f.config.providers.a!.reasoningEfforts = []; + f.config.combos!.auto!.targets[0]!.reasoningEfforts = ["high"]; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/auto", input: "Solve the fixture task.", stream: false }), + }), f.config, { model: "", provider: "" }, {}); + expect(response.status).toBe(503); + expect(((await response.json()) as { error: { message: string } }).error.message) + .toBe("No available targets for combo: auto (a/m: ineligible for this request)"); + expect(f.inference.bodies).toHaveLength(0); +}); + for (const lane of ["native", "responses"] as const) { test(`${lane}: unallowlisted service effort fails open without applying the rejected effort`, async () => { const f = fixture(lane, "typesafe", "high");