mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
* 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
137 lines
5.3 KiB
TypeScript
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;
|
|
}
|