diff --git a/src/provider/openai-compatible-adapter.test.ts b/src/provider/openai-compatible-adapter.test.ts index 8c162901b..72e9c7c4e 100644 --- a/src/provider/openai-compatible-adapter.test.ts +++ b/src/provider/openai-compatible-adapter.test.ts @@ -150,20 +150,58 @@ describe("openai-compatible adapter reasoning_content handling", () => { .messages; } - test("strips reasoning_content from input messages for DeepSeek models", () => { - const assistant = messagesFor("deepseek-v4").find( + test("strips reasoning_content from input messages for pre-V4 DeepSeek models", () => { + const assistant = messagesFor("deepseek-v3").find( (m) => m["role"] === "assistant", ); expect(assistant).toBeDefined(); expect("reasoning_content" in defined(assistant)).toBe(false); }); + test.each([ + "deepseek-v4", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", + ])("V4 keeps reasoning_content on replayed assistant turns (%s)", (model) => { + const assistant = messagesFor(model).find((m) => m["role"] === "assistant"); + expect(assistant).toBeDefined(); + expect(assistant?.["reasoning_content"]).toBe("ponder"); + }); + test("keeps reasoning_content for non-DeepSeek models", () => { const assistant = messagesFor("kimi-k2").find( (m) => m["role"] === "assistant", ); expect(assistant?.["reasoning_content"]).toBe("ponder"); }); + + test("non-V4/non-DeepSeek models are byte-identical on reasoning_content", () => { + for (const model of [ + "gpt-5", + "gpt-5.1", + "kimi-k2", + "grok-4.6", + "claude-sonnet-4.5", + "glm-5.3", + "openai-compatible-generic", + ]) { + const assistant = messagesFor(model).find( + (m) => m["role"] === "assistant", + ); + expect(assistant?.["reasoning_content"]).toBe("ponder"); + } + }); + + test("pre-V4 DeepSeek ids still strip reasoning_content (byte-identical)", () => { + for (const model of ["deepseek-v3", "deepseek-r1"]) { + const assistant = messagesFor(model).find( + (m) => m["role"] === "assistant", + ); + expect(assistant).toBeDefined(); + expect("reasoning_content" in defined(assistant)).toBe(false); + } + }); }); describe("openai-compatible adapter V4 effort wiring", () => { diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index 0a81f2835..4f3bcce54 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -52,11 +52,17 @@ export function createOpenAICompatibleAdapter( const hasProviderOptions = providerOptions !== undefined && Object.keys(providerOptions).length > 0; // DeepSeek returns HTTP 400 if `reasoning_content` appears in input messages, - // whereas the base adapter emits it for any model with thinking enabled. + // whereas the base adapter emits it for any model with thinking enabled. V4 + // keeps each turn's reasoning on replay, so only pre-V4 DeepSeek strips it. const m = model.toLowerCase().trim(); - const stripReasoning = m.includes("deepseek"); - needsDeepSeekPatch = stripReasoning; - if (!hasProviderOptions && !stripReasoning) return ensureAccept(built); + const isDeepSeek = m.includes("deepseek"); + const stripReasoning = isDeepSeek && !/^deepseek-v4/i.test(m); + needsDeepSeekPatch = isDeepSeek; + // V4 always runs the settings step below, even with no providerOptions, so + // its sampling/stream defaults reach the wire; non-V4 models without + // providerOptions short-circuit here and stay byte-identical. + if (!hasProviderOptions && !stripReasoning && !/^deepseek-v4/i.test(m)) + return ensureAccept(built); const body = JSON.parse(built.body) as Record; if (hasProviderOptions) Object.assign(body, providerOptions);