Skip to content

Commit b69db53

Browse files
feat(provider): add v4 effort ladder and wire mapping (#1364)
* feat(provider): add v4 effort ladder and wire mapping * style(provider): tighten v4 effort comments * refactor(provider): remove dead v4 helper and tighten effort mapping
1 parent 76fee53 commit b69db53

6 files changed

Lines changed: 226 additions & 1 deletion
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, test, expect } from "bun:test";
2+
import {
3+
DEEPSEEK_V4_EFFORTS,
4+
isDeepSeekV4Model,
5+
mapV4Effort,
6+
} from "./deepseek-v4-effort.js";
7+
8+
describe("isDeepSeekV4Model", () => {
9+
test.each([
10+
"deepseek-v4",
11+
"deepseek-v4-pro",
12+
"deepseek-v4-flash",
13+
"deepseek-v4-flash-vision-exp",
14+
])("true for V4 id %s", (model) => {
15+
expect(isDeepSeekV4Model(model)).toBe(true);
16+
});
17+
test.each([
18+
"deepseek-v3",
19+
"deepseek-r1",
20+
"gpt-5",
21+
"kimi-k2",
22+
"claude-sonnet-4.5",
23+
"glm-5.3",
24+
"grok-4.6",
25+
"some-proxy-deepseek-v4-pro",
26+
])("false for non-V4 id %s", (model) => {
27+
expect(isDeepSeekV4Model(model)).toBe(false);
28+
});
29+
});
30+
31+
describe("DEEPSEEK_V4_EFFORTS", () => {
32+
test("ladder is none/xhigh/max", () => {
33+
expect(DEEPSEEK_V4_EFFORTS).toEqual(["none", "xhigh", "max"]);
34+
});
35+
});
36+
37+
describe("mapV4Effort", () => {
38+
test.each([
39+
["xhigh", "xhigh"],
40+
["max", "max"],
41+
] as const)("maps %s raw to %s", (effort, wire) => {
42+
expect(mapV4Effort(effort)).toBe(wire);
43+
});
44+
test("maps none to null (drop the wire effort)", () => {
45+
expect(mapV4Effort("none")).toBeNull();
46+
});
47+
test.each(["low", "medium", "high"] as const)(
48+
"maps off-ladder %s to undefined",
49+
(effort) => {
50+
expect(mapV4Effort(effort)).toBeUndefined();
51+
},
52+
);
53+
});

‎src/provider/deepseek-v4-effort.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { ReasoningEffort } from "../agent/profile-types.js";
2+
3+
// DeepSeek V4 effort translator shared by the adapter and the effort picker.
4+
// V4 ships under several ids across two catalogs — deepseek-v4-pro/-flash/
5+
// -flash-vision-exp — so the family is matched by prefix rather than an exact
6+
// id list, mirroring the grok/kimi/muse-spark prefix predicates elsewhere.
7+
export function isDeepSeekV4Model(model: string): boolean {
8+
return /^deepseek-v4/i.test(model.trim());
9+
}
10+
11+
// The native V4 rungs. `none` is a toggle, never a wire effort; `xhigh`/`max`
12+
// are the wire rungs sent raw (the API docs' low/high/max is the gateway
13+
// normalization layer, not what the model/encoder honors).
14+
export const DEEPSEEK_V4_EFFORTS: readonly ReasoningEffort[] = [
15+
"none",
16+
"xhigh",
17+
"max",
18+
];
19+
20+
// Translate a V4 effort to its raw wire value: `none` maps to null (the caller
21+
// drops `reasoning_effort`, since `reasoning_effort:"none"` is illegal on the
22+
// wire), `xhigh`/`max` pass through verbatim, and any off-ladder value returns
23+
// undefined so the caller leaves effort untouched.
24+
export function mapV4Effort(
25+
effort: ReasoningEffort,
26+
): "xhigh" | "max" | null | undefined {
27+
if (effort === "none") return null;
28+
if (effort === "xhigh" || effort === "max") return effort;
29+
return undefined;
30+
}

