mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
* fix(stream): normalize delta.reasoning to reasoning_content in SSE streaming NVIDIA kimi-k2.5 (and potentially other providers) send reasoning tokens as `delta.reasoning` in SSE streaming chunks instead of the standard OpenAI `delta.reasoning_content` field. This caused reasoning content to be silently dropped during stream passthrough — clients received only the final answer with no reasoning separation. The non-streaming sanitizer (responseSanitizer.ts) already handled this alias, but the streaming pipeline did not. Fix applied in 4 locations: - stream.ts passthrough: normalize + force re-serialize sanitized chunk - stream.ts translate: accumulate reasoning from delta.reasoning - sseParser.ts: collect delta.reasoning in parseSSEToOpenAIResponse - streamPayloadCollector.ts: collect delta.reasoning in buildOpenAISummary * fix: eliminate injectedUsage reuse bug and add reasoning alias tests - Detect delta.reasoning alias before sanitizeStreamingChunk() which already normalizes it, removing dead post-sanitization normalization - Replace injectedUsage reuse with separate needsReserialization flag so reasoning re-serialization cannot block finish_reason/usage mutations on the same SSE chunk (fixes CRITICAL review finding) - Add unit test for parseSSEToOpenAIResponse reasoning alias - Add unit test for buildStreamSummaryFromEvents reasoning alias * fix(stream): separate reasoning from content in passthrough response body The passthroughAccumulatedContent variable was mixing delta.content and delta.reasoning_content into one string, causing the client_response log and responseBody to lose reasoning separation. - Add passthroughAccumulatedReasoning accumulator for reasoning deltas - Set message.reasoning_content in responseBody when reasoning exists - Only accumulate delta.content into passthroughAccumulatedContent * fix: trim leading whitespace from assembled content in log summaries NVIDIA and other providers emit token deltas with leading spaces (e.g. ' The', ' user'). When joined, these produce a leading space in the provider_response and parsed non-streaming response logs. Trim the joined content and reasoning_content in both buildOpenAISummary and parseSSEToOpenAIResponse for consistent log output. * fix(stream): split combined reasoning+content deltas into separate SSE events Some providers (e.g. NVIDIA NIM) send transition chunks with both `delta.reasoning` and `delta.content` in the same SSE event. After sanitization this becomes `reasoning_content` + `content`, which violates the standard OpenAI streaming contract where these fields are never mixed. Clients using if/else logic (LobeChat, etc.) skip content when reasoning_content is present, losing the first content token. Split such combined chunks into two separate SSE events: 1. Reasoning-only event (finish_reason=null, no usage) 2. Content-only event (carries finish_reason and usage)
206 lines
6.3 KiB
JavaScript
206 lines
6.3 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const {
|
|
normalizePayloadForLog,
|
|
protectPayloadForLog,
|
|
serializePayloadForStorage,
|
|
parseStoredPayload,
|
|
} = await import("../../src/lib/logPayloads.ts");
|
|
const {
|
|
createStructuredSSECollector,
|
|
buildStreamSummaryFromEvents,
|
|
compactStructuredStreamPayload,
|
|
} = await import("../../open-sse/utils/streamPayloadCollector.ts");
|
|
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
|
|
|
test("normalizes JSON strings before log protection and redacts sensitive keys", () => {
|
|
const protectedPayload = protectPayloadForLog(
|
|
JSON.stringify({
|
|
authorization: "Bearer secret-token-value",
|
|
nested: {
|
|
apiKey: "top-secret-key",
|
|
},
|
|
})
|
|
);
|
|
|
|
assert.deepEqual(protectedPayload, {
|
|
authorization: "[REDACTED]",
|
|
nested: {
|
|
apiKey: "[REDACTED]",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("wraps raw text payloads in JSON-safe objects", () => {
|
|
const normalized = normalizePayloadForLog("event: ping\ndata: plain-text\n\n");
|
|
|
|
assert.deepEqual(normalized, {
|
|
_rawText: "event: ping\ndata: plain-text\n\n",
|
|
});
|
|
});
|
|
|
|
test("serializes truncated payloads as valid JSON objects", () => {
|
|
const stored = serializePayloadForStorage({ text: "x".repeat(200) }, 80);
|
|
const parsed = parseStoredPayload(stored);
|
|
|
|
assert.equal(parsed._truncated, true);
|
|
assert.equal(parsed._originalSize > 80, true);
|
|
assert.equal(typeof parsed._preview, "string");
|
|
});
|
|
|
|
test("structured SSE collector preserves event order and marks truncation", () => {
|
|
const collector = createStructuredSSECollector({ maxEvents: 2, maxBytes: 200 });
|
|
|
|
collector.push({ type: "response.created", id: "r1" });
|
|
collector.push({ type: "response.output_text.delta", delta: "hi" });
|
|
collector.push({ type: "response.completed" });
|
|
|
|
const payload = collector.build({ done: true });
|
|
|
|
assert.equal(payload._streamed, true);
|
|
assert.equal(payload._eventCount, 3);
|
|
assert.equal(payload._truncated, true);
|
|
assert.equal(payload._droppedEvents, 1);
|
|
assert.equal(payload.events.length, 2);
|
|
assert.equal(payload.events[0].event, "response.created");
|
|
assert.equal(payload.events[1].event, "response.output_text.delta");
|
|
assert.deepEqual(payload.summary, { done: true });
|
|
});
|
|
|
|
test("builds compact OpenAI stream summary for detailed logs", () => {
|
|
const collector = createStructuredSSECollector({ stage: "provider_response" });
|
|
|
|
collector.push({
|
|
id: "chatcmpl_1",
|
|
object: "chat.completion.chunk",
|
|
created: 123,
|
|
model: "gpt-4.1-mini",
|
|
choices: [{ index: 0, delta: { role: "assistant", content: "Hello " } }],
|
|
});
|
|
collector.push({
|
|
id: "chatcmpl_1",
|
|
object: "chat.completion.chunk",
|
|
created: 123,
|
|
model: "gpt-4.1-mini",
|
|
choices: [{ index: 0, delta: { content: "world" } }],
|
|
});
|
|
collector.push({
|
|
id: "chatcmpl_1",
|
|
object: "chat.completion.chunk",
|
|
created: 123,
|
|
model: "gpt-4.1-mini",
|
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
|
});
|
|
|
|
const summary = buildStreamSummaryFromEvents(
|
|
collector.getEvents(),
|
|
FORMATS.OPENAI,
|
|
"gpt-4.1-mini"
|
|
);
|
|
const compact = compactStructuredStreamPayload(
|
|
collector.build(summary, { includeEvents: false })
|
|
);
|
|
|
|
assert.equal(compact.object, "chat.completion");
|
|
assert.equal(compact.choices[0].message.content, "Hello world");
|
|
assert.equal(compact.choices[0].finish_reason, "stop");
|
|
assert.equal(compact._omniroute_stream.stage, "provider_response");
|
|
assert.equal(compact._omniroute_stream.eventCount, 3);
|
|
assert.equal("events" in compact, false);
|
|
});
|
|
|
|
test("builds compact Claude stream summary for detailed logs", () => {
|
|
const collector = createStructuredSSECollector({ stage: "provider_response" });
|
|
|
|
collector.push({
|
|
type: "message_start",
|
|
message: {
|
|
id: "msg_1",
|
|
model: "claude-sonnet-4",
|
|
role: "assistant",
|
|
usage: { input_tokens: 11 },
|
|
},
|
|
});
|
|
collector.push({
|
|
type: "content_block_start",
|
|
index: 0,
|
|
content_block: { type: "text", text: "" },
|
|
});
|
|
collector.push({
|
|
type: "content_block_delta",
|
|
index: 0,
|
|
delta: { type: "text_delta", text: "你好" },
|
|
});
|
|
collector.push({
|
|
type: "message_delta",
|
|
delta: { stop_reason: "end_turn" },
|
|
usage: { output_tokens: 7 },
|
|
});
|
|
|
|
const summary = buildStreamSummaryFromEvents(
|
|
collector.getEvents(),
|
|
FORMATS.CLAUDE,
|
|
"claude-sonnet-4"
|
|
);
|
|
const compact = compactStructuredStreamPayload(
|
|
collector.build(summary, { includeEvents: false })
|
|
);
|
|
|
|
assert.equal(compact.type, "message");
|
|
assert.equal(compact.model, "claude-sonnet-4");
|
|
assert.deepEqual(compact.content, [{ type: "text", text: "你好" }]);
|
|
assert.equal(compact.usage.input_tokens, 11);
|
|
assert.equal(compact.usage.output_tokens, 7);
|
|
assert.equal(compact._omniroute_stream.eventCount, 4);
|
|
});
|
|
|
|
test("builds compact OpenAI summary with reasoning alias (delta.reasoning)", () => {
|
|
const collector = createStructuredSSECollector({ stage: "provider_response" });
|
|
|
|
collector.push({
|
|
id: "chatcmpl_r1",
|
|
object: "chat.completion.chunk",
|
|
created: 100,
|
|
model: "moonshotai/kimi-k2.5",
|
|
choices: [{ index: 0, delta: { role: "assistant" } }],
|
|
});
|
|
collector.push({
|
|
id: "chatcmpl_r1",
|
|
object: "chat.completion.chunk",
|
|
created: 100,
|
|
model: "moonshotai/kimi-k2.5",
|
|
choices: [{ index: 0, delta: { reasoning: "Let me think..." } }],
|
|
});
|
|
collector.push({
|
|
id: "chatcmpl_r1",
|
|
object: "chat.completion.chunk",
|
|
created: 100,
|
|
model: "moonshotai/kimi-k2.5",
|
|
choices: [{ index: 0, delta: { content: "The answer is 4." } }],
|
|
});
|
|
collector.push({
|
|
id: "chatcmpl_r1",
|
|
object: "chat.completion.chunk",
|
|
created: 100,
|
|
model: "moonshotai/kimi-k2.5",
|
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
});
|
|
|
|
const summary = buildStreamSummaryFromEvents(
|
|
collector.getEvents(),
|
|
FORMATS.OPENAI,
|
|
"moonshotai/kimi-k2.5"
|
|
);
|
|
const compact = compactStructuredStreamPayload(
|
|
collector.build(summary, { includeEvents: false })
|
|
);
|
|
|
|
assert.equal(compact.object, "chat.completion");
|
|
assert.equal(compact.choices[0].message.content, "The answer is 4.");
|
|
assert.equal(compact.choices[0].message.reasoning_content, "Let me think...");
|
|
assert.equal(compact.choices[0].finish_reason, "stop");
|
|
});
|