Skip to content

Commit ad3e3cb

Browse files
Pluviobytecursoragentsteipete
authored
fix(agents): preserve reasoning_content replay across DeepSeek tier suffixes (#87593)
* fix(agents): preserve reasoning_content replay across DeepSeek tier suffixes OpenCode Zen exposes DeepSeek V4 as `deepseek-v4-flash-free`, which keeps the upstream DeepSeek thinking-mode contract that requires `reasoning_content` to be passed back on follow-up requests. The existing replay allowlist only matched the bare ids (`deepseek-v4-flash`, `kimi-k2-thinking`, ...), so the tier-suffixed id missed every candidate and the sanitizer stripped `reasoning_content` from the assistant turn. DeepSeek then rejected the second API call with HTTP 400 and the session deadlocked. Strip the well-known tier suffixes (`-free`, `-paid`, `-trial`) when generating allowlist candidates so the base model id matches and the reasoning replay survives. Existing matching for prefixed / colon-suffixed routes is unchanged. Fixes #87575 Co-authored-by: Cursor <[email protected]> * fix(agents): avoid spread-rebuild when iterating allowlist candidates oxlint flagged the [...candidates] spread as an unnecessary array copy. Use an explicit baseCount loop bound instead so we still iterate the original entries while pushing tier-stripped variants onto the same array. Co-authored-by: Cursor <[email protected]> * test(opencode): add live DeepSeek replay probe * test(opencode): avoid forced tool choice in live replay --------- Co-authored-by: Pluviobyte <[email protected]> Co-authored-by: Cursor <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 5216841 commit ad3e3cb

3 files changed

Lines changed: 204 additions & 0 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {
2+
completeSimple,
3+
type AssistantMessage,
4+
type Model,
5+
type Tool,
6+
} from "openclaw/plugin-sdk/llm";
7+
import { extractNonEmptyAssistantText, isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
8+
import { Type } from "typebox";
9+
import { describe, expect, it } from "vitest";
10+
11+
const OPENCODE_API_KEY =
12+
process.env.OPENCODE_API_KEY?.trim() || process.env.OPENCODE_ZEN_API_KEY?.trim() || "";
13+
const LIVE_MODEL_ID =
14+
process.env.OPENCLAW_LIVE_OPENCODE_DEEPSEEK_MODEL?.trim() || "deepseek-v4-flash-free";
15+
const LIVE = isLiveTestEnabled(["OPENCODE_LIVE_TEST"]) && OPENCODE_API_KEY.length > 0;
16+
const describeLive = LIVE ? describe : describe.skip;
17+
18+
function resolveOpencodeDeepSeekLiveModel(): Model<"openai-completions"> {
19+
return {
20+
id: LIVE_MODEL_ID,
21+
name: LIVE_MODEL_ID,
22+
api: "openai-completions",
23+
provider: "opencode",
24+
baseUrl: "https://opencode.ai/zen/v1",
25+
reasoning: true,
26+
input: ["text"],
27+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
28+
contextWindow: 65_536,
29+
maxTokens: 8192,
30+
};
31+
}
32+
33+
function liveEchoTool(): Tool {
34+
return {
35+
name: "live_echo",
36+
description: "Return the supplied value.",
37+
parameters: Type.Object(
38+
{
39+
value: Type.String(),
40+
},
41+
{ additionalProperties: false },
42+
),
43+
};
44+
}
45+
46+
function requireToolCall(message: AssistantMessage) {
47+
const toolCall = message.content.find((block) => block.type === "toolCall");
48+
if (toolCall?.type !== "toolCall") {
49+
throw new Error(`OpenCode DeepSeek live model did not call a tool: ${message.stopReason}`);
50+
}
51+
return toolCall;
52+
}
53+
54+
function hasReasoningContentReplay(message: AssistantMessage): boolean {
55+
return message.content.some(
56+
(block) => block.type === "thinking" && block.thinkingSignature === "reasoning_content",
57+
);
58+
}
59+
60+
describeLive("opencode plugin live", () => {
61+
it("accepts DeepSeek V4 tier-suffixed thinking replay after a tool call", async () => {
62+
const model = resolveOpencodeDeepSeekLiveModel();
63+
const tool = liveEchoTool();
64+
const firstOptions = {
65+
apiKey: OPENCODE_API_KEY,
66+
reasoning: "low",
67+
maxTokens: 128,
68+
} as const;
69+
70+
const first = await completeSimple(
71+
model,
72+
{
73+
messages: [
74+
{
75+
role: "user",
76+
content: "You must call the live_echo tool with value ok. Do not answer directly.",
77+
timestamp: Date.now(),
78+
},
79+
],
80+
tools: [tool],
81+
},
82+
firstOptions,
83+
);
84+
85+
if (first.stopReason === "error") {
86+
throw new Error(first.errorMessage || "OpenCode DeepSeek first turn returned an error");
87+
}
88+
89+
const toolCall = requireToolCall(first);
90+
expect(hasReasoningContentReplay(first)).toBe(true);
91+
92+
const second = await completeSimple(
93+
model,
94+
{
95+
messages: [
96+
{
97+
role: "user",
98+
content: "You must call the live_echo tool with value ok. Do not answer directly.",
99+
timestamp: Date.now() - 3,
100+
},
101+
first,
102+
{
103+
role: "toolResult",
104+
toolCallId: toolCall.id,
105+
toolName: toolCall.name,
106+
content: [{ type: "text", text: "ok" }],
107+
isError: false,
108+
timestamp: Date.now() - 1,
109+
},
110+
{
111+
role: "user",
112+
content: "Reply with exactly: ok",
113+
timestamp: Date.now(),
114+
},
115+
],
116+
tools: [tool],
117+
},
118+
{
119+
apiKey: OPENCODE_API_KEY,
120+
reasoning: "low",
121+
maxTokens: 64,
122+
},
123+
);
124+
125+
if (second.stopReason === "error") {
126+
throw new Error(second.errorMessage || "OpenCode DeepSeek replay returned an error");
127+
}
128+
129+
expect(extractNonEmptyAssistantText(second.content)).toMatch(/^ok[.!]?$/i);
130+
}, 120_000);
131+
});

src/agents/openai-transport-stream.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8154,6 +8154,51 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () =>
81548154
expect(assistant.reasoning_content).toBe("Need to answer politely.");
81558155
});
81568156

