Skip to content

Commit 4cab808

Browse files
committed
fix(provider): replay reasoning_content on v4 deepseek turns
1 parent ae424d1 commit 4cab808

2 files changed

Lines changed: 50 additions & 6 deletions

File tree

‎src/provider/openai-compatible-adapter.test.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,20 +150,58 @@ describe("openai-compatible adapter reasoning_content handling", () => {
150150
.messages;
151151
}
152152

153-
test("strips reasoning_content from input messages for DeepSeek models", () => {
154-
const assistant = messagesFor("deepseek-v4").find(
153+
test("strips reasoning_content from input messages for pre-V4 DeepSeek models", () => {
154+
const assistant = messagesFor("deepseek-v3").find(
155155
(m) => m["role"] === "assistant",
156156
);
157157
expect(assistant).toBeDefined();
158158
expect("reasoning_content" in defined(assistant)).toBe(false);
159159
});
160160

161+
test.each([
162+
"deepseek-v4",
163+
"deepseek-v4-pro",
164+
"deepseek-v4-flash",
165+
"deepseek-v4-flash-vision-exp",
166+
])("V4 keeps reasoning_content on replayed assistant turns (%s)", (model) => {
167+
const assistant = messagesFor(model).find((m) => m["role"] === "assistant");
168+
expect(assistant).toBeDefined();
169+
expect(assistant?.["reasoning_content"]).toBe("ponder");
170+
});
171+
161172
test("keeps reasoning_content for non-DeepSeek models", () => {
162173
const assistant = messagesFor("kimi-k2").find(
163174
(m) => m["role"] === "assistant",
164175
);
165176
expect(assistant?.["reasoning_content"]).toBe("ponder");
166177
});
178+
179+
test("non-V4/non-DeepSeek models are byte-identical on reasoning_content", () => {
180+
for (const model of [
181+
"gpt-5",
182+
"gpt-5.1",
183+
"kimi-k2",
184+
"grok-4.6",
185+
"claude-sonnet-4.5",
186+
"glm-5.3",
187+
"openai-compatible-generic",
188+
]) {
189+
const assistant = messagesFor(model).find(
190+
(m) => m["role"] === "assistant",
191+
);
192+
expect(assistant?.["reasoning_content"]).toBe("ponder");
193+
}
194+
});
195+
196+
test("pre-V4 DeepSeek ids still strip reasoning_content (byte-identical)", () => {
197+
for (const model of ["deepseek-v3", "deepseek-r1"]) {
198+
const assistant = messagesFor(model).find(
199+
(m) => m["role"] === "assistant",
200+
);
201+
expect(assistant).toBeDefined();
202+
expect("reasoning_content" in defined(assistant)).toBe(false);
203+
}
204+
});
167205
});
168206

169207
describe("openai-compatible adapter V4 effort wiring", () => {

‎src/provider/openai-compatible-adapter.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,17 @@ export function createOpenAICompatibleAdapter(
5252
const hasProviderOptions =
5353
providerOptions !== undefined && Object.keys(providerOptions).length > 0;
5454
// DeepSeek returns HTTP 400 if `reasoning_content` appears in input messages,
55-
// whereas the base adapter emits it for any model with thinking enabled.
55+
// whereas the base adapter emits it for any model with thinking enabled. V4
56+
// keeps each turn's reasoning on replay, so only pre-V4 DeepSeek strips it.
5657
const m = model.toLowerCase().trim();
57-
const stripReasoning = m.includes("deepseek");
58-
needsDeepSeekPatch = stripReasoning;
59-
if (!hasProviderOptions && !stripReasoning) return ensureAccept(built);
58+
const isDeepSeek = m.includes("deepseek");
59+
const stripReasoning = isDeepSeek && !/^deepseek-v4/i.test(m);
60+
needsDeepSeekPatch = isDeepSeek;
61+
// V4 always runs the settings step below, even with no providerOptions, so
62+
// its sampling/stream defaults reach the wire; non-V4 models without
63+
// providerOptions short-circuit here and stay byte-identical.
64+
if (!hasProviderOptions && !stripReasoning && !/^deepseek-v4/i.test(m))
65+
return ensureAccept(built);
6066

6167
const body = JSON.parse(built.body) as Record<string, unknown>;
6268
if (hasProviderOptions) Object.assign(body, providerOptions);

0 commit comments

Comments
 (0)