‎src/provider/openai-compatible-adapter.test.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,84 @@ describe("openai-compatible adapter reasoning_content handling", () => {
164164
expect(assistant?.["reasoning_content"]).toBe("ponder");
165165
});
166166
});
167+
168+
describe("openai-compatible adapter V4 effort wiring", () => {
169+
function bodyForModel(
170+
model: string,
171+
options: InferenceOptions,
172+
): Record<string, unknown> {
173+
const adapter = createOpenAICompatibleAdapter({
174+
...source,
175+
model,
176+
} as typeof source);
177+
const built = adapter.buildRequest(messages, model, options);
178+
return JSON.parse(built.body) as Record<string, unknown>;
179+
}
180+
181+
test("none drops reasoning_effort and sets thinking off", () => {
182+
const body = bodyForModel("deepseek-v4-pro", {
183+
providerOptions: { reasoning_effort: "none" },
184+
} as InferenceOptions);
185+
expect("reasoning_effort" in body).toBe(false);
186+
expect(body["chat_template_kwargs"]).toEqual({ thinking: false });
187+
});
188+
189+
test.each(["xhigh", "max"] as const)(
190+
"maps ladder %s through verbatim",
191+
(effort) => {
192+
const body = bodyForModel("deepseek-v4-pro", {
193+
providerOptions: { reasoning_effort: effort },
194+
} as InferenceOptions);
195+
expect(body["reasoning_effort"]).toBe(effort);
196+
},
197+
);
198+
199+
test.each(["low", "medium", "high"] as const)(
200+
"leaves off-ladder %s untouched",
201+
(effort) => {
202+
const body = bodyForModel("deepseek-v4-pro", {
203+
providerOptions: { reasoning_effort: effort },
204+
} as InferenceOptions);
205+
expect(body["reasoning_effort"]).toBe(effort);
206+
},
207+
);
208+
209+
describe("v4-effort is no-op for non-V4", () => {
210+
const models = [
211+
"gpt-5",
212+
"gpt-5.1",
213+
"kimi-k2",
214+
"grok-4.6",
215+
"claude-sonnet-4.5",
216+
"glm-5.3",
217+
"some-openai-compatible-model",
218+
"deepseek-r1",
219+
"deepseek-v3",
220+
];
221+
222+
test.each(models)(
223+
"merged body for %s is byte-identical to a manual base+merge (V4 step no-op)",
224+
(model) => {
225+
const base = bodyForModel(model, {} as InferenceOptions);
226+
const withEffort = bodyForModel(model, {
227+
providerOptions: { reasoning_effort: "high" },
228+
} as InferenceOptions);
229+
const expected = {
230+
...base,
231+
...({ reasoning_effort: "high" } as const),
232+
};
233+
expect(withEffort).toEqual(expected);
234+
},
235+
);
236+
237+
test.each(models)(
238+
"merged body for %s carries reasoning_effort verbatim",
239+
(model) => {
240+
const body = bodyForModel(model, {
241+
providerOptions: { reasoning_effort: "high" },
242+
} as InferenceOptions);
243+
expect(body["reasoning_effort"]).toBe("high");
244+
},
245+
);
246+
});
247+
});

‎src/provider/openai-compatible-adapter.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { type BuiltRequest, type ProviderAdapter } from "@intx/inference";
22
import { createOpenAIAdapter } from "@intx/inference/providers";
3+
import type { ReasoningEffort } from "../agent/profile-types.js";
4+
import { isDeepSeekV4Model, mapV4Effort } from "./deepseek-v4-effort.js";
35
import { normalizeNullDeltaFields } from "./null-delta-fields.js";
46

