mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +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)
This commit is contained in:
1
changelog.d/fixes/10284-reasoning-probe-truncated-200.md
Normal file
1
changelog.d/fixes/10284-reasoning-probe-truncated-200.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(sse):** tiny-budget reasoning probes (e.g. Claude Code's `/model` check sends `max_tokens: 1`) are answered with a valid truncated 200 instead of relaying the upstream 5xx "empty response content" — which previously also marked the connection unavailable and poisoned fallback/cooldown bookkeeping for a request that is only a probe ([#10281](https://github.com/diegosouzapw/OmniRoute/issues/10281)) — thanks @harkaranbrar7
|
||||
@@ -159,7 +159,13 @@ import {
|
||||
buildCapabilityMismatchMessage,
|
||||
} from "@/shared/constants/capabilities/capabilityFilter.ts";
|
||||
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts";
|
||||
import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts";
|
||||
import {
|
||||
REASONING_BUFFER_MIN_TRIGGER,
|
||||
buildReasoningProbeTruncatedResponse,
|
||||
isEmptyContentUpstreamFailure,
|
||||
isTinyBudgetReasoningProbe,
|
||||
toPositiveInteger,
|
||||
} from "../services/reasoningTokenBuffer.ts";
|
||||
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
|
||||
import {
|
||||
buildErrorBody,
|
||||
@@ -248,7 +254,10 @@ import {
|
||||
normalizeOpenAIToolFinishReasons,
|
||||
restoreNonStreamingToolNames,
|
||||
} from "./chatCore/passthroughToolNames.ts";
|
||||
import { createDisabledCompressionConfig, resolveCompressionSettings } from "./chatCore/compressionSettings.ts";
|
||||
import {
|
||||
createDisabledCompressionConfig,
|
||||
resolveCompressionSettings,
|
||||
} from "./chatCore/compressionSettings.ts";
|
||||
import type { EnforceDecision } from "@/lib/quota/types";
|
||||
import { isCompressionExcluded } from "../services/compression/exclusions.ts";
|
||||
import {
|
||||
@@ -1823,7 +1832,11 @@ export async function handleChatCore({
|
||||
// engines (Caveman/RTK). Codex Desktop / Responses clients need this path even
|
||||
// when those engines are off, otherwise multi-turn image sessions hard-reject
|
||||
// at the budget check below (#8560).
|
||||
if (reactiveContextCompactionEnabled && !nativeCodexPassthrough && estimatedTokens > threshold) {
|
||||
if (
|
||||
reactiveContextCompactionEnabled &&
|
||||
!nativeCodexPassthrough &&
|
||||
estimatedTokens > threshold
|
||||
) {
|
||||
log?.info?.(
|
||||
"CONTEXT",
|
||||
`Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)`
|
||||
@@ -1893,7 +1906,12 @@ export async function handleChatCore({
|
||||
|
||||
// Last-resort compaction against the concrete input budget (not the 70% threshold).
|
||||
// Covers cases where the proactive pass was skipped or still left the request oversized (#8560).
|
||||
if (reactiveContextCompactionEnabled && !nativeCodexPassthrough && finalEstimatedInputTokens >= finalContextLimit && body) {
|
||||
if (
|
||||
reactiveContextCompactionEnabled &&
|
||||
!nativeCodexPassthrough &&
|
||||
finalEstimatedInputTokens >= finalContextLimit &&
|
||||
body
|
||||
) {
|
||||
const lastResortTarget = Math.max(1, finalContextLimit - toolsReserve - 1);
|
||||
const lastResortAdapter = adaptBodyForCompression(body as Record<string, unknown>);
|
||||
const lastResortResult = compressContext(lastResortAdapter.body, {
|
||||
@@ -3734,6 +3752,33 @@ export async function handleChatCore({
|
||||
|
||||
if (signatureRecovery.succeeded) break providerFailure;
|
||||
|
||||
// #10281 — tiny-budget reasoning probes (e.g. Claude Code's `/model` check
|
||||
// sends `max_tokens: 1`): the model burns the whole budget 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.
|
||||
// Answer such probes with a valid truncated response rather than relaying the
|
||||
// upstream failure — which would also mark the connection unavailable and
|
||||
// poison fallback/cooldown bookkeeping for a request that is only a probe.
|
||||
if (
|
||||
!stream &&
|
||||
isTinyBudgetReasoningProbe({ model: currentModel, body: finalBody || translatedBody }) &&
|
||||
isEmptyContentUpstreamFailure(statusCode, message)
|
||||
) {
|
||||
providerResponse = buildReasoningProbeTruncatedResponse({
|
||||
model: currentModel,
|
||||
maxTokens: toPositiveInteger(
|
||||
(finalBody || translatedBody)?.max_tokens ??
|
||||
(finalBody || translatedBody)?.max_completion_tokens
|
||||
),
|
||||
requestId: skillRequestId,
|
||||
});
|
||||
log?.warn?.(
|
||||
"PROBE",
|
||||
`Reasoning probe (max_tokens < ${REASONING_BUFFER_MIN_TRIGGER}) answered with truncated 200 — upstream reported "${message}"`
|
||||
);
|
||||
break providerFailure;
|
||||
}
|
||||
|
||||
// T06/T10/T36: classify provider errors and persist terminal account states.
|
||||
let errorType = classifyProviderError(statusCode, message, provider);
|
||||
if (statusCode === 429 && isModelScope()) {
|
||||
@@ -4355,7 +4400,11 @@ export async function handleChatCore({
|
||||
}
|
||||
: responseBody
|
||||
);
|
||||
sanitizeUsagePayloadForRequest(responseBody, finalBody || translatedBody || body, responsePayloadFormat);
|
||||
sanitizeUsagePayloadForRequest(
|
||||
responseBody,
|
||||
finalBody || translatedBody || body,
|
||||
responsePayloadFormat
|
||||
);
|
||||
effectiveServiceTier = resolveReportedServiceTier(responseBody) ?? effectiveServiceTier;
|
||||
// Notify success - caller can clear error status if needed
|
||||
if (onRequestSuccess) {
|
||||
@@ -4500,9 +4549,14 @@ export async function handleChatCore({
|
||||
// #8331: keep the client-visible metering fields real everywhere except Claude-Code-compatible
|
||||
// providers, where Claude Code's own context accounting relies on the buffered number — see
|
||||
// clientUsageBuffer.ts module docstring.
|
||||
applyClientUsageBuffer(translatedResponse, finalBody || translatedBody || body, clientResponseFormat, {
|
||||
preserveContextBudgetInVisibleUsage: isClaudeCodeCompatible,
|
||||
});
|
||||
applyClientUsageBuffer(
|
||||
translatedResponse,
|
||||
finalBody || translatedBody || body,
|
||||
clientResponseFormat,
|
||||
{
|
||||
preserveContextBudgetInVisibleUsage: isClaudeCodeCompatible,
|
||||
}
|
||||
);
|
||||
|
||||
if (memoryOwnerId && memorySettings?.enabled && memorySettings.maxTokens > 0) {
|
||||
const requestMemoryText = extractMemoryTextFromRequestBody(body as Record<string, unknown>);
|
||||
|
||||
@@ -54,3 +54,74 @@ export function resolveReasoningBufferedMaxTokens(
|
||||
// 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" },
|
||||
});
|
||||
}
|
||||
|
||||
171
tests/unit/reasoning-probe-truncated-response-10281.test.ts
Normal file
171
tests/unit/reasoning-probe-truncated-response-10281.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* #10281 — tiny-budget reasoning probes (e.g. Claude Code's `/model` capability
|
||||
* check sends `max_tokens: 1`) must be answered with a valid truncated 200 when
|
||||
* the upstream answers the reasoning-only outcome with a 5xx ("empty response
|
||||
* content") instead of a truncated 200 — rather than relaying the upstream
|
||||
* failure, which also poisons connection cooldown/health bookkeeping.
|
||||
*
|
||||
* Covers the pure helpers in open-sse/services/reasoningTokenBuffer.ts:
|
||||
* - isTinyBudgetReasoningProbe — probe detection
|
||||
* - isEmptyContentUpstreamFailure — empty-content 5xx detection
|
||||
* - buildReasoningProbeTruncatedResponse — synthetic truncated 200
|
||||
* plus the invariant that the synthetic body is NOT flagged as empty content by
|
||||
* errorClassifier.isEmptyContentResponse (finish_reason "length" is legitimate).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
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-reasoning-probe-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { saveModelsDevCapabilities, clearModelsDevCapabilities } =
|
||||
await import("../../src/lib/modelsDevSync.ts");
|
||||
const {
|
||||
REASONING_BUFFER_MIN_TRIGGER,
|
||||
buildReasoningProbeTruncatedResponse,
|
||||
isEmptyContentUpstreamFailure,
|
||||
isTinyBudgetReasoningProbe,
|
||||
} = await import("../../open-sse/services/reasoningTokenBuffer.ts");
|
||||
const { isEmptyContentResponse } = await import("../../open-sse/services/errorClassifier.ts");
|
||||
|
||||
function capabilityEntry(limitContext: unknown, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
tool_call: true,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
structured_output: true,
|
||||
temperature: true,
|
||||
modalities_input: JSON.stringify(["text"]),
|
||||
modalities_output: JSON.stringify(["text"]),
|
||||
knowledge_cutoff: null,
|
||||
release_date: null,
|
||||
last_updated: null,
|
||||
status: null,
|
||||
family: null,
|
||||
open_weights: false,
|
||||
limit_context: limitContext,
|
||||
limit_input: limitContext,
|
||||
limit_output: 4096,
|
||||
interleaved_field: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test.before(() => {
|
||||
saveModelsDevCapabilities({
|
||||
zhipu: {
|
||||
// A thinking-capable model: probe detection + buffer logic both engage.
|
||||
"glm-5.2": capabilityEntry(200000, { reasoning: true, limit_output: 65536 }),
|
||||
// A non-reasoning sibling: probes are not special-cased.
|
||||
"glm-5.2-flash": capabilityEntry(200000, { reasoning: false, limit_output: 4096 }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
clearModelsDevCapabilities();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#10281 isTinyBudgetReasoningProbe detects tiny explicit budgets on reasoning models", () => {
|
||||
const thinking = "zhipu/glm-5.2";
|
||||
// Claude Code's `/model` probe (max_tokens: 1) is a tiny-budget reasoning probe.
|
||||
assert.equal(
|
||||
isTinyBudgetReasoningProbe({ model: thinking, body: { max_tokens: 1 } }),
|
||||
true,
|
||||
"max_tokens=1 on a reasoning model is a probe"
|
||||
);
|
||||
// Just below the trigger threshold is still a probe.
|
||||
assert.equal(
|
||||
isTinyBudgetReasoningProbe({
|
||||
model: thinking,
|
||||
body: { max_tokens: REASONING_BUFFER_MIN_TRIGGER - 1 },
|
||||
}),
|
||||
true,
|
||||
"budgets below REASONING_BUFFER_MIN_TRIGGER are probes"
|
||||
);
|
||||
// At/above the threshold it is a genuine budget, not a probe.
|
||||
assert.equal(
|
||||
isTinyBudgetReasoningProbe({
|
||||
model: thinking,
|
||||
body: { max_tokens: REASONING_BUFFER_MIN_TRIGGER },
|
||||
}),
|
||||
false,
|
||||
"budgets at REASONING_BUFFER_MIN_TRIGGER are not probes"
|
||||
);
|
||||
assert.equal(
|
||||
isTinyBudgetReasoningProbe({ model: thinking, body: { max_tokens: 512 } }),
|
||||
false,
|
||||
"genuine budgets are not probes"
|
||||
);
|
||||
// OpenAI Responses format field is honoured.
|
||||
assert.equal(
|
||||
isTinyBudgetReasoningProbe({ model: thinking, body: { max_completion_tokens: 1 } }),
|
||||
true,
|
||||
"max_completion_tokens=1 is a probe"
|
||||
);
|
||||
// Missing / non-positive budgets are not probes.
|
||||
assert.equal(
|
||||
isTinyBudgetReasoningProbe({ model: thinking, body: {} }),
|
||||
false,
|
||||
"no budget is not a probe"
|
||||
);
|
||||
assert.equal(
|
||||
isTinyBudgetReasoningProbe({ model: thinking, body: { max_tokens: 0 } }),
|
||||
false,
|
||||
"non-positive budget is not a probe"
|
||||
);
|
||||
// Non-reasoning models never probe-special-case.
|
||||
assert.equal(
|
||||
isTinyBudgetReasoningProbe({ model: "zhipu/glm-5.2-flash", body: { max_tokens: 1 } }),
|
||||
false,
|
||||
"non-reasoning models are not probes"
|
||||
);
|
||||
});
|
||||
|
||||
test("#10281 isEmptyContentUpstreamFailure matches empty-content 5xx markers", () => {
|
||||
assert.equal(isEmptyContentUpstreamFailure(500, "empty response content"), true);
|
||||
assert.equal(isEmptyContentUpstreamFailure(500, "No content was produced"), true);
|
||||
assert.equal(isEmptyContentUpstreamFailure(502, "empty response content"), true);
|
||||
assert.equal(
|
||||
isEmptyContentUpstreamFailure(500, "empty response body"),
|
||||
false,
|
||||
"generic empty-body 5xx is not a reasoning outcome"
|
||||
);
|
||||
assert.equal(isEmptyContentUpstreamFailure(500, "server_error"), false);
|
||||
assert.equal(isEmptyContentUpstreamFailure(503, "upstream timeout"), false);
|
||||
assert.equal(
|
||||
isEmptyContentUpstreamFailure(429, "empty response content"),
|
||||
false,
|
||||
"non-5xx is not an empty-content failure"
|
||||
);
|
||||
assert.equal(isEmptyContentUpstreamFailure(200, "empty response content"), false);
|
||||
});
|
||||
|
||||
test("#10281 buildReasoningProbeTruncatedResponse yields a valid truncated 200", async () => {
|
||||
const res = buildReasoningProbeTruncatedResponse({
|
||||
model: "zhipu/glm-5.2",
|
||||
maxTokens: 1,
|
||||
requestId: "test-request-id",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("content-type") || "", /application\/json/);
|
||||
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const choice = (body.choices as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(body.object, "chat.completion");
|
||||
assert.equal(body.model, "zhipu/glm-5.2");
|
||||
assert.equal(choice.finish_reason, "length");
|
||||
assert.equal((choice.message as Record<string, unknown>).content, "");
|
||||
assert.equal((body.usage as Record<string, number>).completion_tokens, 1);
|
||||
|
||||
// The synthetic body must pass the empty-content guard (finish_reason "length"
|
||||
// is a legitimate truncated completion — see errorClassifier.ts) so the
|
||||
// non-stream success path does not re-flag it as a fake-success failure.
|
||||
assert.equal(isEmptyContentResponse(body), false, "truncated probe response is a legitimate 200");
|
||||
});
|
||||
Reference in New Issue
Block a user