Files
2026-07-13 13:39:12 +08:00

128 lines
4.7 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;
}
if (!addReadableReasoning(message, delta)) {
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;
}