57
// The stock OpenAI adapter builds the request body from a fixed set of fields
@@ -45,14 +47,41 @@ export function createOpenAICompatibleAdapter(
4547
const providerOptions = options.providerOptions;
4648
const hasProviderOptions =
4749
providerOptions !== undefined && Object.keys(providerOptions).length > 0;
50+
// Normalize the model once so every predicate below reads from the same
51+
// lower-cased, trimmed string rather than re-normalizing per call.
52+
const m = model.toLowerCase().trim();
4853
// DeepSeek returns HTTP 400 if `reasoning_content` appears in input messages,
4954
// whereas the base adapter emits it for any model with thinking enabled.
50-
const stripReasoning = model.toLowerCase().includes("deepseek");
55+
const stripReasoning = m.includes("deepseek");
5156
needsDeepSeekPatch = stripReasoning;
57+
const isV4 = isDeepSeekV4Model(m);
5258
if (!hasProviderOptions && !stripReasoning) return ensureAccept(built);
5359

5460
const body = JSON.parse(built.body) as Record<string, unknown>;
5561
if (hasProviderOptions) Object.assign(body, providerOptions);
62+
// V4's ladder (none/xhigh/max) is translated here: `none` drops
63+
// reasoning_effort and toggles the encoder's thinking off (it is illegal
64+
// on the wire), while `xhigh`/`max` go out raw. Effort absent under V4 is
65+
// left absent. Off-ladder efforts (low/medium/high) are left untouched.
66+
if (isV4) {
67+
if ("reasoning_effort" in body) {
68+
const raw = body["reasoning_effort"] as ReasoningEffort;
69+
const wireEffort = mapV4Effort(raw);
70+
if (wireEffort === null) {
71+
delete body["reasoning_effort"];
72+
// Merge into any existing kwargs so provider-supplied keys survive.
73+
const kwargs =
74+
body["chat_template_kwargs"] !== null &&
75+
typeof body["chat_template_kwargs"] === "object"
76+
? (body["chat_template_kwargs"] as Record<string, unknown>)
77+
: {};
78+
kwargs.thinking = false;
79+
Object.assign(body, { chat_template_kwargs: kwargs });
80+
} else if (wireEffort !== undefined) {
81+
body["reasoning_effort"] = wireEffort;
82+
}
83+
}
84+
}
5685
if (stripReasoning && Array.isArray(body["messages"])) {
5786
for (const msg of body["messages"]) {
5887
if (msg !== null && typeof msg === "object")

‎src/provider/reasoning-effort.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,17 @@ describe("supportedEfforts", () => {
116116
expect(supportedEfforts("glm-5.3")).toEqual(["low", "high", "max"]);
117117
});
118118

119+
test("V4 model gets the native none/xhigh/max ladder", () => {
120+
expect(supportedEfforts("deepseek-v4-pro")).toEqual([
121+
"none",
122+
"xhigh",
123+
"max",
124+
]);
125+
});
126+
test("pre-V4 deepseek-v3 stays on the unknown-model subset", () => {
127+
expect(supportedEfforts("deepseek-v3")).toEqual(["low", "medium", "high"]);
128+
});
129+
119130
// Every catalog Muse Spark id reaches supportedEfforts unnormalized, so
120131
// they must all land on the same ladder — two ids pin the family rule.
121132
test.each(["muse-spark-1.3-contributor", "muse-spark-1.3-contributor-free"])(
@@ -175,6 +186,15 @@ describe("validateEffort", () => {
175186
expect(validateEffort("glm-5.3", "medium").ok).toBe(false);
176187
expect(validateEffort("glm-5.3", "max")).toEqual({ ok: true });
177188
});
189+
190+
test("V4 accepts none/xhigh/max and rejects medium (and low/high)", () => {
191+
for (const effort of ["none", "xhigh", "max"] as const) {
192+
expect(validateEffort("deepseek-v4-pro", effort)).toEqual({ ok: true });
193+
}
194+
for (const effort of ["medium", "low", "high"] as const) {
195+
expect(validateEffort("deepseek-v4-pro", effort).ok).toBe(false);
196+
}
197+
});
178198
});
179199

180200
describe("cycleReasoningEffort", () => {
@@ -448,6 +468,10 @@ describe("defaultEffortForModel", () => {
448468
expect(defaultEffortForModel("glm-5.3")).toBe("max");
449469
});
450470

471+
test("V4 model defaults to max", () => {
472+
expect(defaultEffortForModel("deepseek-v4-pro")).toBe("max");
473+
});
474+
451475
test("gpt-5 and o-series default to medium", () => {
452476
expect(defaultEffortForModel("gpt-5")).toBe("medium");
453477
expect(defaultEffortForModel("o4-mini")).toBe("medium");

‎src/provider/reasoning-effort.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
// profile schema and the runtime cannot drift. Re-exported here for callers
88
// that already import from this module.
99
import { REASONING_EFFORTS as CANONICAL_EFFORTS } from "../agent/profile-types.js";
10+
import {
11+
DEEPSEEK_V4_EFFORTS,
12+
isDeepSeekV4Model,
13+
} from "./deepseek-v4-effort.js";
1014

1115
export const REASONING_EFFORTS = CANONICAL_EFFORTS;
1216
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
@@ -186,6 +190,9 @@ export function supportedEfforts(
186190
if (GLM_53_MODELS.includes(model)) {
187191
return [...GLM_53_EFFORTS];
188192
}
193+
if (isDeepSeekV4Model(model)) {
194+
return [...DEEPSEEK_V4_EFFORTS];
195+
}
189196
if (isMuseSparkModel(model)) {
190197
return [...MUSE_SPARK_EFFORTS];
191198
}
@@ -287,6 +294,7 @@ export function defaultEffortForModel(
287294
}
288295
if (model.startsWith("grok")) return pick("high");
289296
if (GLM_53_MODELS.includes(model)) return pick("max");
297+
if (isDeepSeekV4Model(model)) return pick("max");
290298
if (isMuseSparkModel(model)) return pick("low");
291299
if (!isCodex && supported.includes("none")) return "none";
292300
if (isCodex || isKnownOpenAIReasoningModel(model)) return pick("medium");

0 commit comments

Comments
 (0)