diff --git a/src/provider/openai-compatible-adapter.test.ts b/src/provider/openai-compatible-adapter.test.ts index 518ff29b5..41ec48e69 100644 --- a/src/provider/openai-compatible-adapter.test.ts +++ b/src/provider/openai-compatible-adapter.test.ts @@ -164,3 +164,84 @@ describe("openai-compatible adapter reasoning_content handling", () => { expect(assistant?.["reasoning_content"]).toBe("ponder"); }); }); + +describe("openai-compatible adapter DeepSeek V4 Flash wire params", () => { + const V4 = "deepseek-ai/DeepSeek-V4-Flash-0731"; + const history: ConversationTurn[] = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "ponder" }, + { type: "text", text: "hello" }, + ], + }, + { role: "user", content: [{ type: "text", text: "again" }] }, + ] as unknown as ConversationTurn[]; + + function v4Body( + providerOptions?: Record, + model = V4, + ): Record { + const adapter = createOpenAICompatibleAdapter(source); + const built = adapter.buildRequest(history, model, { + maxTokens: 100, + ...(providerOptions !== undefined ? { providerOptions } : {}), + } as InferenceOptions); + return JSON.parse(built.body) as Record; + } + + test("turns thinking on and applies agentic sampling at high effort", () => { + const body = v4Body({ reasoning_effort: "high" }); + expect(body["chat_template_kwargs"]).toEqual({ thinking: true }); + expect(body["reasoning_effort"]).toBe("high"); + expect(body["temperature"]).toBe(1); + expect(body["top_p"]).toBe(0.95); + expect(body["stream_options"]).toEqual({ include_usage: true }); + }); + + test("folds Corbits' ladder onto low/high/max", () => { + expect(v4Body({ reasoning_effort: "minimal" })["reasoning_effort"]).toBe( + "low", + ); + expect(v4Body({ reasoning_effort: "medium" })["reasoning_effort"]).toBe( + "high", + ); + expect(v4Body({ reasoning_effort: "max" })["reasoning_effort"]).toBe("max"); + }); + + test("effort none sends chat mode explicitly and no effort", () => { + const body = v4Body({ reasoning_effort: "none" }); + expect(body["chat_template_kwargs"]).toEqual({ thinking: false }); + expect("reasoning_effort" in body).toBe(false); + expect("top_p" in body).toBe(false); + }); + + test("an explicitly configured temperature/top_p wins", () => { + const body = v4Body({ + reasoning_effort: "high", + temperature: 0.6, + top_p: 0.9, + }); + expect(body["temperature"]).toBe(0.6); + expect(body["top_p"]).toBe(0.9); + }); + + test("replays reasoning_content on prior assistant turns", () => { + const msgs = v4Body({ reasoning_effort: "high" })["messages"] as Record< + string, + unknown + >[]; + const assistant = msgs.find((m) => m["role"] === "assistant"); + expect(assistant?.["reasoning_content"]).toBe("ponder"); + }); + + test("other DeepSeek models keep the old strip and get no extra params", () => { + const body = v4Body({ reasoning_effort: "high" }, "deepseek-v3.2"); + expect("chat_template_kwargs" in body).toBe(false); + expect("top_p" in body).toBe(false); + const msgs = body["messages"] as Record[]; + const assistant = msgs.find((m) => m["role"] === "assistant"); + expect("reasoning_content" in defined(assistant)).toBe(false); + }); +}); diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index d869af801..4ac3518b0 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -45,11 +45,13 @@ export function createOpenAICompatibleAdapter( const providerOptions = options.providerOptions; 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. - const stripReasoning = model.toLowerCase().includes("deepseek"); - needsDeepSeekPatch = stripReasoning; - if (!hasProviderOptions && !stripReasoning) return ensureAccept(built); + // Earlier DeepSeek models 400 on `reasoning_content` in input messages; + // V4 Flash keeps it because its encoder preserves each turn's thinking. + const v4Flash = isDeepSeekV4Flash(model); + const stripReasoning = model.toLowerCase().includes("deepseek") && !v4Flash; + needsDeepSeekPatch = model.toLowerCase().includes("deepseek"); + if (!hasProviderOptions && !stripReasoning && !v4Flash) + return ensureAccept(built); const body = JSON.parse(built.body) as Record; if (hasProviderOptions) Object.assign(body, providerOptions); @@ -59,6 +61,7 @@ export function createOpenAICompatibleAdapter( delete (msg as Record)["reasoning_content"]; } } + if (v4Flash) applyDeepSeekV4FlashParams(body); const merged: BuiltRequest = { ...built, body: JSON.stringify(body) }; return ensureAccept(merged); }; @@ -73,3 +76,46 @@ export function createOpenAICompatibleAdapter( return { ...base, buildRequest, parseResponse }; } + +/** True for DeepSeek V4 Flash on any OpenAI-compatible host. */ +export function isDeepSeekV4Flash(model: string): boolean { + return /(^|\/)deepseek-v4-flash/i.test(model.trim()); +} + +// DeepSeek V4 understands exactly three reasoning_effort levels (low, high, +// max); its reference encoder asserts on anything else. Corbits' generic +// ladder (minimal/low/medium/high/xhigh/max) is folded onto those three. +const DSV4_EFFORT: Record = { + minimal: "low", + low: "low", + medium: "high", + high: "high", + xhigh: "max", + max: "max", +}; + +/** Apply the DeepSeek V4 Flash wire shape to an already-built chat body. */ +export function applyDeepSeekV4FlashParams( + body: Record, +): void { + const raw = body["reasoning_effort"]; + const effort = typeof raw === "string" ? raw.toLowerCase() : undefined; + const thinking = + effort !== undefined && effort !== "none" && effort !== "off"; + const existingKwargs = + body["chat_template_kwargs"] !== null && + typeof body["chat_template_kwargs"] === "object" + ? (body["chat_template_kwargs"] as Record) + : {}; + body["chat_template_kwargs"] = { ...existingKwargs, thinking }; + if (thinking) { + body["reasoning_effort"] = DSV4_EFFORT[effort] ?? "high"; + if (body["temperature"] === undefined) body["temperature"] = 1.0; + if (body["top_p"] === undefined) body["top_p"] = 0.95; + } else { + delete body["reasoning_effort"]; + } + if (body["stream"] === true && body["stream_options"] === undefined) { + body["stream_options"] = { include_usage: true }; + } +} diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts index 8a42497e4..48adefad7 100644 --- a/src/subagent/provider-family.ts +++ b/src/subagent/provider-family.ts @@ -83,8 +83,23 @@ export function isGptProvider(input: { return false; } +/** + * True when the model id is DeepSeek V4 Flash (any provider). Scoped to the + * Flash cell only: the residual it unlocks was tuned against that model. + */ +export function isDeepSeekV4FlashProvider(input: { + providerName: string; + model?: string; +}): boolean { + return ( + input.model !== undefined && + /(^|\/)deepseek-v4-flash/i.test(input.model.trim()) + ); +} + /** Model families the shared directors branch on via ModelFamilyPolicy. */ export type ModelFamily = + | "deepseek-v4-flash" | "grok" | "kimi" | "muse" @@ -103,6 +118,7 @@ export function detectModelFamily(input: { providerName: string; model?: string; }): ModelFamily { + if (isDeepSeekV4FlashProvider(input)) return "deepseek-v4-flash"; if (isXaiGrokLeafProvider(input)) return "grok"; if (isKimiLeafProvider(input)) return "kimi"; if (isMuseSparkLeafProvider(input)) return "muse";