From f41541edaba98cc6ce06930f04ee1c5c71a796ab Mon Sep 17 00:00:00 2001 From: "user.email" Date: Thu, 8 Oct 2026 22:06:21 -0700 Subject: [PATCH 1/4] feat(provider): add deepseek-v4 per-family adapter Lift the DeepSeek V4 wire quirks out of the generic openai-compatible adapter into a bifrost-style deepseek-v4 adapter, registered in the AdapterManifest and selected by a deepseek-v4 provider id stamped on the source for /^deepseek-v4/i models. Removes the V4 branch from the generic adapter, keeping the pre-V4 reasoning_content strip and the NIM null-delta patch. Relocates the V4 effort/settings tests. --- src/config/index.ts | 11 +- src/config/inference-sources.test.ts | 30 +++ src/provider/deepseek-v4-adapter.test.ts | 208 ++++++++++++++++++ src/provider/deepseek-v4-adapter.ts | 71 ++++++ src/provider/inference-dependencies.ts | 8 + .../openai-compatible-adapter.test.ts | 194 ---------------- src/provider/openai-compatible-adapter.ts | 57 +---- 7 files changed, 328 insertions(+), 251 deletions(-) create mode 100644 src/provider/deepseek-v4-adapter.test.ts create mode 100644 src/provider/deepseek-v4-adapter.ts diff --git a/src/config/index.ts b/src/config/index.ts index 18de85188..b323cf977 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -59,6 +59,8 @@ import { GROK_SESSION_ID_OPTION, } from "../provider/grok-responses.js"; import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js"; +import { DEEPSEEK_V4_PROVIDER } from "../provider/deepseek-v4-adapter.js"; +import { isDeepSeekV4Model } from "../provider/deepseek-v4-effort.js"; import { isOllamaProviderId, ollamaOpenAIBaseURL } from "../provider/ollama.js"; import { selectableGoModelIds } from "../provider/model-catalogs.js"; import { @@ -265,8 +267,9 @@ export function resolveMcpServers( } // Build the OpenAI-compatible InferenceSource the runtime consumes. `id` is the -// user-facing name for this source (e.g. "zen"); `provider` is always -// "openai-compatible" so the inference registry routes it to the right adapter. +// user-facing name for this source (e.g. "zen"); `provider` routes to the +// openai-compatible adapter, or the deepseek-v4 per-family adapter for V4 +// models, so the inference registry picks the right adapter. export function buildOpenAISource(fields: { id: string; baseURL: string; @@ -287,7 +290,9 @@ export function buildOpenAISource(fields: { registerSourceSecret(fields.id, fields.apiKey); return { id: fields.id, - provider: "openai-compatible", + provider: isDeepSeekV4Model(fields.model) + ? DEEPSEEK_V4_PROVIDER + : "openai-compatible", baseURL: isOllamaProviderId(fields.id) ? ollamaOpenAIBaseURL(fields.baseURL) : normalizeOpenAICompatibleBaseURL(fields.baseURL), diff --git a/src/config/inference-sources.test.ts b/src/config/inference-sources.test.ts index 3142fb7ea..eca705a60 100644 --- a/src/config/inference-sources.test.ts +++ b/src/config/inference-sources.test.ts @@ -794,3 +794,33 @@ describe("session source bundles", () => { expect(bundle.defaultSource).toBe("openai"); }); }); + +describe("deepseek-v4 provider stamping (PR A)", () => { + function v4SourceFor(model: string) { + return buildInferenceSourceForRef( + { provider: "ds", model }, + { + sessionId: "s1", + catalog: [ + { + name: "ds", + baseURL: "https://ds.example/v1", + apiKey: "k", + models: [model], + }, + ], + }, + undefined, + ); + } + + test("a deepseek-v4-* model stamps provider 'deepseek-v4'", () => { + const source = v4SourceFor("deepseek-v4-pro"); + expect(source?.provider).toBe("deepseek-v4"); + }); + + test("non-V4 custom models stay provider 'openai-compatible'", () => { + const source = v4SourceFor("gpt-5"); + expect(source?.provider).toBe("openai-compatible"); + }); +}); diff --git a/src/provider/deepseek-v4-adapter.test.ts b/src/provider/deepseek-v4-adapter.test.ts new file mode 100644 index 000000000..0c92df8cc --- /dev/null +++ b/src/provider/deepseek-v4-adapter.test.ts @@ -0,0 +1,208 @@ +import { describe, test, expect } from "bun:test"; +import type { ConversationTurn, InferenceOptions } from "@intx/types/runtime"; +import { + createDeepSeekV4Adapter, + DEEPSEEK_V4_PROVIDER, +} from "./deepseek-v4-adapter.js"; +import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; + +const source = { + id: "test", + provider: DEEPSEEK_V4_PROVIDER, + baseURL: "https://example.test", + apiKey: "sk-test", + model: "deepseek-v4-pro", +} as unknown as Parameters[0]; + +const messages: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "hi" }], + } as unknown as ConversationTurn, +]; + +function bodyFor(options: InferenceOptions): Record { + const adapter = createDeepSeekV4Adapter(source); + const built = adapter.buildRequest(messages, "deepseek-v4-pro", options); + return JSON.parse(built.body) as Record; +} + +describe("deepseek-v4 adapter effort wiring", () => { + test.each(["xhigh", "max"] as const)( + "wires V4 effort %s through raw and turns thinking on", + (effort) => { + const body = bodyFor({ + providerOptions: { reasoning_effort: effort }, + } as InferenceOptions); + expect(body["reasoning_effort"]).toBe(effort); + expect(body["chat_template_kwargs"]).toEqual({ thinking: true }); + }, + ); + + test("none → chat_template_kwargs.thinking false and NO reasoning_effort", () => { + const body = bodyFor({ + providerOptions: { reasoning_effort: "none" }, + } as InferenceOptions); + expect(body["chat_template_kwargs"]).toEqual({ thinking: false }); + expect("reasoning_effort" in body).toBe(false); + }); + + test("off-ladder effort is left untouched (no high→xhigh coercion)", () => { + const body = bodyFor({ + providerOptions: { reasoning_effort: "high" }, + } as InferenceOptions); + expect(body["reasoning_effort"]).toBe("high"); + expect("chat_template_kwargs" in body).toBe(false); + }); + + test("unset effort does not inject empty chat_template_kwargs", () => { + const body = bodyFor({} as InferenceOptions); + expect("chat_template_kwargs" in body).toBe(false); + expect(body["reasoning_effort"]).toBeUndefined(); + }); +}); + +describe("deepseek-v4 adapter request settings", () => { + test("stream_options include_usage is true", () => { + const body = bodyFor({} as InferenceOptions); + expect(body["stream_options"]).toEqual({ include_usage: true }); + }); + + test("top_p 0.95 and temperature 1.0 defaulted when absent", () => { + const body = bodyFor({} as InferenceOptions); + expect(body["top_p"]).toBe(0.95); + expect(body["temperature"]).toBe(1.0); + }); + + test("top_p and temperature preserved when providerOptions set them", () => { + const body = bodyFor({ + providerOptions: { temperature: 0.7, top_p: 0.9 }, + } as InferenceOptions); + expect(body["top_p"]).toBe(0.9); + expect(body["temperature"]).toBe(0.7); + }); + + test("provider-set stream_options is preserved (absent-only)", () => { + const body = bodyFor({ + providerOptions: { stream_options: { include_usage: false } }, + } as InferenceOptions); + expect(body["stream_options"]).toEqual({ include_usage: false }); + }); + + test("chat_template_kwargs merge preserves existing keys", () => { + const body = bodyFor({ + providerOptions: { + reasoning_effort: "xhigh", + chat_template_kwargs: { some: "existing" }, + }, + } as InferenceOptions); + expect(body["chat_template_kwargs"]).toEqual({ + thinking: true, + some: "existing", + }); + }); + + test("provider-set thinking is preserved (absent-only thinking)", () => { + const noneThenTrue = bodyFor({ + providerOptions: { + reasoning_effort: "none", + chat_template_kwargs: { thinking: true }, + }, + } as InferenceOptions); + expect(noneThenTrue["chat_template_kwargs"]).toEqual({ thinking: true }); + + const xhighThenFalse = bodyFor({ + providerOptions: { + reasoning_effort: "xhigh", + chat_template_kwargs: { thinking: false }, + }, + } as InferenceOptions); + expect(xhighThenFalse["chat_template_kwargs"]).toEqual({ thinking: false }); + }); +}); + +describe("deepseek-v4 adapter inherits base quirks", () => { + const withThinking: ConversationTurn[] = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + model: "deepseek-v4", + content: [ + { type: "thinking", thinking: "ponder" }, + { type: "text", text: "hello" }, + ], + }, + { role: "user", content: [{ type: "text", text: "again" }] }, + ] as unknown as ConversationTurn[]; + + test("keeps reasoning_content on replayed assistant turns (inherited base)", () => { + const adapter = createDeepSeekV4Adapter(source); + const built = adapter.buildRequest( + withThinking, + "deepseek-v4", + {} as InferenceOptions, + ); + const assistant = ( + JSON.parse(built.body) as { messages: Record[] } + ).messages.find((m) => m["role"] === "assistant"); + expect(assistant?.["reasoning_content"]).toBe("ponder"); + }); + + test("non-V4 select a generic body build (base short-circuits, no V4 fields)", () => { + const adapter = createDeepSeekV4Adapter({ + ...source, + model: "gpt-5", + } as typeof source); + const built = adapter.buildRequest( + messages, + "gpt-5", + {} as InferenceOptions, + ); + const body = JSON.parse(built.body) as Record; + expect("top_p" in body).toBe(false); + expect("temperature" in body).toBe(false); + expect("stream_options" in body).toBe(false); + expect("chat_template_kwargs" in body).toBe(false); + expect(body["model"]).toBe("gpt-5"); + }); + + test("keeps reasoning_content for non-DeepSeek models", () => { + const adapter = createDeepSeekV4Adapter({ + ...source, + model: "kimi-k2", + } as typeof source); + const built = adapter.buildRequest( + withThinking, + "kimi-k2", + {} as InferenceOptions, + ); + const assistant = ( + JSON.parse(built.body) as { messages: Record[] } + ).messages.find((m) => m["role"] === "assistant"); + expect(assistant?.["reasoning_content"]).toBe("ponder"); + }); +}); + +describe("deepseek-v4 adapter byte-identical to base for non-V4", () => { + test("non-V4 body matches the patched generic base byte-for-byte", () => { + const baseAdapter = createOpenAICompatibleAdapter({ + ...source, + model: "gpt-5", + } as typeof source); + const baseBuilt = baseAdapter.buildRequest(messages, "gpt-5", { + providerOptions: { reasoning_effort: "none" }, + } as InferenceOptions); + + const adapter = createDeepSeekV4Adapter({ + ...source, + model: "gpt-5", + } as typeof source); + const built = adapter.buildRequest(messages, "gpt-5", { + providerOptions: { reasoning_effort: "none" }, + } as InferenceOptions); + + const baseBody = JSON.parse(baseBuilt.body) as Record; + const body = JSON.parse(built.body) as Record; + expect(body).toEqual(baseBody); + }); +}); diff --git a/src/provider/deepseek-v4-adapter.ts b/src/provider/deepseek-v4-adapter.ts new file mode 100644 index 000000000..24d53b70d --- /dev/null +++ b/src/provider/deepseek-v4-adapter.ts @@ -0,0 +1,71 @@ +import { type ProviderAdapter } from "@intx/inference"; +import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; +import { + DEEPSEEK_V4_MODEL_CARD, + isDeepSeekV4Model, + mapV4Effort, + V4_STREAM_OPTIONS, +} from "./deepseek-v4-effort.js"; + +export const DEEPSEEK_V4_PROVIDER = "deepseek-v4"; + +type AdapterSource = Parameters[0]; + +// Wraps the patched openai-compatible adapter so V4 requests inherit the +// providerOptions merge, NIM null-delta patch, and Accept header; this layer +// only applies the V4 effort/sampling/stream quirks to the built body. +export function createDeepSeekV4Adapter( + source: AdapterSource, +): ProviderAdapter { + const base = createOpenAICompatibleAdapter(source); + + const buildRequest: ProviderAdapter["buildRequest"] = ( + messages, + model, + options, + ) => { + const req = base.buildRequest(messages, model, options); + if (!isDeepSeekV4Model(model)) return req; + const body = JSON.parse(req.body) as Record; + + // V4 effort ladder (none/xhigh/max): none drops reasoning_effort and + // turns thinking off; xhigh/max go out raw with thinking on; off-ladder + // or absent efforts are left untouched. + if ("reasoning_effort" in body) { + const effort = body["reasoning_effort"] as Parameters< + typeof mapV4Effort + >[0]; + const wire = mapV4Effort(effort); + if (wire === null) { + delete body["reasoning_effort"]; + mergeThinking(body, false); + } else if (wire !== undefined) { + body["reasoning_effort"] = wire; + mergeThinking(body, true); + } + } + + if (body["top_p"] === undefined) + body["top_p"] = DEEPSEEK_V4_MODEL_CARD.topP; + if (body["temperature"] === undefined) + body["temperature"] = DEEPSEEK_V4_MODEL_CARD.temperature; + body["stream_options"] ??= V4_STREAM_OPTIONS.stream_options; + + return { ...req, body: JSON.stringify(body) }; + }; + + return { ...base, buildRequest }; +} + +// chat_template_kwargs clone with an absent-only thinking toggle (never mutate +// the caller's providerOptions; provider-set thinking wins). +function mergeThinking(body: Record, thinking: boolean): void { + const existing = + body["chat_template_kwargs"] !== null && + typeof body["chat_template_kwargs"] === "object" + ? (body["chat_template_kwargs"] as Record) + : {}; + const merged = { ...existing }; + merged.thinking ??= thinking; + Object.assign(body, { chat_template_kwargs: merged }); +} diff --git a/src/provider/inference-dependencies.ts b/src/provider/inference-dependencies.ts index 9a28ad486..42060182c 100644 --- a/src/provider/inference-dependencies.ts +++ b/src/provider/inference-dependencies.ts @@ -9,6 +9,7 @@ import * as opencodeGo from "./opencode-go-adapter.js"; import * as codexResponses from "./codex-responses.js"; import * as grokResponses from "./grok-responses.js"; import * as bifrostAdapter from "./bifrost-adapter.js"; +import * as deepseekV4 from "./deepseek-v4-adapter.js"; import * as openaiResponses from "./openai-responses.js"; import * as anthropicSession from "./anthropic-session-adapter.js"; import { @@ -21,6 +22,7 @@ import { withReplaySanitizer } from "./replay-sanitizer.js"; import { isPollOnlyPendingBatch } from "../subagent/poll-exempt.js"; import { OPENCODE_GO_PROVIDER_ID } from "../../packages/opencode-go/src/index.js"; import { BIFROST_PROVIDER } from "./bifrost-adapter.js"; +import { DEEPSEEK_V4_PROVIDER } from "./deepseek-v4-adapter.js"; import { OPENAI_RESPONSES_PROVIDER } from "./openai-responses.js"; import { OPENCODE_GO_MESSAGES_PROVIDER, @@ -58,6 +60,11 @@ const manifest: AdapterManifest = [ specifier: "bifrost-adapter", export: "createBifrostAdapter", }, + { + provider: DEEPSEEK_V4_PROVIDER, + specifier: "deepseek-v4-adapter", + export: "createDeepSeekV4Adapter", + }, { provider: OPENAI_RESPONSES_PROVIDER, specifier: "openai-responses", @@ -81,6 +88,7 @@ const localModules: Record = { "codex-responses": codexResponses, "grok-responses": grokResponses, "bifrost-adapter": bifrostAdapter, + "deepseek-v4-adapter": deepseekV4, "openai-responses": openaiResponses, "anthropic-session-adapter": anthropicSession, }; diff --git a/src/provider/openai-compatible-adapter.test.ts b/src/provider/openai-compatible-adapter.test.ts index 72e9c7c4e..9fcc012d5 100644 --- a/src/provider/openai-compatible-adapter.test.ts +++ b/src/provider/openai-compatible-adapter.test.ts @@ -1,7 +1,6 @@ import { defined } from "../../testkit/defined.js"; import { describe, test, expect } from "bun:test"; import { ProtocolMismatchError } from "@intx/inference"; -import { createOpenAIAdapter } from "@intx/inference/providers"; import type { ConversationTurn, InferenceOptions } from "@intx/types/runtime"; import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; @@ -203,196 +202,3 @@ describe("openai-compatible adapter reasoning_content handling", () => { } }); }); - -describe("openai-compatible adapter V4 effort wiring", () => { - function bodyForModel( - model: string, - options: InferenceOptions, - ): Record { - const adapter = createOpenAICompatibleAdapter({ - ...source, - model, - } as typeof source); - const built = adapter.buildRequest(messages, model, options); - return JSON.parse(built.body) as Record; - } - - test.each(["xhigh", "max"] as const)( - "wires V4 effort %s through raw and turns thinking on", - (effort) => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { reasoning_effort: effort }, - } as InferenceOptions); - expect(body["reasoning_effort"]).toBe(effort); - expect(body["chat_template_kwargs"]).toEqual({ thinking: true }); - }, - ); - - describe("V4 request settings (PR4)", () => { - test("none → chat_template_kwargs.thinking false and NO reasoning_effort", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { reasoning_effort: "none" }, - } as InferenceOptions); - expect(body["chat_template_kwargs"]).toEqual({ thinking: false }); - expect("reasoning_effort" in body).toBe(false); - }); - - test("stream_options include_usage is true", () => { - const body = bodyForModel("deepseek-v4-pro", {} as InferenceOptions); - expect(body["stream_options"]).toEqual({ include_usage: true }); - // An unset effort must not force the encoder's thinking pass on nor - // inject an empty chat_template_kwargs object. - expect(body["chat_template_kwargs"]).toBeUndefined(); - }); - - test("top_p 0.95 and temperature 1.0 defaulted when absent", () => { - const body = bodyForModel("deepseek-v4-pro", {} as InferenceOptions); - expect(body["top_p"]).toBe(0.95); - expect(body["temperature"]).toBe(1.0); - // An unset effort must not force the encoder's thinking pass on nor - // inject an empty chat_template_kwargs object. - expect(body["chat_template_kwargs"]).toBeUndefined(); - }); - - test("top_p and temperature preserved when providerOptions set them", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { temperature: 0.7, top_p: 0.9 }, - } as InferenceOptions); - expect(body["top_p"]).toBe(0.9); - expect(body["temperature"]).toBe(0.7); - }); - - test("chat_template_kwargs merge preserves existing keys", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { - reasoning_effort: "xhigh", - chat_template_kwargs: { some: "existing" }, - }, - } as InferenceOptions); - expect(body["chat_template_kwargs"]).toEqual({ - thinking: true, - some: "existing", - }); - }); - - test("off-ladder effort is left untouched (no high→xhigh coercion)", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { reasoning_effort: "high" }, - } as InferenceOptions); - expect(body["reasoning_effort"]).toBe("high"); - expect("chat_template_kwargs" in body).toBe(false); - }); - - test("none merges thinking off into, never replaces, existing kwargs", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { - reasoning_effort: "none", - chat_template_kwargs: { some: "existing" }, - }, - } as InferenceOptions); - expect(body["chat_template_kwargs"]).toEqual({ - thinking: false, - some: "existing", - }); - }); - - test("provider-set thinking is preserved (absent-only thinking)", () => { - // A provider-supplied thinking value beats the V4 effort's implied one: - // `none` normally forces thinking off, but an explicit thinking:true - // must be kept (never force-stamped), and vice versa. - const noneThenTrue = bodyForModel("deepseek-v4-pro", { - providerOptions: { - reasoning_effort: "none", - chat_template_kwargs: { thinking: true }, - }, - } as InferenceOptions); - expect(noneThenTrue["chat_template_kwargs"]).toEqual({ thinking: true }); - - const xhighThenFalse = bodyForModel("deepseek-v4-pro", { - providerOptions: { - reasoning_effort: "xhigh", - chat_template_kwargs: { thinking: false }, - }, - } as InferenceOptions); - expect(xhighThenFalse["chat_template_kwargs"]).toEqual({ - thinking: false, - }); - }); - - test("unset effort does not inject empty chat_template_kwargs", () => { - const body = bodyForModel("deepseek-v4-pro", {} as InferenceOptions); - expect("chat_template_kwargs" in body).toBe(false); - expect(body["reasoning_effort"]).toBeUndefined(); - }); - - test("provider-set stream_options is preserved (absent-only)", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { - stream_options: { include_usage: false }, - }, - } as InferenceOptions); - expect(body["stream_options"]).toEqual({ include_usage: false }); - }); - }); - - describe("v4-effort is no-op for non-V4", () => { - const models = [ - "gpt-5", - "gpt-5.1", - "kimi-k2", - "grok-4.6", - "claude-sonnet-4.5", - "glm-5.3", - "some-openai-compatible-model", - "deepseek-r1", - "deepseek-v3", - ]; - - test.each( - models.flatMap((model) => - (["high", "none", "max"] as const).map((effort) => [model, effort]), - ), - )( - "merged body for %s with effort %s is byte-identical to a manual base+merge (V4 step no-op)", - (model, effort) => { - const base = bodyForModel(model, {} as InferenceOptions); - const withEffort = bodyForModel(model, { - providerOptions: { reasoning_effort: effort }, - } as InferenceOptions); - // For a non-V4 model the V4 step must not run: the output body equals - // the base body shallow-merged with providerOptions verbatim (no - // effort mapping, no chat_template_kwargs injection) — including V4 - // ladder values like none (delete + thinking:false) and max (verbatim), - // so a regression routing any V4 rung through the V4 branch is caught. - const expected = { - ...base, - ...({ reasoning_effort: effort } as const), - }; - expect(withEffort).toEqual(expected); - }, - ); - - test.each(models)( - "V4 settings (top_p/temperature/stream_options/chat_template_kwargs) not injected for %s", - (model) => { - const body = bodyForModel(model, {} as InferenceOptions); - // Full-body equality against the raw base OpenAI adapter output: for a - // non-V4 model the wrapper must short-circuit the V4 step entirely, so - // the body matches the base build byte-for-byte (a regression that - // injects a 5th field or mutates an existing one would fail here). - const baseAdapter = createOpenAIAdapter({ - ...source, - model, - } as typeof source); - const baseBuilt = baseAdapter.buildRequest( - messages, - model, - {} as InferenceOptions, - ); - expect(body).toEqual( - JSON.parse(baseBuilt.body) as Record, - ); - }, - ); - }); -}); diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index 4f3bcce54..d8a28566b 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -1,11 +1,5 @@ import { type BuiltRequest, type ProviderAdapter } from "@intx/inference"; import { createOpenAIAdapter } from "@intx/inference/providers"; -import type { ReasoningEffort } from "../agent/profile-types.js"; -import { - DEEPSEEK_V4_MODEL_CARD, - mapV4Effort, - V4_STREAM_OPTIONS, -} from "./deepseek-v4-effort.js"; import { normalizeNullDeltaFields } from "./null-delta-fields.js"; // The stock OpenAI adapter builds the request body from a fixed set of fields @@ -53,62 +47,17 @@ export function createOpenAICompatibleAdapter( 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. V4 - // keeps each turn's reasoning on replay, so only pre-V4 DeepSeek strips it. + // keeps each turn's reasoning on replay (handled by its own adapter), so only + // pre-V4 DeepSeek strips it here. const m = model.toLowerCase().trim(); 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); + if (!hasProviderOptions && !stripReasoning) return ensureAccept(built); const body = JSON.parse(built.body) as Record; if (hasProviderOptions) Object.assign(body, providerOptions); - // Merge a thinking toggle into a clone of the existing chat_template_kwargs - // (never overwrite the object wholesale, and never mutate the caller's - // providerOptions ref). The toggle is absent-only: a provider-supplied - // thinking value wins over the V4 effort's implied value. - const applyV4Thinking = (thinking: boolean): void => { - const existing = - body["chat_template_kwargs"] !== null && - typeof body["chat_template_kwargs"] === "object" - ? (body["chat_template_kwargs"] as Record) - : {}; - const merged = { ...existing }; - merged.thinking ??= thinking; - Object.assign(body, { chat_template_kwargs: merged }); - }; - // V4 drives its own effort ladder (none/xhigh/max) and encodes thinking via - // chat_template_kwargs. `none` is a toggle, never a wire effort — drop - // reasoning_effort and tell the encoder to skip the reasoning pass; xhigh/ - // max go out raw. Off-ladder efforts stay untouched, and an absent effort - // is never invented here (no empty chat_template_kwargs either). Sampling - // defaults and stream include_usage are absent-field-only — provider-set - // values always win. - if (/^deepseek-v4/i.test(m)) { - let wireEffort: "xhigh" | "max" | null | undefined; - if ("reasoning_effort" in body) { - const raw = body["reasoning_effort"] as ReasoningEffort; - wireEffort = mapV4Effort(raw); - if (wireEffort === null) { - delete body["reasoning_effort"]; - applyV4Thinking(false); - } else if (wireEffort !== undefined) { - body["reasoning_effort"] = wireEffort; - applyV4Thinking(true); - } - } - if (body["top_p"] === undefined) - body["top_p"] = DEEPSEEK_V4_MODEL_CARD.topP; - if (body["temperature"] === undefined) - body["temperature"] = DEEPSEEK_V4_MODEL_CARD.temperature; - // Absent-only, mirroring the sampling guard: provider-set stream_options - // is never overwritten. - body["stream_options"] ??= V4_STREAM_OPTIONS.stream_options; - } if (stripReasoning && Array.isArray(body["messages"])) { for (const msg of body["messages"]) { if (msg !== null && typeof msg === "object") From b7eea0fa3222332f19d56339878ce33b4ec3abc1 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Thu, 8 Oct 2026 22:07:38 -0700 Subject: [PATCH 2/4] feat(subagent): detect deepseek-v4 family --- src/subagent/provider-family.test.ts | 32 ++++++++++++++++++++++++++++ src/subagent/provider-family.ts | 15 +++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/subagent/provider-family.test.ts b/src/subagent/provider-family.test.ts index 7e2fbf7e2..4a0cffc30 100644 --- a/src/subagent/provider-family.test.ts +++ b/src/subagent/provider-family.test.ts @@ -3,6 +3,7 @@ import { detectModelFamily, isAstraLeafProvider, isClaudeLeafProvider, + isDeepSeekV4LeafProvider, isGptProvider, isKimiLeafProvider, isXaiGrokLeafProvider, @@ -76,12 +77,43 @@ describe("detectModelFamily", () => { [{ providerName: "anthropic", model: "claude-sonnet-4" }, "claude"], [{ providerName: "openai-compat", model: "claude-opus-4-6" }, "claude"], [{ providerName: "openai", model: "gpt-5.6" }, "gpt"], + [ + { providerName: "openai-compat", model: "deepseek-v4-pro" }, + "deepseek-v4", + ], + [ + { providerName: "opencode-go", model: "deepseek-v4-flash" }, + "deepseek-v4", + ], + [ + { providerName: "openai-compat", model: "deepseek-v4-flash-vision-exp" }, + "deepseek-v4", + ], + [{ providerName: "openai-compat", model: "deepseek-coder" }, "default"], [{ providerName: "unknown-provider", model: "unknown-model" }, "default"], ])("resolves %j -> %s", (row, expected) => { expect(detectModelFamily(row)).toBe(expected); }); }); +describe("isDeepSeekV4LeafProvider", () => { + test.each<[ProviderRow, boolean]>([ + [{ providerName: "openai-compat", model: "deepseek-v4-pro" }, true], + [{ providerName: "openai-compat", model: "deepseek-v4-flash" }, true], + [ + { providerName: "openai-compat", model: "deepseek-v4-flash-vision-exp" }, + true, + ], + [{ providerName: "openai-compat", model: "DEEPSEEK-V4-PRO" }, true], + [{ providerName: "xai/default", model: "grok-4.6" }, false], + [{ providerName: "openai", model: "gpt-5.6" }, false], + [{ providerName: "openai-compat", model: "deepseek-v3" }, false], + [{ providerName: "openai-compat" }, false], + ])("matches %j -> %s", (row, expected) => { + expect(isDeepSeekV4LeafProvider(row)).toBe(expected); + }); +}); + describe("isClaudeLeafProvider", () => { test.each<[ProviderRow, boolean]>([ [{ providerName: "anthropic" }, true], diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts index 8a42497e4..c1e6030a7 100644 --- a/src/subagent/provider-family.ts +++ b/src/subagent/provider-family.ts @@ -1,6 +1,7 @@ import { GROK_RESPONSES_PROVIDER } from "../provider/grok-responses.js"; import { isXaiProviderName } from "../config/xai-providers.js"; import { isCodexProviderName } from "../config/codex-providers.js"; +import { isDeepSeekV4Model } from "../provider/deepseek-v4-effort.js"; /** * True when the leaf inference path is xAI / Grok family. @@ -63,6 +64,18 @@ export function isAstraLeafProvider(input: { return input.model !== undefined && /^gpt-6-astra/i.test(input.model.trim()); } +/** + * True when the model is the DeepSeek V4 family. Single-sourced from the + * shared V4 effort module (isDeepSeekV4Model, /^deepseek-v4/i) so the adapter + * family, effort routing, and model-family key stay consistent. + */ +export function isDeepSeekV4LeafProvider(input: { + providerName: string; + model?: string; +}): boolean { + return input.model !== undefined && isDeepSeekV4Model(input.model); +} + /** * True when the inference path is the GPT family: a Codex provider name * (codex/ OAuth profiles, the codex-responses adapter, bare codex) or a @@ -91,6 +104,7 @@ export type ModelFamily = | "claude" | "gpt" | "astra" + | "deepseek-v4" | "default"; /** @@ -108,6 +122,7 @@ export function detectModelFamily(input: { if (isMuseSparkLeafProvider(input)) return "muse"; if (isClaudeLeafProvider(input)) return "claude"; if (isAstraLeafProvider(input)) return "astra"; + if (isDeepSeekV4LeafProvider(input)) return "deepseek-v4"; if (isGptProvider(input)) return "gpt"; return "default"; } From 2b8df313e4ba420677c5367334ed869cd17a182c Mon Sep 17 00:00:00 2001 From: "user.email" Date: Thu, 8 Oct 2026 23:12:19 -0700 Subject: [PATCH 3/4] fix(provider): match vendor-qualified deepseek-v4 model ids --- src/provider/deepseek-v4-effort.test.ts | 5 +++++ src/provider/deepseek-v4-effort.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/provider/deepseek-v4-effort.test.ts b/src/provider/deepseek-v4-effort.test.ts index 886157b20..ec52b9d14 100644 --- a/src/provider/deepseek-v4-effort.test.ts +++ b/src/provider/deepseek-v4-effort.test.ts @@ -13,13 +13,18 @@ describe("isDeepSeekV4Model", () => { "deepseek-v4", "deepseek-v4-pro", "deepseek-v4-flash", + "deepseek-v4.1-flash", "deepseek-v4-flash-vision-exp", + "deepseek-ai/DeepSeek-V4-Flash-0731", + "deepseek-ai/deepseek-v4-flash", ])("true for V4 id %s", (model) => { expect(isDeepSeekV4Model(model)).toBe(true); }); test.each([ "deepseek-v3", "deepseek-r1", + "deepseek-coder", + "deepseek-chat", "gpt-5", "kimi-k2", "claude-sonnet-4.5", diff --git a/src/provider/deepseek-v4-effort.ts b/src/provider/deepseek-v4-effort.ts index 3a6b76508..2ba54e3cb 100644 --- a/src/provider/deepseek-v4-effort.ts +++ b/src/provider/deepseek-v4-effort.ts @@ -2,11 +2,13 @@ import type { ReasoningEffort } from "../agent/profile-types.js"; // DeepSeek V4 effort translator shared by the adapter and the effort picker. // V4 ships under several ids across two catalogs — deepseek-v4-pro/-flash/ -// -flash-vision-exp — so the family is matched by prefix rather than an exact -// id list; the `^` anchor keeps hypothetical spaced/pre-V4 ids out. This shape -// mirrors the grok/kimi/muse-spark prefix predicates elsewhere in the tree. +// -flash-vision-exp — and under a vendor-qualified host id like +// deepseek-ai/DeepSeek-V4-Flash-0731. Match the `deepseek-v4` segment at the +// start or after a `/`, so both bare and qualified ids resolve while pre-V4 +// ids (deepseek-coder, deepseek-chat) do not. This mirrors the grok/kimi/astra +// leaf predicates, which also accept an org-qualified segment. export function isDeepSeekV4Model(model: string): boolean { - return /^deepseek-v4/i.test(model.trim()); + return /(^|\/)deepseek-v4/i.test(model.trim()); } // The operator-confirmed native V4 rungs. `none` is a toggle, never a wire From 0273d2e53f778e001e4a0a2d2553bd04107b42a0 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Thu, 8 Oct 2026 23:13:50 -0700 Subject: [PATCH 4/4] fix(provider): keep reasoning_content on qualified deepseek-v4 replays --- src/provider/deepseek-v4-adapter.test.ts | 18 ++++++++++++++++++ src/provider/openai-compatible-adapter.ts | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/provider/deepseek-v4-adapter.test.ts b/src/provider/deepseek-v4-adapter.test.ts index 0c92df8cc..0681e1236 100644 --- a/src/provider/deepseek-v4-adapter.test.ts +++ b/src/provider/deepseek-v4-adapter.test.ts @@ -148,6 +148,24 @@ describe("deepseek-v4 adapter inherits base quirks", () => { expect(assistant?.["reasoning_content"]).toBe("ponder"); }); + test("vendor-qualified V4 id keeps reasoning_content on replays (org/ prefix)", () => { + const adapter = createDeepSeekV4Adapter(source); + const built = adapter.buildRequest( + withThinking, + "deepseek-ai/DeepSeek-V4-Flash-0731", + { + providerOptions: { reasoning_effort: "xhigh" }, + } as InferenceOptions, + ); + const body = JSON.parse(built.body) as { + messages: Record[]; + reasoning_effort?: unknown; + }; + const assistant = body.messages.find((m) => m["role"] === "assistant"); + expect(assistant?.["reasoning_content"]).toBe("ponder"); + expect(body.reasoning_effort).toBe("xhigh"); + }); + test("non-V4 select a generic body build (base short-circuits, no V4 fields)", () => { const adapter = createDeepSeekV4Adapter({ ...source, diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index d8a28566b..865bed071 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -1,6 +1,7 @@ import { type BuiltRequest, type ProviderAdapter } from "@intx/inference"; import { createOpenAIAdapter } from "@intx/inference/providers"; import { normalizeNullDeltaFields } from "./null-delta-fields.js"; +import { isDeepSeekV4Model } from "./deepseek-v4-effort.js"; // The stock OpenAI adapter builds the request body from a fixed set of fields // (max_tokens, temperature, tools, messages, response_format) and ignores @@ -51,7 +52,7 @@ export function createOpenAICompatibleAdapter( // pre-V4 DeepSeek strips it here. const m = model.toLowerCase().trim(); const isDeepSeek = m.includes("deepseek"); - const stripReasoning = isDeepSeek && !/^deepseek-v4/i.test(m); + const stripReasoning = isDeepSeek && !isDeepSeekV4Model(m); needsDeepSeekPatch = isDeepSeek; if (!hasProviderOptions && !stripReasoning) return ensureAccept(built);