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) (#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:
233
tests/integration/openrouter-reasoning-details-e2e.test.ts
Normal file
233
tests/integration/openrouter-reasoning-details-e2e.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user