mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
* fix(sse): answer tiny-budget reasoning probes with a truncated 200 (#10281) Claude Code's /model capability check sends max_tokens: 1. Reasoning models burn the whole probe on thinking, and some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the empty outcome with a 5xx "empty response content" instead of a truncated 200. The relayed failure also marked the connection unavailable and poisoned fallback/cooldown bookkeeping for what is only a probe. Detect tiny-budget reasoning probes in the non-streaming providerFailure path and synthesize a valid truncated response (200, empty content, finish_reason "length") — the same semantics errorClassifier.ts already grants to length-truncated empty 200s. Probes no longer poison connection health. Refs #10281. * chore(changelog): add fragment for reasoning-probe truncated-200 fix (#10284)
128 lines
5.1 KiB
TypeScript
128 lines
5.1 KiB
TypeScript
import {
|
|
getExplicitModelOutputCap,
|
|
getResolvedModelCapabilities,
|
|
} from "../../src/lib/modelCapabilities.ts";
|
|
|
|
/**
|
|
* Below this caller-supplied `max_tokens`, the request is treated as a probe
|
|
* (e.g. Claude Code's `/model` capability check sends `max_tokens: 1`) rather
|
|
* than a genuine reasoning budget, so no headroom is added. Keeping it a named
|
|
* constant makes the threshold easy to tune. See issue #6274 (probe inflated to
|
|
* 1001 upstream) vs. issue #3587 (headroom for real reasoning budgets).
|
|
*/
|
|
export const REASONING_BUFFER_MIN_TRIGGER = 256;
|
|
|
|
export function toPositiveInteger(value: unknown): number | null {
|
|
const numericValue =
|
|
typeof value === "number"
|
|
? value
|
|
: typeof value === "string" && value.trim() !== ""
|
|
? Number(value)
|
|
: null;
|
|
if (numericValue === null || !Number.isFinite(numericValue)) return null;
|
|
const normalized = Math.floor(numericValue);
|
|
return normalized > 0 ? normalized : null;
|
|
}
|
|
|
|
export function resolveReasoningBufferedMaxTokens(
|
|
modelStr: string,
|
|
currentMaxTokens: unknown,
|
|
options: { enabled?: boolean } = {}
|
|
): number | null {
|
|
if (options.enabled === false) return null;
|
|
|
|
const current = toPositiveInteger(currentMaxTokens);
|
|
if (current === null) return null;
|
|
|
|
const capabilities = getResolvedModelCapabilities(modelStr);
|
|
if (capabilities.supportsThinking !== true) return null;
|
|
|
|
const maxOutputTokens = toPositiveInteger(getExplicitModelOutputCap(modelStr));
|
|
if (maxOutputTokens === null) return null;
|
|
if (current > maxOutputTokens) return maxOutputTokens;
|
|
|
|
// Issue #6274: a tiny explicit budget is a capability probe, not a reasoning
|
|
// request. Respect it verbatim instead of inflating (e.g. 1 -> 1001).
|
|
if (current < REASONING_BUFFER_MIN_TRIGGER) return current;
|
|
|
|
// Issue #9507: never enlarge a client's explicit max_tokens. The #3587
|
|
// headroom heuristic (Math.ceil(current * 1.5)) silently rewrote reasoning
|
|
// budgets upward (64000 -> 96000 on claude-opus-5), violating the #1761
|
|
// contract that upward adjustment must be opt-in. The over-cap clamp above
|
|
// (line 42) already narrows, and the model's own output cap is the only
|
|
// legitimate ceiling; any headroom beyond the client-declared value is a
|
|
// silent cost increase the client did not authorize.
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* A tiny-budget reasoning probe is a request with an explicit `max_tokens`
|
|
* below REASONING_BUFFER_MIN_TRIGGER targeting a reasoning-capable model — e.g.
|
|
* Claude Code's `/model` capability check sends `max_tokens: 1`. Reasoning
|
|
* models burn the whole probe on thinking, so the upstream produces no visible
|
|
* content; some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the
|
|
* non-streaming probe with an HTTP 5xx (`"empty response content"`) instead of
|
|
* a truncated 200. See #10281.
|
|
*/
|
|
export function isTinyBudgetReasoningProbe(opts: { model: string; body: unknown }): boolean {
|
|
const body = (opts.body ?? {}) as Record<string, unknown>;
|
|
const maxTokens = toPositiveInteger(body.max_tokens ?? body.max_completion_tokens);
|
|
if (maxTokens === null || maxTokens >= REASONING_BUFFER_MIN_TRIGGER) return false;
|
|
const capabilities = getResolvedModelCapabilities(opts.model);
|
|
return capabilities.supportsThinking === true;
|
|
}
|
|
|
|
/**
|
|
* Upstream failure markers that describe the "model reasoned but produced no
|
|
* visible content" outcome (e.g. `{"error":{"message":"empty response content"}}`).
|
|
*/
|
|
const EMPTY_CONTENT_FAILURE_RE =
|
|
/empty(\s+response)?\s+content|no\s+(usable\s+)?content|reasoning\s+consumed/i;
|
|
|
|
/**
|
|
* True when the upstream failure is a 5xx describing the empty-content outcome
|
|
* of a reasoning probe rather than a genuine provider outage. Combined with
|
|
* `isTinyBudgetReasoningProbe`, false positives are not practical (a real 5xx
|
|
* carrying these markers on a tiny-budget reasoning request is this exact case).
|
|
*/
|
|
export function isEmptyContentUpstreamFailure(statusCode: number, message: string): boolean {
|
|
if (!Number.isFinite(statusCode) || statusCode < 500 || statusCode >= 600) return false;
|
|
return EMPTY_CONTENT_FAILURE_RE.test(String(message || ""));
|
|
}
|
|
|
|
/**
|
|
* Build a valid truncated OpenAI chat.completion response (200, empty content,
|
|
* `finish_reason: "length"`) used to answer a tiny-budget reasoning probe whose
|
|
* upstream answered the empty outcome with a 5xx. Mirrors the semantics OmniRoute
|
|
* already grants to `finish_reason: "length"` empty 200s (errorClassifier.ts).
|
|
*/
|
|
export function buildReasoningProbeTruncatedResponse(opts: {
|
|
model: string;
|
|
maxTokens: number | null;
|
|
requestId: string;
|
|
}): Response {
|
|
const maxTokens = opts.maxTokens ?? 1;
|
|
const body = {
|
|
id: `chatcmpl-${opts.requestId}`,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: opts.model,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: "assistant", content: "" },
|
|
finish_reason: "length",
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 0,
|
|
completion_tokens: maxTokens,
|
|
total_tokens: maxTokens,
|
|
},
|
|
};
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|