Skip to content

Commit af344c9

Browse files
committed
fix(provider): tighten deepseek-v4 effort policy
1 parent 9b8ba29 commit af344c9

13 files changed

Lines changed: 173 additions & 73 deletions

‎src/agent/model-family-policy.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -411,39 +411,39 @@ describe("resolveModelFamilyPolicy", () => {
411411

412412
test("orchestrators get the d1 primary residual, no leaf role body", () => {
413413
const p = v4({ orchestrator: true });
414-
expect(p.family).toBe("deepseek-v4");
414+
expect(p.family).toBe("deepseek");
415415
expect(p.promptResidual).toBeDefined();
416416
expect(p.promptResidual).toContain("DeepSeek V4 Flash");
417417
expect(p.leafRoleBody).toBeUndefined();
418418
});
419419

420420
test("coder leaves get the tuned slim coder body, no residual", () => {
421421
const p = v4({ orchestrator: false, directorId: "coder" });
422-
expect(p.family).toBe("deepseek-v4");
422+
expect(p.family).toBe("deepseek");
423423
expect(p.leafRoleBody).toBeDefined();
424424
expect(p.leafRoleBody).toContain("Coder");
425425
expect(p.promptResidual).toBeUndefined();
426426
});
427427

428428
test("reviewer leaves get the tuned slim reviewer body", () => {
429429
const p = v4({ orchestrator: false, directorId: "reviewer" });
430-
expect(p.family).toBe("deepseek-v4");
430+
expect(p.family).toBe("deepseek");
431431
expect(p.leafRoleBody).toBeDefined();
432432
expect(p.leafRoleBody).toContain("Reviewer");
433433
expect(p.promptResidual).toBeUndefined();
434434
});
435435

436436
test("explorer leaves get the g2 leaf residual only", () => {
437437
const p = v4({ orchestrator: false, directorId: "explorer" });
438-
expect(p.family).toBe("deepseek-v4");
438+
expect(p.family).toBe("deepseek");
439439
expect(p.promptResidual).toBeDefined();
440440
expect(p.promptResidual).toContain("DeepSeek V4 Flash");
441441
expect(p.leafRoleBody).toBeUndefined();
442442
});
443443

444444
test("a leaf without a tuned director id gets the default policy, no body/residual", () => {
445445
const p = v4({ orchestrator: false });
446-
expect(p.family).toBe("deepseek-v4");
446+
expect(p.family).toBe("deepseek");
447447
expect(p.leafRoleBody).toBeUndefined();
448448
expect(p.promptResidual).toBeUndefined();
449449
});

‎src/agent/model-family-policy.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ export function resolveModelFamilyPolicy(input: {
299299
// Generic gpt prompts are byte-identical — only astra carries the
300300
// residual.
301301
return { family, ...ASTRA_POLICY };
302-
case "deepseek-v4": {
302+
case "deepseek": {
303303
// Winning d1+slim config: d1 primary residual on orchestrators, g2 leaf
304304
// residual on explorer leaves, tuned slim coder/reviewer bodies.
305305
const directorId = input.directorId;

‎src/config/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ import {
6060
} from "../provider/grok-responses.js";
6161
import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js";
6262
import { DEEPSEEK_V4_PROVIDER } from "../provider/deepseek-v4-adapter.js";
63-
import { isDeepSeekV4Model } from "../provider/deepseek-v4-effort.js";
63+
import { isDeepSeekModel } from "../provider/deepseek-v4-effort.js";
6464
import { isOllamaProviderId, ollamaOpenAIBaseURL } from "../provider/ollama.js";
6565
import { selectableGoModelIds } from "../provider/model-catalogs.js";
6666
import {
@@ -290,7 +290,7 @@ export function buildOpenAISource(fields: {
290290
registerSourceSecret(fields.id, fields.apiKey);
291291
return {
292292
id: fields.id,
293-
provider: isDeepSeekV4Model(fields.model)
293+
provider: isDeepSeekModel(fields.model)
294294
? DEEPSEEK_V4_PROVIDER
295295
: "openai-compatible",
296296
baseURL: isOllamaProviderId(fields.id)

‎src/config/inference-sources.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -814,12 +814,15 @@ describe("deepseek-v4 provider stamping (PR A)", () => {
814814
);
815815
}
816816

817-
test("a deepseek-v4-* model stamps provider 'deepseek-v4'", () => {
817+
test("a deepseek-* model stamps provider 'deepseek-v4' (family routing)", () => {
818818
const source = v4SourceFor("deepseek-v4-pro");
819819
expect(source?.provider).toBe("deepseek-v4");
820+
821+
const preV4 = v4SourceFor("deepseek-v3");
822+
expect(preV4?.provider).toBe("deepseek-v4");
820823
});
821824

822-
test("non-V4 custom models stay provider 'openai-compatible'", () => {
825+
test("non-deepseek custom models stay provider 'openai-compatible'", () => {
823826
const source = v4SourceFor("gpt-5");
824827
expect(source?.provider).toBe("openai-compatible");
825828
});

‎src/provider/deepseek-v4-adapter.test.ts‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ describe("deepseek-v4 adapter effort wiring", () => {
4747
expect("reasoning_effort" in body).toBe(false);
4848
});
4949

50-
test("off-ladder effort is left untouched (no high→xhigh coercion)", () => {
51-
const body = bodyFor({
52-
providerOptions: { reasoning_effort: "high" },
53-
} as InferenceOptions);
54-
expect(body["reasoning_effort"]).toBe("high");
55-
expect("chat_template_kwargs" in body).toBe(false);
50+
test("off-ladder effort is rejected (no pass-through to the wire)", () => {
51+
expect(() =>
52+
bodyFor({
53+
providerOptions: { reasoning_effort: "high" },
54+
} as InferenceOptions),
55+
).toThrow(/does not support reasoning effort "high"/);
5656
});
5757

5858
test("unset effort does not inject empty chat_template_kwargs", () => {
@@ -223,4 +223,29 @@ describe("deepseek-v4 adapter byte-identical to base for non-V4", () => {
223223
const body = JSON.parse(built.body) as Record<string, unknown>;
224224
expect(body).toEqual(baseBody);
225225
});
226+
227+
test("pre-V4 deepseek gets no V4 quirks (family route, V4-variant gate)", () => {
228+
const baseAdapter = createOpenAICompatibleAdapter({
229+
...source,
230+
model: "deepseek-v3",
231+
} as typeof source);
232+
const baseBuilt = baseAdapter.buildRequest(messages, "deepseek-v3", {
233+
providerOptions: { reasoning_effort: "none" },
234+
} as InferenceOptions);
235+
236+
const adapter = createDeepSeekV4Adapter({
237+
...source,
238+
model: "deepseek-v3",
239+
} as typeof source);
240+
const built = adapter.buildRequest(messages, "deepseek-v3", {
241+
providerOptions: { reasoning_effort: "none" },
242+
} as InferenceOptions);
243+
244+
const baseBody = JSON.parse(baseBuilt.body) as Record<string, unknown>;
245+
const body = JSON.parse(built.body) as Record<string, unknown>;
246+
expect(body).toEqual(baseBody);
247+
expect("top_p" in body).toBe(false);
248+
expect("temperature" in body).toBe(false);
249+
expect("stream_options" in body).toBe(false);
250+
});
226251
});

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type ProviderAdapter } from "@intx/inference";
22
import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js";
33
import {
44
DEEPSEEK_V4_MODEL_CARD,
5+
isDeepSeekModel,
56
isDeepSeekV4Model,
67
mapV4Effort,
78
V4_STREAM_OPTIONS,
@@ -13,7 +14,9 @@ type AdapterSource = Parameters<typeof createOpenAICompatibleAdapter>[0];
1314

1415
// Wraps the patched openai-compatible adapter so V4 requests inherit the
1516
// providerOptions merge, NIM null-delta patch, and Accept header; this layer
16-
// only applies the V4 effort/sampling/stream quirks to the built body.
17+
// routes the whole DeepSeek family through this family adapter and applies the
18+
// V4 effort/sampling/stream quirks to the built body only for the V4 variant
19+
// (pre-V4 deepseek stays on the generic openai-compatible body, byte-identical).
1720
export function createDeepSeekV4Adapter(
1821
source: AdapterSource,
1922
): ProviderAdapter {
@@ -25,12 +28,12 @@ export function createDeepSeekV4Adapter(
2528
options,
2629
) => {
2730
const req = base.buildRequest(messages, model, options);
28-
if (!isDeepSeekV4Model(model)) return req;
31+
if (!isDeepSeekModel(model) || !isDeepSeekV4Model(model)) return req;
2932
const body = JSON.parse(req.body) as Record<string, unknown>;
3033

3134
// V4 effort ladder (none/xhigh/max): none drops reasoning_effort and
32-
// turns thinking off; xhigh/max go out raw with thinking on; off-ladder
33-
// or absent efforts are left untouched.
35+
// turns thinking off; xhigh/max go out raw with thinking on. Off-ladder
36+
// efforts throw inside mapV4Effort (rejected, never passed to the wire).
3437
if ("reasoning_effort" in body) {
3538
const effort = body["reasoning_effort"] as Parameters<
3639
typeof mapV4Effort
@@ -39,7 +42,7 @@ export function createDeepSeekV4Adapter(
3942
if (wire === null) {
4043
delete body["reasoning_effort"];
4144
mergeThinking(body, false);
42-
} else if (wire !== undefined) {
45+
} else {
4346
body["reasoning_effort"] = wire;
4447
mergeThinking(body, true);
4548
}

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

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,43 @@ import { describe, test, expect } from "bun:test";
22
import {
33
DEEPSEEK_V4_EFFORTS,
44
DEEPSEEK_V4_MODEL_CARD,
5+
DEEPSEEK_V4_ROLE_EFFORT,
6+
isDeepSeekModel,
57
isDeepSeekV4Model,
68
mapV4Effort,
79
V4_STREAM_OPTIONS,
810
v4Thinking,
911
} from "./deepseek-v4-effort.js";
1012

13+
describe("isDeepSeekModel", () => {
14+
test.each([
15+
"deepseek-v4",
16+
"deepseek-v4-pro",
17+
"deepseek-v4-flash",
18+
"deepseek-v4.1-flash",
19+
"deepseek-v4-flash-vision-exp",
20+
"deepseek-ai/DeepSeek-V4-Flash-0731",
21+
"deepseek-ai/deepseek-v4-flash",
22+
"deepseek-v3",
23+
"deepseek-r1",
24+
"deepseek-coder",
25+
"deepseek-chat",
26+
"deepseek-ai/DeepSeek-Chat",
27+
])("true for deepseek family id %s", (model) => {
28+
expect(isDeepSeekModel(model)).toBe(true);
29+
});
30+
test.each([
31+
"gpt-5",
32+
"kimi-k2",
33+
"claude-sonnet-4.5",
34+
"glm-5.3",
35+
"grok-4.6",
36+
"some-proxy-deepseek-pro",
37+
])("false for non-deepseek id %s", (model) => {
38+
expect(isDeepSeekModel(model)).toBe(false);
39+
});
40+
});
41+
1142
describe("isDeepSeekV4Model", () => {
1243
test.each([
1344
"deepseek-v4",
@@ -25,6 +56,7 @@ describe("isDeepSeekV4Model", () => {
2556
"deepseek-r1",
2657
"deepseek-coder",
2758
"deepseek-chat",
59+
"deepseek-ai/DeepSeek-Chat",
2860
"gpt-5",
2961
"kimi-k2",
3062
"claude-sonnet-4.5",
@@ -42,6 +74,28 @@ describe("DEEPSEEK_V4_EFFORTS", () => {
4274
});
4375
});
4476

77+
describe("DEEPSEEK_V4_ROLE_EFFORT", () => {
78+
test("coder defaults to max", () => {
79+
expect(DEEPSEEK_V4_ROLE_EFFORT.coder).toBe("max");
80+
});
81+
test("dispatch/planner/reviewer are max and other leaves xhigh", () => {
82+
expect(DEEPSEEK_V4_ROLE_EFFORT.dispatch).toBe("max");
83+
expect(DEEPSEEK_V4_ROLE_EFFORT.planner).toBe("max");
84+
expect(DEEPSEEK_V4_ROLE_EFFORT.reviewer).toBe("max");
85+
for (const role of [
86+
"explorer",
87+
"artist",
88+
"qa-lead",
89+
"prober",
90+
"designer",
91+
"shakespeare",
92+
"warden",
93+
] as const) {
94+
expect(DEEPSEEK_V4_ROLE_EFFORT[role]).toBe("xhigh");
95+
}
96+
});
97+
});
98+
4599
describe("mapV4Effort", () => {
46100
test.each([
47101
["xhigh", "xhigh"],
@@ -53,9 +107,11 @@ describe("mapV4Effort", () => {
53107
expect(mapV4Effort("none")).toBeNull();
54108
});
55109
test.each(["low", "medium", "high", "ultra", "minimal"] as const)(
56-
"leaves off-ladder %s untouched (undefined)",
110+
"rejects off-ladder %s with a clear error (no pass-through)",
57111
(effort) => {
58-
expect(mapV4Effort(effort)).toBeUndefined();
112+
expect(() => mapV4Effort(effort)).toThrow(
113+
`DeepSeek V4 does not support reasoning effort "${effort}"`,
114+
);
59115
},
60116
);
61117
});

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

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import type { ReasoningEffort } from "../agent/profile-types.js";
22
import type { DirectorId } from "../agent/directors/types.js";
33

44
/**
5-
* Per-role V4 effort defaults. coder is the cheapest spawn: 'low' clamps to
6-
* the V4 'none' wire rung (thinking off), per the operator's "simplest
7-
* implementer = none" intent. Every other role takes its rung from the map.
5+
* Per-role V4 effort defaults. dispatch/planner/reviewer dispatch and judge —
6+
* max. coder defaults to max (operator-confirmed). Every other leaf reasons at
7+
* xhigh.
88
*/
99
export const DEEPSEEK_V4_ROLE_EFFORT: Record<DirectorId, ReasoningEffort> = {
1010
dispatch: "max",
1111
planner: "max",
1212
reviewer: "max",
13-
coder: "low",
13+
coder: "max",
1414
explorer: "xhigh",
1515
artist: "xhigh",
1616
"qa-lead": "xhigh",
@@ -20,13 +20,23 @@ export const DEEPSEEK_V4_ROLE_EFFORT: Record<DirectorId, ReasoningEffort> = {
2020
warden: "xhigh",
2121
};
2222

23-
// DeepSeek V4 effort translator shared by the adapter and the effort picker.
24-
// V4 ships under several ids across two catalogs — deepseek-v4-pro/-flash/
25-
// -flash-vision-exp — and under a vendor-qualified host id like
26-
// deepseek-ai/DeepSeek-V4-Flash-0731. Match the `deepseek-v4` segment at the
27-
// start or after a `/`, so both bare and qualified ids resolve while pre-V4
28-
// ids (deepseek-coder, deepseek-chat) do not. This mirrors the grok/kimi/astra
29-
// leaf predicates, which also accept an org-qualified segment.
23+
// DeepSeek family predicate shared by the adapter router and the family policy.
24+
// The family ships under several ids across two catalogs — deepseek-v4-pro/
25+
// -flash/-flash-vision-exp and deepseek-chat/deepseek-coder/deepseek-v3 — and
26+
// under a vendor-qualified host id like deepseek-ai/DeepSeek-V4-Flash-0731.
27+
// Match the `deepseek` segment at the start or after a `/`, so both bare and
28+
// qualified ids resolve across the whole family (including future versions).
29+
// This mirrors the grok/kimi/astra leaf predicates, which also accept an
30+
// org-qualified segment.
31+
export function isDeepSeekModel(model: string): boolean {
32+
return /(^|\/)deepseek/i.test(model.trim());
33+
}
34+
35+
// V4-variant detection: distinguishes the V4 model line from pre-V4 DeepSeek
36+
// (deepseek-chat, deepseek-coder, deepseek-v3). Only where the wire semantics
37+
// genuinely differ between V4 and pre-V4 deepseek — the V4 adapter's model-card
38+
// and effort quirks, the reasoning_content keep-vs-strip behavior, and the
39+
// none/xhigh/max effort ladder — do callers gate on this variant predicate.
3040
export function isDeepSeekV4Model(model: string): boolean {
3141
return /(^|\/)deepseek-v4/i.test(model.trim());
3242
}
@@ -43,15 +53,17 @@ export const DEEPSEEK_V4_EFFORTS: readonly ReasoningEffort[] = [
4353
/**
4454
* Translate a V4 effort to its raw wire value: `xhigh`/`max` pass through
4555
* verbatim, `none` maps to null (the caller drops `reasoning_effort`, since
46-
* `reasoning_effort:"none"` is illegal on the wire). Off-ladder values return
47-
* undefined — the caller leaves `reasoning_effort` untouched rather than
48-
* coercing it onto a V4 rung.
56+
* `reasoning_effort:"none"` is illegal on the wire). The V4 ladder is strictly
57+
* none/xhigh/max: any off-ladder effort arriving on a manual/exec/config path
58+
* is rejected with a clear error rather than coerced or passed through, so no
59+
* off-ladder value can reach the wire for a V4 model.
4960
*/
50-
export function mapV4Effort(
51-
effort: ReasoningEffort,
52-
): "xhigh" | "max" | null | undefined {
61+
export function mapV4Effort(effort: ReasoningEffort): "xhigh" | "max" | null {
5362
if (effort === "none") return null;
54-
return effort === "xhigh" || effort === "max" ? effort : undefined;
63+
if (effort === "xhigh" || effort === "max") return effort;
64+
throw new Error(
65+
`DeepSeek V4 does not support reasoning effort "${effort}" (supported: none, xhigh, max).`,
66+
);
5567
}
5668

5769
/**

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type BuiltRequest, type ProviderAdapter } from "@intx/inference";
22
import { createOpenAIAdapter } from "@intx/inference/providers";
33
import { normalizeNullDeltaFields } from "./null-delta-fields.js";
4-
import { isDeepSeekV4Model } from "./deepseek-v4-effort.js";
4+
import { isDeepSeekModel, isDeepSeekV4Model } from "./deepseek-v4-effort.js";
55

66
// The stock OpenAI adapter builds the request body from a fixed set of fields
77
// (max_tokens, temperature, tools, messages, response_format) and ignores
@@ -51,7 +51,7 @@ export function createOpenAICompatibleAdapter(
5151
// keeps each turn's reasoning on replay (handled by its own adapter), so only
5252
// pre-V4 DeepSeek strips it here.
5353
const m = model.toLowerCase().trim();
54-
const isDeepSeek = m.includes("deepseek");
54+
const isDeepSeek = isDeepSeekModel(m);
5555
const stripReasoning = isDeepSeek && !isDeepSeekV4Model(m);
5656
needsDeepSeekPatch = isDeepSeek;
5757
if (!hasProviderOptions && !stripReasoning) return ensureAccept(built);

‎src/subagent/agent-fleet-effort.test.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -390,15 +390,14 @@ describe("deepseek-v4 per-role effort policy (CL-10294)", () => {
390390
return worker.provider.reasoningEffort as ReasoningEffort;
391391
}
392392

393-
test("planner/reviewer max, coder cheapest, other leaves xhigh", async () => {
394-
// V4's native ladder is ["none","xhigh","max"]. coder's low clamps to the
395-
// cheapest wire rung (none, thinking off) per the operator's "simplest
396-
// implementer = none" intent; planner/reviewer resolve max. dispatch is the
397-
// primary session (not a spawned worker) and already defaults to max for
393+
test("planner/reviewer/coder max, other leaves xhigh", async () => {
394+
// V4's native ladder is ["none","xhigh","max"]. coder defaults to max
395+
// (operator-confirmed, PR #1377); planner/reviewer resolve max. dispatch is
396+
// the primary session (not a spawned worker) and already defaults to max for
398397
// V4 via defaultEffortForModel.
399398
expect(await captureEffort("planner")).toBe("max");
400399
expect(await captureEffort("reviewer")).toBe("max");
401-
expect(await captureEffort("coder")).toBe("none");
400+
expect(await captureEffort("coder")).toBe("max");
402401
expect(await captureEffort("explorer")).toBe("xhigh");
403402
expect(await captureEffort("artist")).toBe("xhigh");
404403
expect(await captureEffort("qa-lead")).toBe("xhigh");

0 commit comments

Comments
 (0)