mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
fitThinkingToMaxTokens() clamps the synthesized max_tokens to the model
output cap, but resolved that cap from a bare model id via
safeCapMaxOutputTokens(model) -> capMaxOutputTokens(model). A cap that is
only known per provider -- an operator max_output_tokens override, a
synced catalog limit_output, or a registry entry -- is invisible to a
bare-model lookup, so modelCap came back null and the unbounded
responseRoom + requestedBudget branch ran.
When the client sends no max-token field at all, adjustMaxTokens()
supplies DEFAULT_MAX_TOKENS (64000) and reasoning_effort: "high" supplies
a 131072 thinking budget, so the provider request carried
max_tokens: 195072 and every such request was rejected upstream with a
bare 400.
Thread the already-in-scope routedProvider (openai-to-claude.ts:122, used
two lines later for the Kimi-coding check) through fitThinkingToMaxTokens()
into capMaxOutputTokens({ provider, model }), which already supports
provider-scoped resolution via resolveCapabilityInput() -- no new lookup
path needed. Omitting the provider (existing callers, tests) keeps the
bare-model behavior unchanged; verified in the added regression test.
Follow-up to #6637, whose token-budgeting half was never addressed: #6893
fixed only the combo fallback classification. Rebased onto the
open-sse/translator/request/openai-to-claude/thinkingBudget.ts extraction
that landed after the original patch was written against the inline code
in openai-to-claude.ts.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **fix(translator):** resolve the Claude thinking output cap with the routed provider so a provider-scoped-only `max_output_tokens` override is no longer invisible to `fitThinkingToMaxTokens()`, which previously let the synthesized `max_tokens` (caller room + thinking budget) go out unbounded and 400 upstream ([#10139](https://github.com/diegosouzapw/OmniRoute/issues/10139))
|
||||
@@ -240,7 +240,12 @@ export function openaiToClaudeRequest(model, body, stream, credentials = null) {
|
||||
// could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger
|
||||
// HTTP 400 from Anthropic.
|
||||
if (!isKimiCoding) {
|
||||
const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking);
|
||||
const fitted = fitThinkingToMaxTokens(
|
||||
model,
|
||||
Number(result.max_tokens) || 0,
|
||||
result.thinking,
|
||||
routedProvider
|
||||
);
|
||||
result.max_tokens = fitted.maxTokens;
|
||||
if (fitted.thinking === undefined) {
|
||||
delete result.thinking;
|
||||
|
||||
@@ -7,9 +7,9 @@ import { capMaxOutputTokens } from "../../../../src/lib/modelCapabilities.ts";
|
||||
const MIN_CLAUDE_THINKING_BUDGET = 1024;
|
||||
const MIN_RESPONSE_ROOM = 1024;
|
||||
|
||||
function safeCapMaxOutputTokens(model: string): number | null {
|
||||
function safeCapMaxOutputTokens(model: string, provider?: string | null): number | null {
|
||||
try {
|
||||
const cap = capMaxOutputTokens(model);
|
||||
const cap = capMaxOutputTokens(provider ? { provider, model } : model);
|
||||
return typeof cap === "number" && cap > 0 ? cap : null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -31,6 +31,12 @@ function safeCapMaxOutputTokens(model: string): number | null {
|
||||
* responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable
|
||||
* thinking entirely (cap too tight for any reasoning).
|
||||
*
|
||||
* `provider` scopes the cap lookup to a provider-specific override (e.g. a
|
||||
* dashboard-set `max_output_tokens` for `opencode-go/qwen3.7-plus`) when the
|
||||
* model-only entry has no cap of its own. Without it, a model whose real
|
||||
* ceiling is only known per-provider resolves to no cap at all and the
|
||||
* synthesized `max_tokens` goes out unbounded (#10139).
|
||||
*
|
||||
* Worked example (real-world Opus 4.7 case that previously 400'd):
|
||||
* caller max_tokens = 32000, reasoning_effort=high → budget = 131072,
|
||||
* model cap = 128000.
|
||||
@@ -42,9 +48,10 @@ function safeCapMaxOutputTokens(model: string): number | null {
|
||||
export function fitThinkingToMaxTokens(
|
||||
model: string,
|
||||
callerMaxTokens: number,
|
||||
thinking: Record<string, unknown> | undefined
|
||||
thinking: Record<string, unknown> | undefined,
|
||||
provider?: string | null
|
||||
): { maxTokens: number; thinking: Record<string, unknown> | undefined } {
|
||||
const modelCap = safeCapMaxOutputTokens(model);
|
||||
const modelCap = safeCapMaxOutputTokens(model, provider);
|
||||
const requestedBudget = Number(thinking?.budget_tokens) || 0;
|
||||
|
||||
// No budgeted thinking — just cap max_tokens to the model output ceiling.
|
||||
|
||||
110
tests/unit/repro-10139-claude-thinking-output-cap.test.ts
Normal file
110
tests/unit/repro-10139-claude-thinking-output-cap.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Regression test for issue #10139 (follow-up to #6637).
|
||||
*
|
||||
* Reporter observed: for a model whose real output ceiling is only known via a
|
||||
* provider-scoped override (e.g. an operator-set `max_output_tokens` for
|
||||
* `opencode-go/qwen3.7-plus`, written through the dashboard's
|
||||
* `PUT /api/provider-models`), `fitThinkingToMaxTokens()` resolved the cap by
|
||||
* model name alone. The provider-scoped override was invisible to that lookup,
|
||||
* so the cap resolved to `null` and the synthesized `max_tokens` (caller's
|
||||
* `max_tokens` + the `reasoning_effort` thinking budget) went out unbounded —
|
||||
* `64000 + 131072 = 195072`, which the upstream provider rejected with a bare
|
||||
* `400 {"model":"qwen3.7-plus"}`.
|
||||
*
|
||||
* Root cause (confirmed by reading `fitThinkingToMaxTokens` and its caller in
|
||||
* `openai-to-claude.ts`): the caller already has `routedProvider` in scope
|
||||
* (used two lines earlier for the Kimi-coding check) but never passed it
|
||||
* through, so `safeCapMaxOutputTokens()` always resolved the model-only
|
||||
* capability entry and missed any provider-scoped override.
|
||||
*
|
||||
* Fix: `fitThinkingToMaxTokens()` takes an optional `provider` argument and
|
||||
* forwards it to `capMaxOutputTokens({ provider, model })`, which already
|
||||
* supports provider-scoped resolution; the caller now passes `routedProvider`.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-repro-10139-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { setModelCapabilityOverride, removeModelCapabilityOverride } =
|
||||
await import("../../src/lib/db/modelCapabilityOverrides.ts");
|
||||
const { fitThinkingToMaxTokens } =
|
||||
await import("../../open-sse/translator/request/openai-to-claude/thinkingBudget.ts");
|
||||
|
||||
const PROVIDER = "opencode-go";
|
||||
const MODEL = "qwen3.7-plus";
|
||||
const TARGET = `${PROVIDER}/${MODEL}`;
|
||||
const REAL_UPSTREAM_OUTPUT_CAP = 131072; // per reporter's boundary test (131072 -> 200, 131073 -> 400)
|
||||
const CALLER_MAX_TOKENS = 64000; // DEFAULT_MAX_TOKENS when the client sends none
|
||||
const THINKING_BUDGET = 131072; // effortBudgetMap.high
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#10139: a provider-scoped-only output cap is invisible without a provider argument", () => {
|
||||
assert.ok(
|
||||
setModelCapabilityOverride(TARGET, "max_output_tokens", REAL_UPSTREAM_OUTPUT_CAP),
|
||||
"expected the max_output_tokens override to be written"
|
||||
);
|
||||
try {
|
||||
// Reproduces the pre-fix call shape: no provider passed.
|
||||
const result = fitThinkingToMaxTokens(MODEL, CALLER_MAX_TOKENS, {
|
||||
type: "enabled",
|
||||
budget_tokens: THINKING_BUDGET,
|
||||
});
|
||||
// This is the defect: with no provider, the override is invisible and the
|
||||
// synthesized max_tokens goes out unbounded (64000 + 131072 = 195072).
|
||||
assert.equal(
|
||||
result.maxTokens,
|
||||
CALLER_MAX_TOKENS + THINKING_BUDGET,
|
||||
"documents the pre-fix behavior: uncapped when provider is omitted"
|
||||
);
|
||||
} finally {
|
||||
removeModelCapabilityOverride(TARGET, "max_output_tokens");
|
||||
}
|
||||
});
|
||||
|
||||
test("#10139: passing the routed provider resolves the override and clamps max_tokens", () => {
|
||||
assert.ok(
|
||||
setModelCapabilityOverride(TARGET, "max_output_tokens", REAL_UPSTREAM_OUTPUT_CAP),
|
||||
"expected the max_output_tokens override to be written"
|
||||
);
|
||||
try {
|
||||
const result = fitThinkingToMaxTokens(
|
||||
MODEL,
|
||||
CALLER_MAX_TOKENS,
|
||||
{ type: "enabled", budget_tokens: THINKING_BUDGET },
|
||||
PROVIDER
|
||||
);
|
||||
assert.ok(
|
||||
result.maxTokens <= REAL_UPSTREAM_OUTPUT_CAP,
|
||||
`expected max_tokens to stay <= ${REAL_UPSTREAM_OUTPUT_CAP}, got ${result.maxTokens} ` +
|
||||
`(reproduces the reported 195072, upstream then 400s)`
|
||||
);
|
||||
assert.equal(result.maxTokens, REAL_UPSTREAM_OUTPUT_CAP);
|
||||
assert.equal(result.thinking?.budget_tokens, REAL_UPSTREAM_OUTPUT_CAP - CALLER_MAX_TOKENS);
|
||||
} finally {
|
||||
removeModelCapabilityOverride(TARGET, "max_output_tokens");
|
||||
}
|
||||
});
|
||||
|
||||
test("#10139: no override present, provider argument is a no-op (unchanged behavior)", () => {
|
||||
const withoutProvider = fitThinkingToMaxTokens(MODEL, CALLER_MAX_TOKENS, {
|
||||
type: "enabled",
|
||||
budget_tokens: THINKING_BUDGET,
|
||||
});
|
||||
const withProvider = fitThinkingToMaxTokens(
|
||||
MODEL,
|
||||
CALLER_MAX_TOKENS,
|
||||
{ type: "enabled", budget_tokens: THINKING_BUDGET },
|
||||
PROVIDER
|
||||
);
|
||||
assert.deepEqual(withProvider, withoutProvider);
|
||||
});
|
||||
Reference in New Issue
Block a user