fix(opencode): preserve DeepSeek reasoning content in streamed responses (#4631)

Integrated into release/v3.8.36 — DeepSeek reasoning_content injection (port #1099); release-green
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-23 22:01:00 -03:00
committed by GitHub
parent 486f01710c
commit b92f045f70
3 changed files with 180 additions and 1 deletions

View File

@@ -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;
}
}

View File

@@ -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<string, unknown>;
/**
* 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;
}

View File

@@ -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<string, unknown>) {
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);
});
});