diff --git a/src/provider/deepseek-v4-effort.test.ts b/src/provider/deepseek-v4-effort.test.ts new file mode 100644 index 000000000..faec39f1b --- /dev/null +++ b/src/provider/deepseek-v4-effort.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect } from "bun:test"; +import { + DEEPSEEK_V4_EFFORTS, + isDeepSeekV4Model, + mapV4Effort, +} from "./deepseek-v4-effort.js"; + +describe("isDeepSeekV4Model", () => { + test.each([ + "deepseek-v4", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", + ])("true for V4 id %s", (model) => { + expect(isDeepSeekV4Model(model)).toBe(true); + }); + test.each([ + "deepseek-v3", + "deepseek-r1", + "gpt-5", + "kimi-k2", + "claude-sonnet-4.5", + "glm-5.3", + "grok-4.6", + "some-proxy-deepseek-v4-pro", + ])("false for non-V4 id %s", (model) => { + expect(isDeepSeekV4Model(model)).toBe(false); + }); +}); + +describe("DEEPSEEK_V4_EFFORTS", () => { + test("ladder is none/xhigh/max", () => { + expect(DEEPSEEK_V4_EFFORTS).toEqual(["none", "xhigh", "max"]); + }); +}); + +describe("mapV4Effort", () => { + test.each([ + ["xhigh", "xhigh"], + ["max", "max"], + ] as const)("maps %s raw to %s", (effort, wire) => { + expect(mapV4Effort(effort)).toBe(wire); + }); + test("maps none to null (drop the wire effort)", () => { + expect(mapV4Effort("none")).toBeNull(); + }); + test.each(["low", "medium", "high"] as const)( + "maps off-ladder %s to undefined", + (effort) => { + expect(mapV4Effort(effort)).toBeUndefined(); + }, + ); +}); diff --git a/src/provider/deepseek-v4-effort.ts b/src/provider/deepseek-v4-effort.ts new file mode 100644 index 000000000..e9f58ecba --- /dev/null +++ b/src/provider/deepseek-v4-effort.ts @@ -0,0 +1,30 @@ +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, mirroring the grok/kimi/muse-spark prefix predicates elsewhere. +export function isDeepSeekV4Model(model: string): boolean { + return /^deepseek-v4/i.test(model.trim()); +} + +// The native V4 rungs. `none` is a toggle, never a wire effort; `xhigh`/`max` +// are the wire rungs sent raw (the API docs' low/high/max is the gateway +// normalization layer, not what the model/encoder honors). +export const DEEPSEEK_V4_EFFORTS: readonly ReasoningEffort[] = [ + "none", + "xhigh", + "max", +]; + +// Translate a V4 effort to its raw wire value: `none` maps to null (the caller +// drops `reasoning_effort`, since `reasoning_effort:"none"` is illegal on the +// wire), `xhigh`/`max` pass through verbatim, and any off-ladder value returns +// undefined so the caller leaves effort untouched. +export function mapV4Effort( + effort: ReasoningEffort, +): "xhigh" | "max" | null | undefined { + if (effort === "none") return null; + if (effort === "xhigh" || effort === "max") return effort; + return undefined; +} diff --git a/src/provider/openai-compatible-adapter.test.ts b/src/provider/openai-compatible-adapter.test.ts index 518ff29b5..4b5d3d704 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 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("none drops reasoning_effort and sets thinking off", () => { + const body = bodyForModel("deepseek-v4-pro", { + providerOptions: { reasoning_effort: "none" }, + } as InferenceOptions); + expect("reasoning_effort" in body).toBe(false); + expect(body["chat_template_kwargs"]).toEqual({ thinking: false }); + }); + + test.each(["xhigh", "max"] as const)( + "maps ladder %s through verbatim", + (effort) => { + const body = bodyForModel("deepseek-v4-pro", { + providerOptions: { reasoning_effort: effort }, + } as InferenceOptions); + expect(body["reasoning_effort"]).toBe(effort); + }, + ); + + test.each(["low", "medium", "high"] as const)( + "leaves off-ladder %s untouched", + (effort) => { + const body = bodyForModel("deepseek-v4-pro", { + providerOptions: { reasoning_effort: effort }, + } as InferenceOptions); + expect(body["reasoning_effort"]).toBe(effort); + }, + ); + + 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)( + "merged body for %s is byte-identical to a manual base+merge (V4 step no-op)", + (model) => { + const base = bodyForModel(model, {} as InferenceOptions); + const withEffort = bodyForModel(model, { + providerOptions: { reasoning_effort: "high" }, + } as InferenceOptions); + const expected = { + ...base, + ...({ reasoning_effort: "high" } as const), + }; + expect(withEffort).toEqual(expected); + }, + ); + + test.each(models)( + "merged body for %s carries reasoning_effort verbatim", + (model) => { + const body = bodyForModel(model, { + providerOptions: { reasoning_effort: "high" }, + } as InferenceOptions); + expect(body["reasoning_effort"]).toBe("high"); + }, + ); + }); +}); diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index d869af801..9d0bbe61a 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -1,5 +1,7 @@ import { type BuiltRequest, type ProviderAdapter } from "@intx/inference"; import { createOpenAIAdapter } from "@intx/inference/providers"; +import type { ReasoningEffort } from "../agent/profile-types.js"; +import { isDeepSeekV4Model, mapV4Effort } 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 @@ -45,14 +47,41 @@ export function createOpenAICompatibleAdapter( const providerOptions = options.providerOptions; const hasProviderOptions = providerOptions !== undefined && Object.keys(providerOptions).length > 0; + // Normalize the model once so every predicate below reads from the same + // lower-cased, trimmed string rather than re-normalizing per call. + const m = model.toLowerCase().trim(); // 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"); + const stripReasoning = m.includes("deepseek"); needsDeepSeekPatch = stripReasoning; + const isV4 = isDeepSeekV4Model(m); if (!hasProviderOptions && !stripReasoning) return ensureAccept(built); const body = JSON.parse(built.body) as Record; if (hasProviderOptions) Object.assign(body, providerOptions); + // V4's ladder (none/xhigh/max) is translated here: `none` drops + // reasoning_effort and toggles the encoder's thinking off (it is illegal + // on the wire), while `xhigh`/`max` go out raw. Effort absent under V4 is + // left absent. Off-ladder efforts (low/medium/high) are left untouched. + if (isV4) { + if ("reasoning_effort" in body) { + const raw = body["reasoning_effort"] as ReasoningEffort; + const wireEffort = mapV4Effort(raw); + if (wireEffort === null) { + delete body["reasoning_effort"]; + // Merge into any existing kwargs so provider-supplied keys survive. + const kwargs = + body["chat_template_kwargs"] !== null && + typeof body["chat_template_kwargs"] === "object" + ? (body["chat_template_kwargs"] as Record) + : {}; + kwargs.thinking = false; + Object.assign(body, { chat_template_kwargs: kwargs }); + } else if (wireEffort !== undefined) { + body["reasoning_effort"] = wireEffort; + } + } + } if (stripReasoning && Array.isArray(body["messages"])) { for (const msg of body["messages"]) { if (msg !== null && typeof msg === "object") diff --git a/src/provider/reasoning-effort.test.ts b/src/provider/reasoning-effort.test.ts index b3bcc699e..390cdba1d 100644 --- a/src/provider/reasoning-effort.test.ts +++ b/src/provider/reasoning-effort.test.ts @@ -116,6 +116,17 @@ describe("supportedEfforts", () => { expect(supportedEfforts("glm-5.3")).toEqual(["low", "high", "max"]); }); + test("V4 model gets the native none/xhigh/max ladder", () => { + expect(supportedEfforts("deepseek-v4-pro")).toEqual([ + "none", + "xhigh", + "max", + ]); + }); + test("pre-V4 deepseek-v3 stays on the unknown-model subset", () => { + expect(supportedEfforts("deepseek-v3")).toEqual(["low", "medium", "high"]); + }); + // Every catalog Muse Spark id reaches supportedEfforts unnormalized, so // they must all land on the same ladder — two ids pin the family rule. test.each(["muse-spark-1.3-contributor", "muse-spark-1.3-contributor-free"])( @@ -175,6 +186,15 @@ describe("validateEffort", () => { expect(validateEffort("glm-5.3", "medium").ok).toBe(false); expect(validateEffort("glm-5.3", "max")).toEqual({ ok: true }); }); + + test("V4 accepts none/xhigh/max and rejects medium (and low/high)", () => { + for (const effort of ["none", "xhigh", "max"] as const) { + expect(validateEffort("deepseek-v4-pro", effort)).toEqual({ ok: true }); + } + for (const effort of ["medium", "low", "high"] as const) { + expect(validateEffort("deepseek-v4-pro", effort).ok).toBe(false); + } + }); }); describe("cycleReasoningEffort", () => { @@ -448,6 +468,10 @@ describe("defaultEffortForModel", () => { expect(defaultEffortForModel("glm-5.3")).toBe("max"); }); + test("V4 model defaults to max", () => { + expect(defaultEffortForModel("deepseek-v4-pro")).toBe("max"); + }); + test("gpt-5 and o-series default to medium", () => { expect(defaultEffortForModel("gpt-5")).toBe("medium"); expect(defaultEffortForModel("o4-mini")).toBe("medium"); diff --git a/src/provider/reasoning-effort.ts b/src/provider/reasoning-effort.ts index 44f3a694d..b9ac32a88 100644 --- a/src/provider/reasoning-effort.ts +++ b/src/provider/reasoning-effort.ts @@ -7,6 +7,10 @@ // profile schema and the runtime cannot drift. Re-exported here for callers // that already import from this module. import { REASONING_EFFORTS as CANONICAL_EFFORTS } from "../agent/profile-types.js"; +import { + DEEPSEEK_V4_EFFORTS, + isDeepSeekV4Model, +} from "./deepseek-v4-effort.js"; export const REASONING_EFFORTS = CANONICAL_EFFORTS; export type ReasoningEffort = (typeof REASONING_EFFORTS)[number]; @@ -186,6 +190,9 @@ export function supportedEfforts( if (GLM_53_MODELS.includes(model)) { return [...GLM_53_EFFORTS]; } + if (isDeepSeekV4Model(model)) { + return [...DEEPSEEK_V4_EFFORTS]; + } if (isMuseSparkModel(model)) { return [...MUSE_SPARK_EFFORTS]; } @@ -287,6 +294,7 @@ export function defaultEffortForModel( } if (model.startsWith("grok")) return pick("high"); if (GLM_53_MODELS.includes(model)) return pick("max"); + if (isDeepSeekV4Model(model)) return pick("max"); if (isMuseSparkModel(model)) return pick("low"); if (!isCodex && supported.includes("none")) return "none"; if (isCodex || isKnownOpenAIReasoningModel(model)) return pick("medium");