diff --git a/changelog.d/fixes/12688-open-sse-reasoning-promotion.md b/changelog.d/fixes/12688-open-sse-reasoning-promotion.md new file mode 100644 index 0000000000..15b1952de3 --- /dev/null +++ b/changelog.d/fixes/12688-open-sse-reasoning-promotion.md @@ -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) diff --git a/open-sse/utils/jsonToSse.ts b/open-sse/utils/jsonToSse.ts index aa7f0cfa78..e3f71f4669 100644 --- a/open-sse/utils/jsonToSse.ts +++ b/open-sse/utils/jsonToSse.ts @@ -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); } diff --git a/open-sse/utils/reasoningFields.ts b/open-sse/utils/reasoningFields.ts index 75b7cbe537..d502504ca4 100644 --- a/open-sse/utils/reasoningFields.ts +++ b/open-sse/utils/reasoningFields.ts @@ -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; } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index f990c85822..bfe9fab221 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -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) { diff --git a/tests/integration/openrouter-reasoning-details-e2e.test.ts b/tests/integration/openrouter-reasoning-details-e2e.test.ts new file mode 100644 index 0000000000..a127ef660f --- /dev/null +++ b/tests/integration/openrouter-reasoning-details-e2e.test.ts @@ -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; + body: Record | 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) || {}), + }); + 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) => + `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 }>; + }); + + 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"); +}); diff --git a/tests/unit/json-to-sse-3089.test.ts b/tests/unit/json-to-sse-3089.test.ts index ac1c9e47e7..b4757f2786 100644 --- a/tests/unit/json-to-sse-3089.test.ts +++ b/tests/unit/json-to-sse-3089.test.ts @@ -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({ diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 925b9c3795..b790fdece3 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -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: [