mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix(translator): join reasoning summary segments with newline separators (#9500)
Closes #9500
This commit is contained in:
committed by
GitHub
parent
466d843a2a
commit
0a0fdad001
1
changelog.d/fixes/9500-reasoning-summary-separator.md
Normal file
1
changelog.d/fixes/9500-reasoning-summary-separator.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(translator): join reasoning summary segments with newline separators (#9500)
|
||||
@@ -166,14 +166,18 @@ export function translateNonStreamingResponse(
|
||||
if (!part || typeof part !== "object") continue;
|
||||
const partObj = toRecord(part);
|
||||
if (partObj.type === "summary_text" && typeof partObj.text === "string") {
|
||||
reasoningContent += partObj.text;
|
||||
// #9500 — reasoning summary parts are discrete segments; join with "\n\n"
|
||||
// (matches extractThinkingFromContent convention) so they don't glue back-to-back.
|
||||
reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text;
|
||||
}
|
||||
}
|
||||
} else if (itemObj.type === "reasoning" && Array.isArray(itemObj.summary)) {
|
||||
for (const part of itemObj.summary) {
|
||||
const partObj = toRecord(part);
|
||||
if (partObj.type === "summary_text" && typeof partObj.text === "string") {
|
||||
reasoningContent += partObj.text;
|
||||
// #9500 — reasoning summary parts are discrete segments; join with "\n\n"
|
||||
// (matches extractThinkingFromContent convention) so they don't glue back-to-back.
|
||||
reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text;
|
||||
}
|
||||
}
|
||||
} else if (itemObj.type === "function_call") {
|
||||
@@ -328,7 +332,9 @@ export function translateNonStreamingResponse(
|
||||
for (const part of content.parts) {
|
||||
const partObj = toRecord(part);
|
||||
if (partObj.thought === true && typeof partObj.text === "string") {
|
||||
reasoningContent += partObj.text;
|
||||
// #9500 — Gemini thinking parts are discrete segments; join with "\n\n"
|
||||
// (matches extractThinkingFromContent convention) so they don't glue back-to-back.
|
||||
reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -711,11 +711,19 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
|
||||
toIdString(evt.item_id)
|
||||
);
|
||||
const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : [];
|
||||
const firstPart =
|
||||
summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" };
|
||||
firstPart.type = firstPart.type || "summary_text";
|
||||
firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`;
|
||||
summary[0] = firstPart;
|
||||
// #9500 — respect summary_index: each segment is a distinct summary_text
|
||||
// part. Place deltas at summary[summary_index] (growing the array) so
|
||||
// segments are preserved for later "\n\n" joining on the non-stream path,
|
||||
// instead of overwriting summary[0] regardless of index.
|
||||
const summaryIndex =
|
||||
typeof evt.summary_index === "number" ? evt.summary_index : 0;
|
||||
const part =
|
||||
summary[summaryIndex] && typeof summary[summaryIndex] === "object"
|
||||
? { ...toRecord(summary[summaryIndex]) }
|
||||
: { type: "summary_text", text: "" };
|
||||
part.type = part.type || "summary_text";
|
||||
part.text = `${toString(part.text)}${toString(evt.delta)}`;
|
||||
summary[summaryIndex] = part;
|
||||
reasoningItem.summary = summary;
|
||||
}
|
||||
|
||||
@@ -726,11 +734,16 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
|
||||
toIdString(evt.item_id)
|
||||
);
|
||||
const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : [];
|
||||
const firstPart =
|
||||
summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" };
|
||||
firstPart.type = firstPart.type || "summary_text";
|
||||
firstPart.text = toString(evt.text, toString(firstPart.text));
|
||||
summary[0] = firstPart;
|
||||
// #9500 — respect summary_index on the terminal done event too.
|
||||
const summaryIndex =
|
||||
typeof evt.summary_index === "number" ? evt.summary_index : 0;
|
||||
const part =
|
||||
summary[summaryIndex] && typeof summary[summaryIndex] === "object"
|
||||
? { ...toRecord(summary[summaryIndex]) }
|
||||
: { type: "summary_text", text: "" };
|
||||
part.type = part.type || "summary_text";
|
||||
part.text = toString(evt.text, toString(part.text));
|
||||
summary[summaryIndex] = part;
|
||||
reasoningItem.summary = summary;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
normalizeOutputIndex,
|
||||
normalizeUpstreamFailure,
|
||||
getVisibleResponsesReasoningSummaryText,
|
||||
buildResponsesReasoningSummaryDelta,
|
||||
} from "./openai-responses/pureHelpers.ts";
|
||||
import { createEventEmitter } from "./openai-responses/eventEmitter.ts";
|
||||
import { buildResponsesToolCallItem } from "./responsesToolItem.ts";
|
||||
@@ -1122,17 +1123,16 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
};
|
||||
}
|
||||
|
||||
// Handle true reasoning summary ("Thought for 15s").
|
||||
// Emit as `delta.reasoning_content` — matches the shape used by the
|
||||
// `reasoning_content_text.delta` branch above and is what Chat clients
|
||||
// (OpenCode, Claude Code, Cursor, etc.) actually render in their thinking
|
||||
// panel. A nested `delta.reasoning.summary` object is swallowed by most
|
||||
// stream mergers and never reaches the user.
|
||||
// Handle true reasoning summary ("Thought for 15s"). Emit as `delta.reasoning_content`
|
||||
// — matches the `reasoning_content_text.delta` branch above and is what Chat clients
|
||||
// (OpenCode, Claude Code, Cursor, etc.) render in their thinking panel. A nested
|
||||
// `delta.reasoning.summary` object is swallowed by most stream mergers.
|
||||
if (eventType === "response.reasoning_summary_text.delta") {
|
||||
const reasoningDelta = data.delta || "";
|
||||
if (!reasoningDelta) return null;
|
||||
markResponsesReasoningDeltaEmitted(state, data.item_id);
|
||||
return buildResponsesReasoningDeltaChunk(state, reasoningDelta);
|
||||
const deltaText = buildResponsesReasoningSummaryDelta(state, data, reasoningDelta);
|
||||
return buildResponsesReasoningDeltaChunk(state, deltaText);
|
||||
}
|
||||
|
||||
// #5786 — reasoning summary exposed ONLY as a terminal snapshot on
|
||||
|
||||
@@ -166,11 +166,46 @@ export function normalizeUpstreamFailure(data, fallbackType = "server_error") {
|
||||
|
||||
export function extractResponsesReasoningSummaryText(item) {
|
||||
if (!item || !Array.isArray(item.summary)) return "";
|
||||
// #9500 — reasoning summary parts are discrete segments; join with "\n\n"
|
||||
// (matches extractThinkingFromContent convention). Filter empties so an
|
||||
// empty summary_text element does not produce a dangling separator.
|
||||
return item.summary
|
||||
.map((part) =>
|
||||
part && typeof part === "object" && typeof part.text === "string" ? part.text : ""
|
||||
)
|
||||
.join("");
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
// #9500 — streaming separator helper. When summary_index increments mid-stream
|
||||
// for a given item_id, a new reasoning segment begins; prefix "\n\n" so segments
|
||||
// don't arrive back-to-back. Only prefixes when a delta was already emitted for
|
||||
// the item AND the index advanced — never on the first segment.
|
||||
export function buildResponsesReasoningSummaryDelta(state, data, reasoningDelta) {
|
||||
const itemId = data.item_id != null ? String(data.item_id) : "";
|
||||
const summaryIndex =
|
||||
typeof data.summary_index === "number" ? data.summary_index : null;
|
||||
if (!(state.reasoningSummaryIndex instanceof Map)) {
|
||||
state.reasoningSummaryIndex = new Map();
|
||||
}
|
||||
const lastIndex = itemId ? state.reasoningSummaryIndex.get(itemId) : undefined;
|
||||
const alreadyEmittedForItem = itemId
|
||||
? state.reasoningItemsWithDelta instanceof Set &&
|
||||
state.reasoningItemsWithDelta.has(itemId)
|
||||
: Boolean(state.reasoningDeltaEmitted);
|
||||
let deltaText = reasoningDelta;
|
||||
if (
|
||||
summaryIndex !== null &&
|
||||
lastIndex !== undefined &&
|
||||
summaryIndex > lastIndex &&
|
||||
alreadyEmittedForItem
|
||||
) {
|
||||
deltaText = `\n\n${reasoningDelta}`;
|
||||
}
|
||||
if (itemId && (lastIndex === undefined || summaryIndex > lastIndex)) {
|
||||
state.reasoningSummaryIndex.set(itemId, summaryIndex);
|
||||
}
|
||||
return deltaText;
|
||||
}
|
||||
|
||||
// #7095/#7176 — when Codex exposes a reasoning item only as encrypted private
|
||||
|
||||
85
tests/unit/repro-9500-reasoning-separator.test.ts
Normal file
85
tests/unit/repro-9500-reasoning-separator.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { translateNonStreamingResponse } = await import(
|
||||
"../../open-sse/handlers/responseTranslator.ts"
|
||||
);
|
||||
const { extractResponsesReasoningSummaryText } = await import(
|
||||
"../../open-sse/translator/response/openai-responses/pureHelpers.ts"
|
||||
);
|
||||
const { openaiResponsesToOpenAIResponse } = await import(
|
||||
"../../open-sse/translator/response/openai-responses.ts"
|
||||
);
|
||||
|
||||
const SEG_A = "**Planning exact formatted output**";
|
||||
const SEG_B = "**Confirming exact reproduction requirement**";
|
||||
const EXPECTED = `${SEG_A}\n\n${SEG_B}`;
|
||||
|
||||
function readReasoning(msg) {
|
||||
if (!msg) return null;
|
||||
return (
|
||||
msg.reasoning_content ??
|
||||
msg.reasoning ??
|
||||
(Array.isArray(msg.reasoning_summary)
|
||||
? msg.reasoning_summary.map((p) => p?.text ?? "").join("")
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
test("#9500 site 1: non-streaming reasoning summary parts joined with separator", () => {
|
||||
const responseBody = {
|
||||
object: "response",
|
||||
model: "cx/gpt-test",
|
||||
output: [
|
||||
{ type: "reasoning", id: "rs_1", summary: [
|
||||
{ type: "summary_text", text: SEG_A },
|
||||
{ type: "summary_text", text: SEG_B },
|
||||
]},
|
||||
{ type: "message", content: [{ type: "output_text", text: "ok" }] },
|
||||
],
|
||||
usage: {},
|
||||
};
|
||||
const projected = translateNonStreamingResponse(
|
||||
responseBody,
|
||||
FORMATS.OPENAI_RESPONSES, // target — flattens into chat.completion
|
||||
FORMATS.OPENAI // source
|
||||
);
|
||||
const msg = projected?.choices?.[0]?.message;
|
||||
const reasoning = readReasoning(msg);
|
||||
assert.ok(reasoning !== null, `could not locate reasoning field: ${JSON.stringify(msg)}`);
|
||||
assert.equal(reasoning, EXPECTED, `segments must be separated by "\n\n", got: ${JSON.stringify(reasoning)}`);
|
||||
});
|
||||
|
||||
test("#9500 site 2: extractResponsesReasoningSummaryText joins with separator", () => {
|
||||
const item = {
|
||||
type: "reasoning", id: "rs_1",
|
||||
summary: [
|
||||
{ type: "summary_text", text: SEG_A },
|
||||
{ type: "summary_text", text: SEG_B },
|
||||
],
|
||||
};
|
||||
const text = extractResponsesReasoningSummaryText(item);
|
||||
assert.equal(text, EXPECTED, `helper must join with "\n\n", got: ${JSON.stringify(text)}`);
|
||||
});
|
||||
|
||||
test("#9500 site 3: streaming emits separator when summary_index changes", () => {
|
||||
const state = { started: false, chatId: null, created: null, toolCallIndex: 0, finishReasonSent: false };
|
||||
const delta1 = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.reasoning_summary_text.delta", delta: SEG_A, item_id: "rs_1", output_index: 0, summary_index: 0 },
|
||||
state
|
||||
);
|
||||
assert.ok(delta1, "first delta should produce a chunk");
|
||||
const a = delta1.choices[0].delta.reasoning_content ?? delta1.choices[0].delta.reasoning_text;
|
||||
|
||||
const delta2 = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.reasoning_summary_text.delta", delta: SEG_B, item_id: "rs_1", output_index: 0, summary_index: 1 },
|
||||
state
|
||||
);
|
||||
assert.ok(delta2, "second delta should produce a chunk");
|
||||
const b = delta2.choices[0].delta.reasoning_content ?? delta2.choices[0].delta.reasoning_text;
|
||||
|
||||
assert.ok(b.startsWith("\n\n"), `new-segment delta must be prefixed with "\n\n", got: ${JSON.stringify(b)}`);
|
||||
assert.equal(b, `\n\n${SEG_B}`);
|
||||
assert.equal(a, SEG_A);
|
||||
});
|
||||
Reference in New Issue
Block a user