From b92f045f704daae648985ba9b7089321fae6537b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:01:00 -0300 Subject: [PATCH] fix(opencode): preserve DeepSeek reasoning content in streamed responses (#4631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — DeepSeek reasoning_content injection (port #1099); release-green --- open-sse/executors/opencode.ts | 14 ++- open-sse/utils/reasoningContentInjector.ts | 73 ++++++++++++++ ...encode-deepseek-reasoning-injected.test.ts | 94 +++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 open-sse/utils/reasoningContentInjector.ts create mode 100644 tests/unit/opencode-deepseek-reasoning-injected.test.ts diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index af0cf431b3..28666efb76 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -7,6 +7,10 @@ import { } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; +import { + injectReasoningContentForThinkingModel, + isThinkingMessageModel, +} from "../utils/reasoningContentInjector.ts"; export class OpencodeExecutor extends BaseExecutor { _requestFormat: string | null = null; @@ -138,7 +142,7 @@ export class OpencodeExecutor extends BaseExecutor { stream: boolean, credentials: ProviderCredentials ): any { - const modifiedBody = super.transformRequest(model, body, stream, credentials); + let modifiedBody = super.transformRequest(model, body, stream, credentials); if ( modifiedBody && typeof modifiedBody === "object" && @@ -162,6 +166,14 @@ export class OpencodeExecutor extends BaseExecutor { } } } + // #1543 / upstream PR #1099: thinking-mode upstreams routed through OpenCode + // (DeepSeek V4 Flash, Kimi, MiniMax, ...) require reasoning_content echoed + // back on assistant messages, or they 400 with "reasoning_content must be + // passed back". OpenAI clients drop it across turns, so we inject a + // placeholder for the affected model families. + if (isThinkingMessageModel(model)) { + modifiedBody = injectReasoningContentForThinkingModel(modifiedBody); + } return modifiedBody; } } diff --git a/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts new file mode 100644 index 0000000000..8fd9b13bd4 --- /dev/null +++ b/open-sse/utils/reasoningContentInjector.ts @@ -0,0 +1,73 @@ +/** + * Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, ...) require + * `reasoning_content` to be echoed back on every assistant message in the + * conversation history. Standard OpenAI clients do not preserve that field + * across turns, so we inject a non-empty placeholder before forwarding. + * + * Without the placeholder these upstreams return: + * 400 Bad Request — reasoning_content must be passed back + * + * Ported from decolua/9router#1099 (issue #1543). Pure helper — no I/O, no + * cross-module deps — kept narrow so it can be reused by other meta-providers + * that proxy to thinking-mode models. + */ + +const PLACEHOLDER = " "; + +type JsonRecord = Record; + +/** + * Model-id predicates for thinking-mode families that need the echo. + * Matched case-insensitively against the resolved model id (post upstream + * routing, e.g. `oc/deepseek-v4-flash-free` for the OpenCode meta-provider). + */ +const THINKING_MODEL_PATTERNS: RegExp[] = [ + /deepseek/i, + /\bkimi\b/i, + /\bk2\b/i, // moonshot kimi k2 family alias + /\bminimax\b/i, +]; + +export function isThinkingMessageModel(model: string | undefined | null): boolean { + if (!model || typeof model !== "string") return false; + return THINKING_MODEL_PATTERNS.some((re) => re.test(model)); +} + +function hasNonEmptyReasoningContent(message: JsonRecord): boolean { + return ( + typeof message.reasoning_content === "string" && + (message.reasoning_content as string).trim().length > 0 + ); +} + +function isAssistantMessage(value: unknown): value is JsonRecord { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + (value as JsonRecord).role === "assistant" + ); +} + +/** + * Inject a placeholder `reasoning_content` on every assistant message in + * `body.messages` that lacks one. Returns the original object if no mutation + * was needed, or a shallow-copied body with a new messages array otherwise. + * + * No-op when the body shape is unexpected (defensive). + */ +export function injectReasoningContentForThinkingModel(body: unknown): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as JsonRecord; + if (!Array.isArray(record.messages)) return body; + + let modified = false; + const messages = record.messages.map((message) => { + if (!isAssistantMessage(message)) return message; + if (hasNonEmptyReasoningContent(message)) return message; + modified = true; + return { ...message, reasoning_content: PLACEHOLDER }; + }); + + return modified ? { ...record, messages } : body; +} diff --git a/tests/unit/opencode-deepseek-reasoning-injected.test.ts b/tests/unit/opencode-deepseek-reasoning-injected.test.ts new file mode 100644 index 0000000000..9e98a6c601 --- /dev/null +++ b/tests/unit/opencode-deepseek-reasoning-injected.test.ts @@ -0,0 +1,94 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts"); + +/** + * Regression test for upstream decolua/9router#1099 (issue #1543): + * + * OpenCode is a meta-provider that proxies multi-turn chats to thinking-mode + * upstreams (DeepSeek V4 Flash, Kimi, MiniMax, ...). Those upstreams require + * `reasoning_content` to be echoed back on every assistant message in the + * conversation history; otherwise they return: + * + * 400 Bad Request — reasoning_content must be passed back + * + * Standard OpenAI clients do not preserve `reasoning_content` across turns, so + * OpencodeExecutor.transformRequest must inject a non-empty placeholder on any + * assistant message that lacks it when routing to a thinking-mode model. + */ +describe("OpencodeExecutor — DeepSeek reasoning_content injection (#1099)", () => { + const executor = new OpencodeExecutor("opencode-zen"); + + function buildBody(extraAssistant: Record) { + return { + model: "oc/deepseek-v4-flash-free", + stream: true, + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "previous answer", ...extraAssistant }, + { role: "user", content: "follow up" }, + ], + }; + } + + it("injects reasoning_content on an assistant message that lacks it (deepseek model)", () => { + const out = executor.transformRequest( + "oc/deepseek-v4-flash-free", + buildBody({}), + true, + { apiKey: "test" } as never + ); + const assistant = out.messages.find((m: { role: string }) => m.role === "assistant"); + assert.equal(typeof assistant.reasoning_content, "string"); + assert.ok( + assistant.reasoning_content.length > 0, + "expected non-empty reasoning_content placeholder injected" + ); + }); + + it("preserves an existing non-empty reasoning_content instead of overwriting", () => { + const original = "prior chain of thought"; + const out = executor.transformRequest( + "oc/deepseek-v4-flash-free", + buildBody({ reasoning_content: original }), + true, + { apiKey: "test" } as never + ); + const assistant = out.messages.find((m: { role: string }) => m.role === "assistant"); + assert.equal(assistant.reasoning_content, original); + }); + + it("injects for kimi-family models routed through opencode", () => { + const body = { + model: "oc/kimi-k2-thinking", + stream: false, + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "answer" }, + ], + }; + const out = executor.transformRequest("oc/kimi-k2-thinking", body, false, { + apiKey: "test", + } as never); + const assistant = out.messages.find((m: { role: string }) => m.role === "assistant"); + assert.equal(typeof assistant.reasoning_content, "string"); + assert.ok(assistant.reasoning_content.length > 0); + }); + + it("does not inject for non-thinking opencode models (no false positives)", () => { + const body = { + model: "oc/gpt-5", + stream: false, + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "answer" }, + ], + }; + const out = executor.transformRequest("oc/gpt-5", body, false, { + apiKey: "test", + } as never); + const assistant = out.messages.find((m: { role: string }) => m.role === "assistant"); + assert.equal(assistant.reasoning_content, undefined); + }); +});