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:
1 parent
7f5bd15518
commit
08b21fea06
4 files changed
+130
-13
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user