From 08b21fea065e7bb4421404c5a8d0be9748f5c016 Mon Sep 17 00:00:00 2001 From: KiMelody Date: Mon, 28 Sep 2026 12:43:52 +0700 Subject: [PATCH] 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). --- open-sse/providers/models/helpers.js | 9 ++ open-sse/translator/formats/claude.js | 25 +++-- open-sse/utils/toolDeduper.js | 7 +- ...ode-go-deepseek-thinking-injection.test.js | 102 ++++++++++++++++++ 4 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 tests/unit/opencode-go-deepseek-thinking-injection.test.js diff --git a/open-sse/providers/models/helpers.js b/open-sse/providers/models/helpers.js index cf8076bd..1a71bc12 100644 --- a/open-sse/providers/models/helpers.js +++ b/open-sse/providers/models/helpers.js @@ -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). diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 14c9fc10..e52bef9d 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -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)); } } } diff --git a/open-sse/utils/toolDeduper.js b/open-sse/utils/toolDeduper.js index ee6b5aa6..e17d37e4 100644 --- a/open-sse/utils/toolDeduper.js +++ b/open-sse/utils/toolDeduper.js @@ -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] diff --git a/tests/unit/opencode-go-deepseek-thinking-injection.test.js b/tests/unit/opencode-go-deepseek-thinking-injection.test.js new file mode 100644 index 00000000..0b79815e --- /dev/null +++ b/tests/unit/opencode-go-deepseek-thinking-injection.test.js @@ -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)", + ); +});