mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* Fix context-window exhaustion classification * fix(combo): keep chat.ts/comboStructure.ts under the file-size ratchet + fix context-overflow boundary bug - Extract getKnownContextOverflow (+ its KnownContextOverflow type) out of comboStructure.ts into a new open-sse/services/combo/knownContextOverflow.ts leaf, so the file-size ratchet (cap 800 for new files) passes. - Extract the skipConnectionDisable predicate out of handleSingleModelChat in chat.ts into open-sse/services/combo/comboPredicates.ts::shouldSkipConnDisable, and consolidate the new combo-failure-handling imports, to keep chat.ts under its frozen file-size baseline (1796) after the #7177 request-scoped-failure wiring. - Fix a real boundary bug in getKnownContextOverflow surfaced by the merge: estimateRequestInputTokens counted a caller-omitted `messages: []` (which some combo entrypoints default in) as real content, charging a few phantom "structural" JSON.stringify tokens toward the estimate. That was enough to falsely trip the new known-context-overflow rejection for a request with no real input when max_tokens exactly equals the target's context window (a common config where limit_input === limit_output === limit_context), regressing tests/unit/combo-routing-engine.test.ts's pre-existing #3587 "non-reasoning model does not get max_tokens buffer" case. Empty arrays/objects no longer count as estimable content. - Add a regression test for the exact-boundary empty-content case. Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> * refactor(combo): move overflow logic into knownContextOverflow module (file-size cap) comboStructure.ts is not frozen in the file-size baseline but is capped at 800 lines; this PR's net +29 on that file alone would push it over once merged. knownContextOverflow.ts already exists in this PR as the dedicated home for "known context limit" logic, so move the genuinely new pieces there instead of leaving them in comboStructure.ts: - hasEstimableContent (new): its own doc comment already frames it purely in terms of the known-context-overflow boundary check, so it belongs next to that check, not in the general request-compatibility file. - getKnownContextLimit (new, requestedOutputTokens-aware): this *is* the "how big is a target's known context window" primitive knownContextOverflow already consumes; hosting it there is a better fit than comboStructure.ts. - getLegacyKnownContextLimit: kept alongside its sibling rather than split across two files, since both are alternate implementations of the same concept (used only by comboStructure.ts's hasKnownCompatibleContextLimit). comboStructure.ts now imports all three back for its own internal callers (estimateRequestInputTokens, getTargetCompatibilityFailures, hasKnownCompatibleContextLimit). deriveRequestCompatibilityRequirements and the RequestCompatibilityRequirements type stay in comboStructure.ts exactly as this PR already has them (still consumed internally there), so knownContextOverflow.ts keeps importing those two, same as before. No behavior change — pure relocation, verified by the existing PR test suite (combo-context-window-filter, combo-breaker-429, combo-failure-log-message, combo-target-exhaustion, diagnostics) plus the pre-existing combo-vision-aware-routing/combo-context-requirements/combo-roundrobin-compat-fallback-6238 suites, all green. Net effect on open-sse/services/combo/comboStructure.ts vs. this PR's merge base: -2 lines (was +29). typecheck:core, lint, and the complexity ratchets (check:complexity, check:cognitive-complexity) are unchanged from this PR's current HEAD — the 4 pre-existing complexity/max-lines findings in valueContainsImagePart/filterTargetsByRequestCompatibility are untouched by this move (same violations, same total ratchet counts, just shifted line numbers). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
/**
|
|
* Tests for open-sse/utils/diagnostics.ts
|
|
*
|
|
* Covers:
|
|
* (a) synthOpenAIErrorChunk — shape validation
|
|
* (b) synthResponsesFailure — matches a response.failed event
|
|
* (c) detectMalformedNonStream — empty/malformed input classified correctly
|
|
* (d) no stack trace leakage in error chunk message
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
reportMalformed200,
|
|
synthOpenAIErrorChunk,
|
|
synthResponsesFailure,
|
|
detectMalformedNonStream,
|
|
describeMalformedNonStream,
|
|
} from "../../open-sse/utils/diagnostics.ts";
|
|
|
|
// ── (a) synthOpenAIErrorChunk shape ──────────────────────────────────────────
|
|
|
|
test("synthOpenAIErrorChunk returns a valid SSE data line", () => {
|
|
const line = synthOpenAIErrorChunk({
|
|
provider: "testprovider",
|
|
model: "gpt-test",
|
|
reason: "empty_stream",
|
|
});
|
|
assert.match(line, /^data: /);
|
|
assert.ok(line.endsWith("\n\n"), "must end with double newline");
|
|
});
|
|
|
|
test("synthOpenAIErrorChunk payload has expected OpenAI chunk shape", () => {
|
|
const line = synthOpenAIErrorChunk({ provider: "myprovider", model: "mymodel", reason: "empty" });
|
|
const payload = JSON.parse(line.slice("data: ".length).trimEnd());
|
|
assert.equal(payload.object, "chat.completion.chunk");
|
|
assert.ok(Array.isArray(payload.choices), "must have choices array");
|
|
assert.equal(payload.choices.length, 1);
|
|
assert.ok(payload.error, "must have error field");
|
|
assert.equal(payload.error.type, "upstream_empty_response");
|
|
assert.ok(typeof payload.error.message === "string" && payload.error.message.length > 0);
|
|
});
|
|
|
|
test("synthOpenAIErrorChunk references provider in message", () => {
|
|
const line = synthOpenAIErrorChunk({
|
|
provider: "mymysteriosprovider",
|
|
model: "m",
|
|
reason: "stall",
|
|
});
|
|
const payload = JSON.parse(line.slice("data: ".length).trimEnd());
|
|
assert.ok(
|
|
payload.error.message.includes("mymysteriosprovider"),
|
|
`message should reference provider, got: ${payload.error.message}`
|
|
);
|
|
});
|
|
|
|
// ── (b) synthResponsesFailure matches a response.failed event ────────────────
|
|
|
|
test("synthResponsesFailure produces a response.failed SSE event", () => {
|
|
const sseText = synthResponsesFailure("empty_stream");
|
|
assert.match(sseText, /event: response\.failed/);
|
|
assert.match(sseText, /data: /);
|
|
});
|
|
|
|
test("synthResponsesFailure includes a reason in the data payload", () => {
|
|
const sseText = synthResponsesFailure("no_terminal");
|
|
// Extract JSON after "data: " line
|
|
const dataLine = sseText.split("\n").find((l) => l.startsWith("data: "));
|
|
assert.ok(dataLine, "must have a data line");
|
|
const parsed = JSON.parse(dataLine.slice("data: ".length));
|
|
assert.ok(
|
|
parsed?.response?.error?.message?.length > 0,
|
|
`response.error.message should be non-empty, got: ${JSON.stringify(parsed?.response?.error)}`
|
|
);
|
|
});
|
|
|
|
// ── (c) detectMalformedNonStream ─────────────────────────────────────────────
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' for null input", () => {
|
|
assert.equal(detectMalformedNonStream(null), "empty_choices");
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' for empty object", () => {
|
|
assert.equal(detectMalformedNonStream({}), "empty_choices");
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' for choices:[]", () => {
|
|
assert.equal(detectMalformedNonStream({ choices: [] }), "empty_choices");
|
|
});
|
|
|
|
test("failed Responses API body gets a request-scoped machine-readable classification", () => {
|
|
const failed = {
|
|
object: "response",
|
|
status: "failed",
|
|
output: [],
|
|
};
|
|
const reason = detectMalformedNonStream(failed);
|
|
assert.equal(reason, "empty_choices");
|
|
assert.deepEqual(describeMalformedNonStream(failed, reason), {
|
|
message: "upstream reported a failed response without usable output",
|
|
code: "upstream_response_failed",
|
|
type: "upstream_response_error",
|
|
});
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' when choice message has no content", () => {
|
|
const body = {
|
|
choices: [{ message: { content: "", tool_calls: null }, finish_reason: "stop" }],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
|
});
|
|
|
|
test("detectMalformedNonStream returns null for valid chat completion", () => {
|
|
const body = {
|
|
choices: [{ message: { content: "Hello!", tool_calls: null }, finish_reason: "stop" }],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream returns null for OpenAI choices whose content is a text-block array (#5559)", () => {
|
|
// Cline (OAuth) returns choices[].message.content as an array of Anthropic-style
|
|
// blocks inside an OpenAI envelope; this must count as real output, not empty_choices.
|
|
const body = {
|
|
id: "chatcmpl-kimi",
|
|
object: "chat.completion",
|
|
model: "moonshotai/kimi-k2.6",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: "assistant", content: [{ type: "text", text: "Here is my analysis." }] },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' for an OpenAI choice with an empty text-block array (#5559 guard)", () => {
|
|
const body = {
|
|
choices: [
|
|
{
|
|
message: { role: "assistant", content: [{ type: "text", text: "" }] },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
|
});
|
|
|
|
test("detectMalformedNonStream returns null for a valid Claude-native message (#4942 regression)", () => {
|
|
const body = {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Preserved" }],
|
|
stop_reason: "end_turn",
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' for a Claude-native message with no text", () => {
|
|
const body = { type: "message", role: "assistant", content: [{ type: "text", text: "" }] };
|
|
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
|
});
|
|
|
|
test("detectMalformedNonStream returns null for a Claude-native message carrying a tool_use block", () => {
|
|
const body = {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "tool_use", id: "tu_1", name: "search", input: {} }],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream tolerates a null block in a Claude-native content array", () => {
|
|
// A malformed/partial provider response could carry a null entry in `content`.
|
|
// The detector must not throw (TypeError on null.type) — it skips the null
|
|
// block and classifies by the remaining valid blocks.
|
|
const body = {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [null, { type: "text", text: "still here" }],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' for a Claude-native message of only null blocks", () => {
|
|
const body = { type: "message", role: "assistant", content: [null] };
|
|
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
|
});
|
|
|
|
test("detectMalformedNonStream returns null when tool_calls present", () => {
|
|
const body = {
|
|
choices: [
|
|
{
|
|
message: { content: null, tool_calls: [{ id: "call_1", type: "function" }] },
|
|
finish_reason: "tool_calls",
|
|
},
|
|
],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream returns null when reasoning_content present (reasoning-only)", () => {
|
|
const body = {
|
|
choices: [
|
|
{
|
|
message: { content: "", reasoning_content: "some reasoning text", tool_calls: null },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' for Responses API with empty output", () => {
|
|
const body = { object: "response", output: [], status: "completed" };
|
|
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
|
});
|
|
|
|
test("detectMalformedNonStream returns null for Responses API with text output", () => {
|
|
const body = {
|
|
object: "response",
|
|
status: "completed",
|
|
output: [
|
|
{
|
|
type: "message",
|
|
content: [{ type: "output_text", text: "Hello there!" }],
|
|
},
|
|
],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'no_terminal' for Responses API with failed status", () => {
|
|
const body = {
|
|
object: "response",
|
|
status: "failed",
|
|
output: [
|
|
{
|
|
type: "message",
|
|
content: [{ type: "output_text", text: "Some text" }],
|
|
},
|
|
],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), "no_terminal");
|
|
});
|
|
|
|
test("detectMalformedNonStream allows Responses API function_call items as valid output", () => {
|
|
const body = {
|
|
object: "response",
|
|
status: "completed",
|
|
output: [{ type: "function_call", name: "search", arguments: "{}" }],
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
// ── (c2) detectMalformedNonStream — Claude Messages shape ────────────────────
|
|
|
|
test("detectMalformedNonStream returns null for Claude message with text content", () => {
|
|
const body = {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Hi!" }],
|
|
stop_reason: "end_turn",
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream returns null for Claude message with tool_use block", () => {
|
|
const body = {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "tool_use", id: "toolu_1", name: "search", input: {} }],
|
|
stop_reason: "tool_use",
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream returns null for Claude message with thinking block", () => {
|
|
const body = {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "thinking", thinking: "let me think" }],
|
|
stop_reason: "end_turn",
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), null);
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' for Claude message with empty content", () => {
|
|
const body = { type: "message", role: "assistant", content: [], stop_reason: "end_turn" };
|
|
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' for Claude message with empty-text block", () => {
|
|
const body = {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "" }],
|
|
stop_reason: "end_turn",
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
|
});
|
|
|
|
test("detectMalformedNonStream returns 'empty_choices' for Claude message with (empty response) placeholder", () => {
|
|
// convertOpenAINonStreamingToClaude substitutes this placeholder for empty
|
|
// upstream content; it must still be treated as malformed.
|
|
const body = {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "(empty response)" }],
|
|
stop_reason: "end_turn",
|
|
};
|
|
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
|
});
|
|
|
|
// ── (d) no stack trace leakage ───────────────────────────────────────────────
|
|
|
|
test("synthOpenAIErrorChunk message does NOT contain stack trace path", () => {
|
|
const line = synthOpenAIErrorChunk({ provider: "p", model: "m", reason: "empty_stream" });
|
|
const payload = JSON.parse(line.slice("data: ".length).trimEnd());
|
|
const msg = payload.error.message as string;
|
|
assert.ok(
|
|
!msg.includes("at /"),
|
|
`error.message must not contain stack trace patterns, got: ${msg}`
|
|
);
|
|
});
|
|
|
|
test("reportMalformed200 runs without throwing", () => {
|
|
// smoke: it only logs, should not throw
|
|
assert.doesNotThrow(() =>
|
|
reportMalformed200({
|
|
mode: "nonstream",
|
|
provider: "testprov",
|
|
model: "testmodel",
|
|
connectionId: "conn-123",
|
|
reason: "empty_choices",
|
|
recvBytes: 42,
|
|
recvLines: -1,
|
|
emitted: -1,
|
|
events: { "response.completed": 1 },
|
|
ttftMs: 100,
|
|
elapsedMs: 200,
|
|
})
|
|
);
|
|
});
|