8157+
// Regression for #87575: OpenCode Zen exposes DeepSeek V4 with a `-free`
8158+
// tier suffix that does not change the upstream replay contract. Without
8159+
// matching the base id we stripped reasoning_content from the follow-up
8160+
// request and DeepSeek rejected the assistant turn with HTTP 400.
8161+
it.each([
8162+
[
8163+
"OpenCode Zen DeepSeek V4 Flash Free",
8164+
{
8165+
id: "deepseek-v4-flash-free",
8166+
name: "DeepSeek V4 Flash Free",
8167+
api: "openai-completions" as const,
8168+
provider: "opencode",
8169+
baseUrl: "https://opencode.ai/zen/v1",
8170+
reasoning: true,
8171+
input: ["text"] as ("text" | "image")[],
8172+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
8173+
contextWindow: 65_536,
8174+
maxTokens: 8192,
8175+
},
8176+
],
8177+
[
8178+
"OpenRouter MiMo V2 Pro Free",
8179+
{
8180+
...customMiMoProxyModel,
8181+
id: "xiaomi/mimo-v2-pro-free",
8182+
},
8183+
],
8184+
[
8185+
"OpenRouter Kimi K2 Thinking Free",
8186+
{
8187+
...customKimiProxyModel,
8188+
id: "moonshotai/kimi-k2-thinking-free",
8189+
},
8190+
],
8191+
] as const)("preserves reasoning_content replay despite the %s tier suffix", (_label, model) => {
8192+
const assistant = getAssistantMessage(
8193+
buildReplayParams(model as Model<"openai-completions">, "reasoning_content"),
8194+
);
8195+
8196+
expect(assistant.reasoning_content).toBe("Need to answer politely.");
8197+
expect(assistant).not.toHaveProperty("reasoning_details");
8198+
expect(assistant).not.toHaveProperty("reasoning");
8199+
expect(assistant).not.toHaveProperty("reasoning_text");
8200+
});
8201+
81578202
it("preserves OpenRouter array reasoning_details from tool-call signatures", () => {
81588203
const reasoningDetail = { type: "reasoning.encrypted", id: "rs_1", data: "ciphertext" };
81598204
const params = buildOpenAICompletionsParams(

src/agents/openai-transport-stream.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3398,6 +3398,23 @@ const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
33983398
"mimo-v2.6-pro",
33993399
]);
34003400

3401+
// Tier/access suffixes that some providers append to otherwise identical model
3402+
// ids (OpenCode Zen exposes `deepseek-v4-flash-free`, OpenRouter exposes
3403+
// `:free` / `:cloud`, etc.). The base model id before the suffix still owns
3404+
// the same DeepSeek-style reasoning_content replay contract, so reasoning
3405+
// replay must not be stripped just because the catalog id grew a marketing
3406+
// suffix (#87575).
3407+
const REASONING_CONTENT_REPLAY_TIER_SUFFIXES = ["-free", "-paid", "-trial"] as const;
3408+
3409+
function stripReasoningContentReplayTierSuffix(modelId: string): string {
3410+
for (const suffix of REASONING_CONTENT_REPLAY_TIER_SUFFIXES) {
3411+
if (modelId.length > suffix.length && modelId.endsWith(suffix)) {
3412+
return modelId.slice(0, -suffix.length);
3413+
}
3414+
}
3415+
return modelId;
3416+
}
3417+
34013418
function getReasoningContentReplayModelIdCandidates(modelId: unknown): string[] {
34023419
if (typeof modelId !== "string") {
34033420
return [];
@@ -3413,6 +3430,17 @@ function getReasoningContentReplayModelIdCandidates(modelId: unknown): string[]
34133430
if (colonParts.length > 1) {
34143431
candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? "");
34153432
}
3433+
const baseCount = candidates.length;
3434+
for (let index = 0; index < baseCount; index += 1) {
3435+
const candidate = candidates[index];
3436+
if (typeof candidate !== "string") {
3437+
continue;
3438+
}
3439+
const stripped = stripReasoningContentReplayTierSuffix(candidate);
3440+
if (stripped !== candidate) {
3441+
candidates.push(stripped);
3442+
}
3443+
}
34163444
return uniqueStrings(candidates.filter(Boolean));
34173445
}
34183446

0 commit comments

Comments
 (0)