Files
OmniRoute/open-sse/utils/jsonToSse.ts
tom c193595db6 fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present (#12665) (#12688)
* fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present (#12665)

OpenRouter thinking models return both a "reasoning" string and a
"reasoning_details[].text" array for the same thinking trace. OmniRoute's
reasoning promotion gated on "is any readable value present" (which includes
the "reasoning" alias), so reasoning_content was never populated and clients
like opencode that only read reasoning_content lost all thinking traces.
Encrypted-only reasoning_details items are left intact (not flattened).

Fixes all three promotion gates:
- non-streaming: copyOpenAICompatibleReasoningFields now mirrors
  reasoning_details[].text into reasoning_content unless reasoning_content
  itself is present
- streaming mirror block: same gate fix on getReadableReasoningValue
- streaming passthrough: force re-serialization when sanitize added a
  reasoning_content the upstream delta did not carry (needsReserialization
  was false because hasUnsupportedReasoningSignal requires !readable)

Tests: non-streaming + streaming unit regressions and an integration E2E
that drives the full handleChat path against a mock OpenRouter provider.

Also closes the same latent gate in the JSON-to-SSE rehydrator
(jsonToSse.ts buildReasoningDelta): a populated reasoning string used to
short-circuit the unsupported-alias mirror, so reasoning_details[].text
was dropped when synthesizing an SSE stream from a non-streaming JSON
body. Adds a #12665 regression test for that path, fixes an over-indented
brace in stream.ts (lint), and restores the missing trailing newline in
the E2E.

* docs(changelog): add fragment for #12688 — fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present

* fix(tests): replace any with typed casts in #12665 regressions to satisfy no-explicit-any gate
2026-09-19 00:02:22 -03:00

137 lines
5.3 KiB
TypeScript

/**
* #3089 — Convert a complete OpenAI-style chat-completion JSON body into an
* equivalent OpenAI SSE (`chat.completion.chunk`) stream.
*
* Some "reasoning" openai-compatible upstreams ignore a `stream: true` request
* and reply with a single `application/json` chat-completion body instead of an
* SSE stream. OmniRoute's streaming readiness check only recognizes SSE `data:`
* frames, so such a body produced a spurious `STREAM_EARLY_EOF` / HTTP 502 even
* though it carried valid `content` / `reasoning_content`. Synthesizing an SSE
* stream from that JSON lets the normal streaming pipeline (and the client) get
* a valid stream that preserves both `content` and `reasoning_content`.
*
* Returns "" when the text is not a parseable chat-completion object with at
* least one choice — callers then fall back to the original (error) handling.
*/
import { normalizeOpenAICompatibleFinishReasonString } from "./finishReason.ts";
import { getUnsupportedReasoningValue } from "./reasoningFields.ts";
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function nonEmptyString(value: unknown): string {
return typeof value === "string" && value.length > 0 ? value : "";
}
function addReadableReasoning(message: JsonRecord, delta: JsonRecord): boolean {
const reasoningContent = nonEmptyString(message.reasoning_content);
if (reasoningContent) {
delta.reasoning_content = reasoningContent;
return true;
}
const reasoning = nonEmptyString(message.reasoning);
if (reasoning) {
delta.reasoning = reasoning;
return true;
}
return false;
}
function addUnsupportedReasoning(message: JsonRecord, delta: JsonRecord) {
const reasoningContent = getUnsupportedReasoningValue(message);
if (reasoningContent) {
delta.reasoning_content = reasoningContent;
}
}
function buildReasoningDelta(message: JsonRecord): JsonRecord | null {
const delta: JsonRecord = {};
if (Array.isArray(message.reasoning_details)) {
delta.reasoning_details = message.reasoning_details;
}
// Always emit the readable field (reasoning_content, else the reasoning
// alias) WITHOUT short-circuiting the unsupported-alias mirror below.
addReadableReasoning(message, delta);
// Mirror unsupported reasoning aliases (reasoning_text / thinking / thought /
// reasoning_details[].text) into reasoning_content unless reasoning_content
// itself is present — same gate as copyOpenAICompatibleReasoningFields
// (#12665). A populated `reasoning` string must NOT skip this: OpenRouter
// thinking models send BOTH `reasoning` and `reasoning_details[].text`.
if (!nonEmptyString(message.reasoning_content)) {
addUnsupportedReasoning(message, delta);
}
return Object.keys(delta).length > 0 ? delta : null;
}
function sseEvent(payload: JsonRecord): string {
return `data: ${JSON.stringify(payload)}\n\n`;
}
export function synthesizeOpenAiSseFromJson(jsonText: string): string {
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return "";
}
if (!isRecord(parsed)) return "";
const choices = parsed.choices;
if (!Array.isArray(choices) || choices.length === 0) return "";
const id = typeof parsed.id === "string" && parsed.id ? parsed.id : "chatcmpl-omniroute-sse";
const created = typeof parsed.created === "number" ? parsed.created : 0;
const model = typeof parsed.model === "string" ? parsed.model : "";
const base = { id, object: "chat.completion.chunk", created, model };
let out = "";
let emittedAny = false;
choices.forEach((choice, fallbackIndex) => {
if (!isRecord(choice)) return;
const index = typeof choice.index === "number" ? choice.index : fallbackIndex;
const message = isRecord(choice.message) ? choice.message : {};
// Emit role, reasoning_content, content and tool_calls as SEPARATE sequential
// deltas — the same shape a real reasoning model streams (reasoning first,
// then content). Combining them in one delta caused the openai→openai
// translator to re-split and DUPLICATE reasoning_content across chunks
// (#3089 follow-up); separate deltas pass through cleanly with no duplication.
const role = typeof message.role === "string" ? message.role : "assistant";
const emitDelta = (delta: JsonRecord) => {
out += sseEvent({ ...base, choices: [{ index, delta, finish_reason: null }] });
};
emitDelta({ role });
const reasoningDelta = buildReasoningDelta(message);
if (reasoningDelta) {
emitDelta(reasoningDelta);
}
if (typeof message.content === "string" && message.content.length > 0) {
emitDelta({ content: message.content });
}
if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
emitDelta({ tool_calls: message.tool_calls });
}
const finishReason = normalizeOpenAICompatibleFinishReasonString(choice.finish_reason);
const finalChoice: JsonRecord = { index, delta: {}, finish_reason: finishReason };
const finalChunk: JsonRecord = { ...base, choices: [finalChoice] };
if (isRecord(parsed.usage)) finalChunk.usage = parsed.usage;
out += sseEvent(finalChunk);
emittedAny = true;
});
if (!emittedAny) return "";
out += "data: [DONE]\n\n";
return out;
}