From 2d60b7f2acca15fcbc430a7888091ba5fbb7f5a5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 8 Oct 2026 21:35:27 -0700 Subject: [PATCH] feat(provider): add v4 request settings defaults --- src/provider/deepseek-v4-effort.test.ts | 30 +++- src/provider/deepseek-v4-effort.ts | 48 +++-- .../openai-compatible-adapter.test.ts | 165 +++++++++++++++--- src/provider/openai-compatible-adapter.ts | 59 +++++-- 4 files changed, 245 insertions(+), 57 deletions(-) diff --git a/src/provider/deepseek-v4-effort.test.ts b/src/provider/deepseek-v4-effort.test.ts index faec39f1b..886157b20 100644 --- a/src/provider/deepseek-v4-effort.test.ts +++ b/src/provider/deepseek-v4-effort.test.ts @@ -1,8 +1,11 @@ import { describe, test, expect } from "bun:test"; import { DEEPSEEK_V4_EFFORTS, + DEEPSEEK_V4_MODEL_CARD, isDeepSeekV4Model, mapV4Effort, + V4_STREAM_OPTIONS, + v4Thinking, } from "./deepseek-v4-effort.js"; describe("isDeepSeekV4Model", () => { @@ -44,10 +47,33 @@ describe("mapV4Effort", () => { 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", + test.each(["low", "medium", "high", "ultra", "minimal"] as const)( + "leaves off-ladder %s untouched (undefined)", (effort) => { expect(mapV4Effort(effort)).toBeUndefined(); }, ); }); + +describe("v4Thinking", () => { + test("thinking off for none", () => { + expect(v4Thinking("none")).toEqual({ thinking: false }); + }); + test.each(["xhigh", "max"] as const)("thinking on for %s", (effort) => { + expect(v4Thinking(effort)).toEqual({ thinking: true }); + }); +}); + +describe("DEEPSEEK_V4_MODEL_CARD", () => { + test("defaults both temperature 1.0 and top_p 0.95", () => { + expect(DEEPSEEK_V4_MODEL_CARD).toEqual({ temperature: 1.0, topP: 0.95 }); + }); +}); + +describe("V4_STREAM_OPTIONS", () => { + test("requests include_usage on stream chunks", () => { + expect(V4_STREAM_OPTIONS).toEqual({ + stream_options: { include_usage: true }, + }); + }); +}); diff --git a/src/provider/deepseek-v4-effort.ts b/src/provider/deepseek-v4-effort.ts index e9f58ecba..3a6b76508 100644 --- a/src/provider/deepseek-v4-effort.ts +++ b/src/provider/deepseek-v4-effort.ts @@ -3,28 +3,56 @@ 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. +// 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. 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). +// The operator-confirmed 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. +/** + * Translate a V4 effort to its raw wire value: `xhigh`/`max` pass through + * verbatim, `none` maps to null (the caller drops `reasoning_effort`, since + * `reasoning_effort:"none"` is illegal on the wire). Off-ladder values return + * undefined — the caller leaves `reasoning_effort` untouched rather than + * coercing it onto a V4 rung. + */ export function mapV4Effort( effort: ReasoningEffort, ): "xhigh" | "max" | null | undefined { if (effort === "none") return null; - if (effort === "xhigh" || effort === "max") return effort; - return undefined; + return effort === "xhigh" || effort === "max" ? effort : undefined; } + +/** + * chat_template_kwargs toggle for a V4 effort: thinking off for `none`, + * thinking on for any wire effort. When the caller drops `reasoning_effort` + * for `none` it supplies `{thinking:false}` so the encoder still knows to + * bypass the reasoning pass. + */ +export function v4Thinking(effort: ReasoningEffort): { thinking: boolean } { + return { thinking: effort !== "none" }; +} + +// Operator-confirmed V4 agentic model-card defaults: BOTH temperature 1.0 and +// top_p 0.95 (deliberate bypass of their temperature/topP mutual-exclusion). +// Absent-field-only — provider-set values always win. +export const DEEPSEEK_V4_MODEL_CARD = { + temperature: 1.0, + topP: 0.95, +} as const; + +// Ask every V4 stream for per-chunk usage so vendor usage accounting can read +// it (`vendor/intx-inference/.../openai.ts` only emits inference.usage when the +// chunk carries it). Hoisted so the adapter reuses one object per build. +export const V4_STREAM_OPTIONS = { + stream_options: { include_usage: true }, +} as const; diff --git a/src/provider/openai-compatible-adapter.test.ts b/src/provider/openai-compatible-adapter.test.ts index 4b5d3d704..8c162901b 100644 --- a/src/provider/openai-compatible-adapter.test.ts +++ b/src/provider/openai-compatible-adapter.test.ts @@ -1,6 +1,7 @@ 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"; @@ -178,33 +179,123 @@ describe("openai-compatible adapter V4 effort wiring", () => { 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", + "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 }); }, ); - test.each(["low", "medium", "high"] as const)( - "leaves off-ladder %s untouched", - (effort) => { + 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: effort }, + providerOptions: { reasoning_effort: "none" }, } as InferenceOptions); - expect(body["reasoning_effort"]).toBe(effort); - }, - ); + 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 = [ @@ -219,28 +310,50 @@ describe("openai-compatible adapter V4 effort wiring", () => { "deepseek-v3", ]; - test.each(models)( - "merged body for %s is byte-identical to a manual base+merge (V4 step no-op)", - (model) => { + 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: "high" }, + 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: "high" } as const), + ...({ reasoning_effort: effort } as const), }; expect(withEffort).toEqual(expected); }, ); test.each(models)( - "merged body for %s carries reasoning_effort verbatim", + "V4 settings (top_p/temperature/stream_options/chat_template_kwargs) not injected for %s", (model) => { - const body = bodyForModel(model, { - providerOptions: { reasoning_effort: "high" }, - } as InferenceOptions); - expect(body["reasoning_effort"]).toBe("high"); + 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 9d0bbe61a..0a81f2835 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -1,7 +1,11 @@ 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 { + 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 @@ -47,40 +51,57 @@ 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 m = model.toLowerCase().trim(); 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) { + + // 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; - const wireEffort = mapV4Effort(raw); + 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 }); + 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"]) {