fix(claude): inject unsigned thinking placeholders for opencode-go DeepSeek /messages (#4436)

DeepSeek models behind OpenCode Go's /messages transport carry the same
thinking pass-back constraint as the official DeepSeek provider - 400
'The content[].thinking in the thinking mode must be passed back'.
prepareClaudeRequest now gates model-based: opencode-go +
isDeepSeekModel(body.model) reuses the official semantics - keep
existing thinking verbatim, inject an unsigned placeholder on tool_use
turns missing one while thinking is enabled. isDeepSeekModel moves to
providers/models/helpers.js (shared with toolDeduper).
This commit is contained in:
KiMelody authored and decolua committed 2026-09-28 12:44:33 +07:00
1 parent 7f5bd15518
commit 08b21fea06
4 files changed
+130 -13

No files matched your search

+9
View File
@@ -28,6 +28,15 @@ export function isMuseSparkModel(modelId) {
return /^muse[-_]?spark(?:$|[-_:.\s])/i.test(base);
}
// "model(level)" is a 9router thinking override; strip before matching.
// Accepts both bare ids ("deepseek-v4-pro(max)") and provider-prefixed ones.
export function isDeepSeekModel(modelId) {
if (!modelId || typeof modelId !== "string") return false;
const clean = modelId.replace(/\([^()]+\)\s*$/, "").trim();
const base = clean.includes("/") ? clean.split("/").pop() : clean;
return /^deepseek-/i.test(base);
}
// Endpoint families for OpenCode models outside the curated registry (modelsFetcher /
// passthrough ids) — regex keeps auto-fetched models on the right endpoint:
// /responses (gpt/grok/muse-spark), /messages (minimax/qwen), /chat/completions (rest).
+18 -7
View File
@@ -7,6 +7,7 @@ import { resolveSessionId } from "../../utils/sessionManager.js";
import { isValidClaudeSignature } from "../../utils/claudeSignature.js";
import { PROVIDERS } from "../../providers/index.js";
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
import { isDeepSeekModel } from "../../providers/models/helpers.js";
import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js";
const CACHE_CONTROL_5M = { type: "ephemeral" };
@@ -167,7 +168,7 @@ function handlesThinkingBlocks(provider) {
return provider === "claude" || provider?.startsWith("anthropic-compatible") || provider === "deepseek";
}
function buildThinkingPlaceholder(provider) {
function buildThinkingPlaceholder(provider, unsigned = false) {
const block = {
type: CLAUDE_BLOCK.THINKING,
thinking: ".",
@@ -175,7 +176,9 @@ function buildThinkingPlaceholder(provider) {
// DeepSeek's Anthropic-compatible endpoint requires a thinking block in
// thinking mode, but it does not need Anthropic's signed-thinking fallback.
if (provider !== "deepseek") {
// The same applies to DeepSeek models served through other providers'
// Claude transports (opencode-go /messages).
if (provider !== "deepseek" && !unsigned) {
block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE;
}
@@ -512,6 +515,14 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
const lastMessageIsUser = lastMessage?.role === "user";
const thinkingEnabled = body.thinking?.type === "enabled" && lastMessageIsUser;
// DeepSeek models also arrive behind OpenCode Go's /messages transport.
// They carry the same thinking pass-back constraint as the official
// DeepSeek provider (verified live 2026-08-15, PR #3332 discussion), so
// they get the identical keep/placeholder handling below.
const deepSeekServed =
provider === "deepseek" ||
(provider === "opencode-go" && isDeepSeekModel(body?.model));
// Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic
let lastAssistantProcessed = false;
for (let i = filtered.length - 1; i >= 0; i--) {
@@ -532,15 +543,15 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
}
// Handle thinking blocks for Anthropic-compatible endpoints.
if (handlesThinkingBlocks(provider)) {
if (handlesThinkingBlocks(provider) || deepSeekServed) {
let hasToolUse = false;
let hasKeptThinking = false;
// Claude native: preserve valid signatures, drop invalid blocks.
// anthropic-compatible: replace with default (safe fallback for lenient upstreams).
// DeepSeek: keep existing thinking as-is; add an unsigned placeholder only if missing.
// DeepSeek (official + opencode-go models): keep existing thinking as-is;
// add an unsigned placeholder only if missing.
const isClaudeNative = provider === "claude";
const isDeepSeek = provider === "deepseek";
const kept = [];
for (const block of msg.content) {
const isThinking = block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING;
@@ -550,7 +561,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
hasKeptThinking = true;
kept.push(block);
}
} else if (isDeepSeek) {
} else if (deepSeekServed) {
hasKeptThinking = true;
kept.push(block);
} else {
@@ -567,7 +578,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
// Add thinking block if thinking enabled + has tool_use but no thinking
if (thinkingEnabled && !hasKeptThinking && hasToolUse) {
msg.content.unshift(buildThinkingPlaceholder(provider));
msg.content.unshift(buildThinkingPlaceholder(provider, deepSeekServed));
}
}
}
+1 -6
View File
@@ -7,6 +7,7 @@
* gateway; GLM/MiniMax/Kimi upstreams accept duplicates). First definition wins,
* tool_choice and message-history references are by name/id so nothing breaks.
*/
import { isDeepSeekModel } from "../providers/models/helpers.js";
const DEDUP_RULES = [
{
@@ -35,12 +36,6 @@ function matches(name, pattern) {
return pattern instanceof RegExp ? pattern.test(name) : false;
}
// "model(level)" is a 9router thinking override; strip before matching.
function isDeepSeekModel(model) {
if (typeof model !== "string") return false;
return /^deepseek-/.test(model.replace(/\([^()]+\)\s*$/, "").trim());
}
/**
* @param {Array} tools - translated tools array
* @param {Object} [opts]
@@ -0,0 +1,102 @@
/**
* opencode-go DeepSeek models on the claude→claude /messages passthrough need
* the same thinking-block handling as the official deepseek provider (#3332):
* keep existing thinking blocks verbatim, and inject an UNSIGNED placeholder
* on tool_use turns that carry none while thinking is enabled — upstream 400s
* with "The content[].thinking in the thinking mode must be passed back to the
* API" otherwise. The gate is model-based because opencode-go also serves
* non-DeepSeek models over /messages (minimax, qwen) that must stay untouched.
*
* Signed placeholders were also accepted live (2026-08-15, opencode.go /messages),
* but unsigned mirrors the official deepseek provider behavior exactly.
*/
import { describe, it, expect } from "vitest";
import { prepareClaudeRequest } from "../../open-sse/translator/formats/claude.js";
function makeBody(model) {
return {
model,
max_tokens: 2048,
thinking: { type: "enabled", budget_tokens: 1024 },
messages: [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_1",
name: "get_weather",
input: { city: "Paris" },
},
],
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "toolu_1", content: "18C" },
],
},
],
};
}
function firstBlock(body) {
return body.messages[0].content[0];
}
describe("prepareClaudeRequest — opencode-go DeepSeek thinking pass-back", () => {
it("injects an unsigned thinking placeholder on tool_use turns missing one", () => {
const out = prepareClaudeRequest(
makeBody("opencode-go/deepseek-v4-pro(max)"),
"opencode-go",
);
const block = firstBlock(out);
expect(block.type).toBe("thinking");
expect(block.signature).toBeUndefined();
expect(out.messages[0].content).toHaveLength(2); // placeholder + tool_use
});
it("keeps an existing thinking block verbatim (no re-sign, no duplicate)", () => {
const body = makeBody("opencode-go/deepseek-v4-flash");
const realThinking = {
type: "thinking",
thinking: "actual reasoning",
signature: "sig_from_upstream",
};
body.messages[0].content.unshift(realThinking);
const out = prepareClaudeRequest(body, "opencode-go");
const thinking = out.messages[0].content.filter(
(b) => b.type === "thinking",
);
expect(thinking).toHaveLength(1);
expect(thinking[0]).toEqual(realThinking);
});
it("leaves non-DeepSeek opencode-go models untouched (minimax rides /messages too)", () => {
const out = prepareClaudeRequest(
makeBody("opencode-go/minimax-m3"),
"opencode-go",
);
expect(out.messages[0].content).toHaveLength(1); // tool_use only
expect(firstBlock(out).type).toBe("tool_use");
});
it("official deepseek provider keeps injecting unsigned placeholders (regression)", () => {
const out = prepareClaudeRequest(makeBody("deepseek-v4-pro"), "deepseek");
const block = firstBlock(out);
expect(block.type).toBe("thinking");
expect(block.signature).toBeUndefined();
});
it.todo(
"inject a reasoning placeholder into Responses `input` items for DeepSeek models — " +
"injectReasoningContent only rewrites body.messages, so responses-format clients " +
"replaying reasoning-bearing sessions on the openai-responses transport are " +
"uncovered (#3332 restore gate: Codex-shaped payload must stay 200)",
);
});