Files
OmniRoute/tests/unit/responses-api-truncation.test.ts
Markus Hartung 1f7c9a9cb2 fix(responses-api): fold in #9183's index-collision, truncation, and reasoning-replay fixes
Closed #9183 in favor of this branch at the operator's request. Folds in
the exact PR diff (verified byte-identical via `gh pr diff 9183`, applied
cleanly with `git apply`):

- Fixed output_index collisions between reasoning/message/tool-call items
  in Responses API streaming, which caused clients to ignore tool calls.
- Fixed a race in chunk processing where tool calls and finish signals
  were lost when they arrived in the same chunk as reasoning/text content.
- Enabled full processing of multi-choice chunks (previously truncated to
  a single choice).
- Added reasoning_content capture/replay for DeepSeek-based models
  (big-pickle), keyed off the real position in the next turn's replayed
  messages array instead of a hardcoded index 0 — prevents history
  corruption that caused models to lose context and stop prematurely.
- Added big-pickle to models recognized for native textual reasoning tags
  so <think> blocks convert to reasoning items correctly.
- Improved Responses-to-Chat translation to group reasoning, content, and
  tool calls into a single assistant turn, as strict OpenAI-compatible
  upstreams require.
- Added a descriptive placeholder for encrypted Responses API reasoning
  blocks so downgraded Chat API requests keep context.

Test plan:
- All 82 tests across reasoning-cache.test.ts, translator-helper-branches.test.ts,
  translator-request-openai-responses.test.ts, and the 3 new test files
  (responses-api-truncation.test.ts, responses-replay-fixes.test.ts,
  responses-request-translation.test.ts) pass
- npm run typecheck:core — clean
- npm run check:file-size — chatCore.ts (5024->5032) and
  translator/response/openai-responses.ts (1174->1180) rebaselined,
  both cohesive additions at the two reasoning-cache capture call sites and
  the turn-grouping fix; tests/unit/reasoning-cache.test.ts rebaselined at
  1035 (crosses the 1000-line new-file test cap, entirely this fold's diff)
2026-08-05 12:08:03 +02:00

100 lines
3.6 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
const { createResponsesApiTransformStream } =
await import("../../open-sse/transformer/responsesTransformer.ts");
const { openaiToOpenAIResponsesResponse } =
await import("../../open-sse/translator/response/openai-responses.ts");
const { initState } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const encoder = new TextEncoder();
const decoder = new TextDecoder();
async function runTransformStream(chunks) {
const stream = createResponsesApiTransformStream(null, 3000, { parseTextualReasoningTags: true });
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
const output = [];
const readerTask = (async () => {
while (true) {
const { value, done } = await reader.read();
if (done) break;
output.push(decoder.decode(value));
}
})();
for (const chunk of chunks) {
await writer.write(encoder.encode(chunk));
}
await writer.close();
await readerTask;
return output.join("");
}
function parseSseOutput(output) {
return output
.trim()
.split("\n\n")
.map((entry) => {
const lines = entry.split("\n");
const eventLine = lines.find((line) => line.startsWith("event: "));
const dataLine = lines.find((line) => line.startsWith("data: "));
return {
event: eventLine ? eventLine.slice("event: ".length) : null,
data: dataLine ? dataLine.slice("data: ".length) : null,
};
});
}
test("ResponsesTransformer: should not skip tool_calls when reasoning is present in same chunk", async () => {
const output = await runTransformStream([
'data: {"choices":[{"index":0,"delta":{"content":"<think>Thinking...</think>","tool_calls":[{"index":0,"id":"call_1","function":{"name":"test","arguments":"{}"}}]}}]}\n\n',
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"arguments":"{}"}}]},"finish_reason":"tool_calls"}]}\n\n',
]);
const events = parseSseOutput(output);
const toolCallAdded = events.find(
(e) =>
e.event === "response.output_item.added" && JSON.parse(e.data).item.type === "function_call"
);
assert.ok(toolCallAdded, "Tool call should be added even if reasoning is in the same chunk");
});
test("ResponsesTransformer: should process all choices in a chunk", async () => {
const output = await runTransformStream([
'data: {"choices":[{"index":0,"delta":{"content":" "}},{"index":1,"delta":{"content":"Done"},"finish_reason":"stop"}]}\n\n',
]);
const events = parseSseOutput(output);
const item1Done = events.find(
(e) => e.event === "response.output_item.done" && JSON.parse(e.data).output_index === 1
);
assert.ok(item1Done, "Choice 1 should be processed even if Choice 0 triggered a skip/trim");
});
test("openaiToOpenAIResponsesResponse: should not skip tool_calls when reasoning is present in same chunk", () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
const chunk = {
id: "1",
model: "deepseek-r1",
choices: [
{
index: 0,
delta: {
content: "<think>Thinking...",
tool_calls: [{ index: 0, id: "call_1", function: { name: "test", arguments: "{}" } }],
},
},
],
};
const events = openaiToOpenAIResponsesResponse(chunk, state);
const toolCallAdded = events.find(
(e) => e.event === "response.output_item.added" && e.data.item.type === "function_call"
);
assert.ok(toolCallAdded, "Tool call should be added even if reasoning is in the same chunk");
});