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
This commit is contained in:
tom
2026-09-18 20:02:22 -07:00
committed by GitHub
parent 0f5f83c5ed
commit c193595db6
7 changed files with 415 additions and 5 deletions

View File

@@ -0,0 +1 @@
- **fix(open-sse):** `reasoning_details[].text` is now promoted to `reasoning_content` even when `reasoning` is also present, so OpenRouter thinking models (GLM-5.3-Flash, DeepSeek-V4-Flash, Kimi K3) no longer lose their thinking traces in clients that only read `reasoning_content` ([#12688](https://github.com/diegosouzapw/OmniRoute/pull/12688) — thanks @thomasmaerz)

View File

@@ -55,7 +55,16 @@ function buildReasoningDelta(message: JsonRecord): JsonRecord | null {
delta.reasoning_details = message.reasoning_details;
}
if (!addReadableReasoning(message, delta)) {
// 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);
}

View File

@@ -111,7 +111,14 @@ export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target:
if (source.thinking !== undefined) target.thinking = source.thinking;
if (source.thought !== undefined) target.thought = source.thought;
if (Array.isArray(source.reasoning_details)) target.reasoning_details = source.reasoning_details;
if (!getReadableReasoningValue(target)) {
// Mirror unsupported reasoning aliases (reasoning_text / thinking / thought /
// reasoning_details[].text) into the client-readable reasoning_content field.
// Only the presence of an existing reasoning_content blocks this — NOT the
// `reasoning` string. OpenRouter thinking models return BOTH `reasoning` and
// `reasoning_details[].text`; previously `reasoning` alone short-circuited the
// promotion, so reasoning_content was never set and thinking traces were lost
// for clients (e.g. opencode) that only read reasoning_content.
if (!nonEmptyString(target.reasoning_content)) {
const mirrored = getUnsupportedReasoningValue(source);
if (mirrored) target.reasoning_content = mirrored;
}

View File

@@ -1906,6 +1906,9 @@ export function createSSEStream(options: StreamOptions = {}) {
parsed?.id != null && typeof parsed.id !== "string";
const rawDelta = parsed.choices?.[0]?.delta;
const hadReasoningAlias = hasUnsupportedReasoningSignal(rawDelta);
const hadUpstreamReasoningContent =
typeof rawDelta?.reasoning_content === "string" &&
rawDelta.reasoning_content.length > 0;
if (!projectedFailure) {
parsed = sanitizeStreamingChunk(parsed);
@@ -1967,12 +1970,22 @@ export function createSSEStream(options: StreamOptions = {}) {
}
// Track whether we need to re-serialize (separate from injectedUsage
// to avoid blocking subsequent finish_reason / usage mutations)
// to avoid blocking subsequent finish_reason / usage mutations).
// sanitizeStreamingChunk above can MIRROR reasoning_details[].text
// into reasoning_content when the upstream only sent `reasoning`
// (OpenRouter thinking models, #12665). hadReasoningAlias covers
// reasoning_text/thinking/thought aliases, but a populated `reasoning`
// string makes hasUnsupportedReasoningSignal return false — so we also
// force a re-serialize when sanitize added a reasoning_content that the
// upstream delta did not already carry.
const needsReserialization =
splitMixedReasoningContent ||
thinkParsed ||
hadReasoningAlias ||
(delta?.content === "" && delta?.reasoning_content);
(delta?.content === "" && delta?.reasoning_content) ||
(!hadUpstreamReasoningContent &&
typeof delta?.reasoning_content === "string" &&
delta.reasoning_content.length > 0);
// T18: Track if we saw tool calls & accumulate for call log
if (delta?.tool_calls && delta.tool_calls.length > 0) {
@@ -2244,7 +2257,17 @@ export function createSSEStream(options: StreamOptions = {}) {
);
}
// Mirror only client-unsupported reasoning aliases into `reasoning_content`.
if (!openAiReasoning) {
// Gate on reasoning_content being ABSENT (not on getReadableReasoningValue
// which also includes the `reasoning` string): OpenRouter thinking models
// return BOTH `reasoning` and `reasoning_details[].text`, and `reasoning`
// alone previously skipped the mirror, dropping thinking traces for clients
// that only read `reasoning_content` (#12665).
const openAiReasoningContent =
typeof openAiDelta?.reasoning_content === "string" &&
openAiDelta.reasoning_content.length > 0
? openAiDelta.reasoning_content
: "";
if (!openAiReasoningContent) {
const delta = openAiDelta;
const r = getUnsupportedReasoningValue(delta);
if (typeof r === "string" && r.length > 0) {

View File

@@ -0,0 +1,233 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-openrouter-reasoning-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.REQUIRE_API_KEY = "false";
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-openrouter-reasoning-secret";
process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = "true";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const { handleChat } = await import("../../src/sse/handlers/chat.ts");
const { initTranslators } = await import("../../open-sse/translator/index.ts");
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
const { resetAllCircuitBreakers } =
await import("../../src/shared/utils/circuitBreaker.ts");
const originalFetch = globalThis.fetch;
const originalRetryDelayMs = BaseExecutor.RETRY_CONFIG.delayMs;
type FetchCall = {
url: string;
method?: string;
headers: Record<string, string>;
body: Record<string, unknown> | null;
};
function toPlainHeaders(headers: HeadersInit | undefined | null) {
if (!headers) return {};
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
if (Array.isArray(headers)) return Object.fromEntries(headers);
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
);
}
function buildRequest(url: string, overrides: RequestInit = {}) {
const headers = new Headers({
"content-type": "application/json",
...((overrides.headers as Record<string, string>) || {}),
});
return new Request(url, { ...overrides, headers });
}
/**
* OpenRouter-shaped non-streaming completion: the provider returns BOTH a
* `reasoning` string AND a `reasoning_details[]` array carrying the same
* thinking text. This is exactly what DeepSeek V4 / GLM 5.3 / Kimi K3 return
* through OpenRouter (#12665).
*/
function buildOpenRouterStreamingSse({
thinking = "Hmm, let me think this through",
content = "Visible answer",
} = {}) {
const chunk = (delta: Record<string, unknown>) =>
`data: ${JSON.stringify({
id: "chatcmpl_openrouter_reasoning_stream",
object: "chat.completion.chunk",
created: 1783636289,
model: "deepseek/deepseek-v4-flash",
choices: [
{ index: 0, delta, finish_reason: null, logprobs: null },
],
})}\n\n`;
return (
chunk({ reasoning: thinking, reasoning_details: [{ type: "reasoning.text", text: thinking }] }) +
chunk({ content }) +
chunk({}) +
chunk({}) +
"data: [DONE]\n\n"
);
}
function buildOpenRouterResponse({
content = "Visible answer",
thinking = "Hmm, let me think this through",
} = {}) {
return new Response(
JSON.stringify({
id: "chatcmpl_openrouter_reasoning",
object: "chat.completion",
created: 1783636289,
model: "deepseek/deepseek-v4-flash",
choices: [
{
index: 0,
message: {
role: "assistant",
content,
reasoning: thinking,
reasoning_details: [{ type: "reasoning.text", text: thinking }],
},
finish_reason: "stop",
logprobs: null,
},
],
usage: { prompt_tokens: 20, completion_tokens: 30, total_tokens: 50 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
test.before(async () => {
await initTranslators();
});
test.afterEach(() => {
globalThis.fetch = originalFetch;
BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs;
BaseExecutor.freeze?.();
clearInflight();
resetAllCircuitBreakers();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
test("openrouter provider: reasoning_details[].text is mirrored to reasoning_content even when reasoning string is present", async () => {
await providersDb.createProviderConnection({
provider: "openrouter",
authType: "apikey",
name: "openrouter-reasoning-e2e",
apiKey: "sk-mock-openrouter-key",
isActive: true,
testStatus: "active",
providerSpecificData: { baseUrl: "http://mock-openrouter.invalid/v1" },
});
const fetchCalls: FetchCall[] = [];
globalThis.fetch = async (input, init: RequestInit = {}) => {
fetchCalls.push({
url: String(input),
method: init.method || "GET",
headers: toPlainHeaders(init.headers),
body: init.body ? JSON.parse(String(init.body)) : null,
});
return buildOpenRouterResponse();
};
const response = await handleChat(
buildRequest("http://localhost/v1/chat/completions", {
method: "POST",
body: JSON.stringify({
model: "openrouter/auto",
stream: false,
messages: [{ role: "user", content: "Think through this carefully." }],
}),
})
);
const json = (await response.json()) as {
choices: Array<{
message: {
content?: unknown;
reasoning?: unknown;
reasoning_content?: unknown;
reasoning_details?: unknown;
};
}>;
};
assert.equal(response.status, 200, JSON.stringify(json));
assert.equal(fetchCalls.length, 1, "should make exactly one upstream call");
assert.match(fetchCalls[0].url, /mock-openrouter\.invalid/, fetchCalls[0].url);
const message = json.choices[0].message;
assert.equal(message.content, "Visible answer");
// The client-readable field must be populated from reasoning_details[].text
// even though the `reasoning` alias is also present (#12665).
assert.equal(message.reasoning_content, "Hmm, let me think this through");
assert.equal(message.reasoning, "Hmm, let me think this through");
assert.deepEqual(message.reasoning_details, [
{ type: "reasoning.text", text: "Hmm, let me think this through" },
]);
});
test("openrouter provider: streaming deltas carry reasoning_content from reasoning_details[].text", async () => {
await providersDb.createProviderConnection({
provider: "openrouter",
authType: "apikey",
name: "openrouter-reasoning-stream-e2e",
apiKey: "sk-mock-openrouter-key",
isActive: true,
testStatus: "active",
providerSpecificData: { baseUrl: "http://mock-openrouter.invalid/v1" },
});
let fetched = false;
globalThis.fetch = async (input, init: RequestInit = {}) => {
void input;
void init;
fetched = true;
return new Response(buildOpenRouterStreamingSse(), {
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
});
};
const response = await handleChat(
buildRequest("http://localhost/v1/chat/completions", {
method: "POST",
body: JSON.stringify({
model: "openrouter/auto",
stream: true,
messages: [{ role: "user", content: "Think through this carefully." }],
}),
})
);
const raw = await response.text();
assert.equal(response.status, 200, raw);
assert.equal(fetched, true, "should make exactly one upstream call");
const chunks = raw.split("\n\n").filter((line) => line.startsWith("data: "));
const payloads = chunks
.map((line) => line.replace(/^data: /, ""))
.filter((json) => json !== "[DONE]")
.map((json) => JSON.parse(json) as {
choices?: Array<{ delta?: Record<string, unknown> }>;
});
const reasoningContentDeltas = payloads
.map((payload) => payload.choices?.[0]?.delta?.reasoning_content)
.filter((content): content is string => Boolean(content));
assert.equal(reasoningContentDeltas.length, 1, JSON.stringify(payloads));
assert.equal(reasoningContentDeltas[0], "Hmm, let me think this through");
});

View File

@@ -131,6 +131,37 @@ describe("synthesizeOpenAiSseFromJson (#3089)", () => {
);
});
test("#12665: reasoning present does NOT suppress reasoning_details text in reasoning_content", () => {
const sse = synthesizeOpenAiSseFromJson(
JSON.stringify({
choices: [
{
message: {
role: "assistant",
reasoning: "client-readable reasoning string",
reasoning_details: [
{ type: "reasoning.text", text: "details thinking trace" },
],
content: "final text",
},
},
],
})
);
const deltas = parseDataChunks(sse)
.filter((c) => c !== "[DONE]")
.map((c) => JSON.parse(c).choices[0].delta);
// reasoning alias is preserved AND reasoning_content is populated from
// reasoning_details[].text (previously the alias short-circuited the mirror).
const rc = deltas.find((d) => d.reasoning_content !== undefined)?.reasoning_content;
assert.equal(rc, "details thinking trace");
assert.equal(
deltas.find((d) => d.reasoning !== undefined)?.reasoning,
"client-readable reasoning string"
);
});
test("forwards tool_calls in the delta", () => {
const sse = synthesizeOpenAiSseFromJson(
JSON.stringify({

View File

@@ -285,6 +285,79 @@ test("sanitizeOpenAIResponse preserves OpenRouter native reasoning and signature
);
});
test("sanitizeOpenAIResponse promotes reasoning_details text to reasoning_content even when reasoning is also present", () => {
// OpenRouter returns BOTH a `reasoning` string AND a `reasoning_details[]`
// array with the same thinking text for DeepSeek V4 / GLM 5.3 / Kimi K3.
// Clients (opencode) only read reasoning_content, so the details text must be
// mirrored into reasoning_content regardless of the `reasoning` alias being
// present (#12665).
const sanitized = sanitizeOpenAIResponse({
model: "openrouter/deepseek/deepseek-v4-flash",
choices: [
{
message: {
role: "assistant",
content: "Visible answer",
reasoning: "Hmm, let me think this through",
reasoning_details: [
{ type: "reasoning.text", text: "Hmm, let me think this through" },
],
},
},
],
});
const message = (
sanitized as {
choices: Array<{
message: {
reasoning?: unknown;
reasoning_content?: unknown;
reasoning_details?: unknown;
};
}>;
}
).choices[0].message;
assert.equal(message.reasoning, "Hmm, let me think this through");
assert.equal(message.reasoning_content, "Hmm, let me think this through");
assert.deepEqual(message.reasoning_details, [
{ type: "reasoning.text", text: "Hmm, let me think this through" },
]);
});
test("sanitizeOpenAIResponse does not flatten signature-only reasoning_details into reasoning_content", () => {
// Regression guard for the flip side: non-text details entries (encrypted
// signatures) must NOT be coerced into reasoning_content text (#12665).
const sanitized = sanitizeOpenAIResponse({
model: "openrouter/moonshotai/kimi-k3",
choices: [
{
message: {
role: "assistant",
content: "Visible answer",
reasoning: "native reasoning",
reasoning_details: [{ type: "reasoning.encrypted", data: "sig" }],
},
},
],
});
const message = (
sanitized as {
choices: Array<{
message: {
reasoning?: unknown;
reasoning_content?: unknown;
reasoning_details?: unknown;
};
}>;
}
).choices[0].message;
assert.equal(message.reasoning_content, undefined);
assert.equal(message.reasoning, "native reasoning");
assert.deepEqual(message.reasoning_details, [{ type: "reasoning.encrypted", data: "sig" }]);
});
test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content for reasoning-only messages", () => {
const sanitized = sanitizeOpenAIResponse({
model: "openrouter/model",
@@ -533,6 +606,39 @@ test("sanitizeStreamingChunk preserves client-readable reasoning deltas", () =>
assert.equal((sanitized as any).choices[0].delta.reasoning_content, undefined);
});
test("sanitizeStreamingChunk promotes reasoning_details text when reasoning is also present in the delta", () => {
// Streaming parity for #12665: OpenRouter streams reasoning_details[].text
// chunks alongside a `reasoning` string; reasoning_content must still be
// populated for the client.
const sanitized = sanitizeStreamingChunk({
choices: [
{
delta: {
reasoning: "thinking chunk",
reasoning_details: [{ type: "reasoning.text", text: "thinking chunk" }],
},
},
],
});
const delta = (
sanitized as {
choices: Array<{
delta: {
reasoning?: unknown;
reasoning_content?: unknown;
reasoning_details?: unknown;
};
}>;
}
).choices[0].delta;
assert.equal(delta.reasoning, "thinking chunk");
assert.equal(delta.reasoning_content, "thinking chunk");
assert.deepEqual(delta.reasoning_details, [
{ type: "reasoning.text", text: "thinking chunk" },
]);
});
test("sanitizeStreamingChunk preserves and mirrors Copilot reasoning_text deltas", () => {
const sanitized = sanitizeStreamingChunk({
choices: [