diff --git a/changelog.d/fixes/12374-combo-exclusion-reason-output-tokens.md b/changelog.d/fixes/12374-combo-exclusion-reason-output-tokens.md new file mode 100644 index 0000000000..d89a27bfec --- /dev/null +++ b/changelog.d/fixes/12374-combo-exclusion-reason-output-tokens.md @@ -0,0 +1 @@ +- **fix(combo):** capability-filter exhaustion caused by `max_tokens` above every target's known output limit now reports that reason (requested `max_tokens` vs the pool's highest known ceiling) instead of the unrelated "supports structured output" message ([#12229](https://github.com/diegosouzapw/OmniRoute/issues/12229)) — thanks @DW-MediaLab diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 8d7adc068a..137561fbaa 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -627,6 +627,18 @@ export type CompatFilterOptions = { failOpen?: boolean; }; +function highestKnownOutputLimit(targets: ResolvedComboTarget[]): number { + let ceiling = 0; + for (const target of targets) { + const limit = getResolvedModelCapabilities({ + provider: target.providerId || target.provider || null, + model: target.modelStr, + }).maxOutputTokens; + if (typeof limit === "number" && limit > ceiling) ceiling = limit; + } + return ceiling; +} + export function hasHardCapabilityFailure(reasons: string[]): boolean { return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason)); } @@ -668,6 +680,16 @@ export function describeCapabilityFilterExhaustion( message = `No target in combo ${name} supports tool calling; request carried ${toolCount} tools`; } else if (primary === "vision") { message = `No target in combo ${name} has confirmed vision support for this image request`; + } else if (primary === "output_tokens") { + // #12229: name the real reason. Collapsing this into the structured-output + // message sent operators chasing response_format when the request's + // max_tokens simply exceeded every target's known output ceiling. + const ceiling = highestKnownOutputLimit( + rejected.filter((entry) => entry.reasons.includes("output_tokens")).map((e) => e.target) + ); + message = + `No target in combo ${name} can produce the requested max_tokens=${requirements.requestedOutputTokens}; ` + + `the highest known output limit in the pool is ${ceiling}`; } else { message = `No target in combo ${name} supports structured output for this request`; } diff --git a/tests/unit/8488-capability-filter-fail-closed.test.ts b/tests/unit/8488-capability-filter-fail-closed.test.ts index 2be07aa7e7..418d9be453 100644 --- a/tests/unit/8488-capability-filter-fail-closed.test.ts +++ b/tests/unit/8488-capability-filter-fail-closed.test.ts @@ -320,3 +320,32 @@ test("auto context estimate still dispatches when all known limits look too smal assert.equal(result.status, 200); assert.deepEqual(dispatches, ["openai/tiny"]); }); + +test("#12229 exhaustion: output_tokens exclusion names max_tokens vs the model ceiling", () => { + saveModelsDevCapabilities({ + claude: { + "claude-haiku-4-5-20251001": capabilityEntry(200000, { + tool_call: true, + structured_output: true, + limit_output: 64000, + }), + }, + }); + + const targets = [target("claude", "claude/claude-haiku-4-5-20251001")]; + const body = { + messages: [{ role: "user", content: "hoi wie ben je?" }], + max_tokens: 100000, + }; + + const exhaustion = describeCapabilityFilterExhaustion(targets, body, "hermes-main"); + assert.ok(exhaustion); + assert.deepEqual(exhaustion!.unmet, ["output_tokens"]); + assert.equal(exhaustion!.excluded[0].reason, "output_tokens"); + assert.equal( + exhaustion!.message, + "No target in combo hermes-main can produce the requested max_tokens=100000; the highest known output limit in the pool is 64000" + ); + assert.doesNotMatch(exhaustion!.message, /structured output/i); + assert.equal(exhaustion!.terminalReason, "capability_mismatch"); +});