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,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: [