Skip to content

Commit 35b2346

Browse files
Merge pull request #1374 from corbitsdev/cl-10243-deepseek-v4-per-family-adapter
feat(provider): add deepseek-v4 per-family adapter
2 parents 3490291 + 0273d2e commit 35b2346

11 files changed

Lines changed: 406 additions & 256 deletions

‎src/config/index.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ import {
5959
GROK_SESSION_ID_OPTION,
6060
} from "../provider/grok-responses.js";
6161
import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js";
62+
import { DEEPSEEK_V4_PROVIDER } from "../provider/deepseek-v4-adapter.js";
63+
import { isDeepSeekV4Model } from "../provider/deepseek-v4-effort.js";
6264
import { isOllamaProviderId, ollamaOpenAIBaseURL } from "../provider/ollama.js";
6365
import { selectableGoModelIds } from "../provider/model-catalogs.js";
6466
import {
@@ -265,8 +267,9 @@ export function resolveMcpServers(
265267
}
266268

267269
// Build the OpenAI-compatible InferenceSource the runtime consumes. `id` is the
268-
// user-facing name for this source (e.g. "zen"); `provider` is always
269-
// "openai-compatible" so the inference registry routes it to the right adapter.
270+
// user-facing name for this source (e.g. "zen"); `provider` routes to the
271+
// openai-compatible adapter, or the deepseek-v4 per-family adapter for V4
272+
// models, so the inference registry picks the right adapter.
270273
export function buildOpenAISource(fields: {
271274
id: string;
272275
baseURL: string;
@@ -287,7 +290,9 @@ export function buildOpenAISource(fields: {
287290
registerSourceSecret(fields.id, fields.apiKey);
288291
return {
289292
id: fields.id,
290-
provider: "openai-compatible",
293+
provider: isDeepSeekV4Model(fields.model)
294+
? DEEPSEEK_V4_PROVIDER
295+
: "openai-compatible",
291296
baseURL: isOllamaProviderId(fields.id)
292297
? ollamaOpenAIBaseURL(fields.baseURL)
293298
: normalizeOpenAICompatibleBaseURL(fields.baseURL),

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,3 +794,33 @@ describe("session source bundles", () => {
794794
expect(bundle.defaultSource).toBe("openai");
795795
});
796796
});
797+
798+
describe("deepseek-v4 provider stamping (PR A)", () => {
799+
function v4SourceFor(model: string) {
800+
return buildInferenceSourceForRef(
801+
{ provider: "ds", model },
802+
{
803+
sessionId: "s1",
804+
catalog: [
805+
{
806+
name: "ds",
807+
baseURL: "https://ds.example/v1",
808+
apiKey: "k",
809+
models: [model],
810+
},
811+
],
812+
},
813+
undefined,
814+
);
815+
}
816+
817+
test("a deepseek-v4-* model stamps provider 'deepseek-v4'", () => {
818+
const source = v4SourceFor("deepseek-v4-pro");
819+
expect(source?.provider).toBe("deepseek-v4");
820+
});
821+
822+
test("non-V4 custom models stay provider 'openai-compatible'", () => {
823+
const source = v4SourceFor("gpt-5");
824+
expect(source?.provider).toBe("openai-compatible");
825+
});
826+
});
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { describe, test, expect } from "bun:test";
2+
import type { ConversationTurn, InferenceOptions } from "@intx/types/runtime";
3+
import {
4+
createDeepSeekV4Adapter,
5+
DEEPSEEK_V4_PROVIDER,
6+
} from "./deepseek-v4-adapter.js";
7+
import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js";
8+
9+
const source = {
10+
id: "test",
11+
provider: DEEPSEEK_V4_PROVIDER,
12+
baseURL: "https://example.test",
13+
apiKey: "sk-test",
14+
model: "deepseek-v4-pro",
15+
} as unknown as Parameters<typeof createDeepSeekV4Adapter>[0];
16+
17+
const messages: ConversationTurn[] = [
18+
{
19+
role: "user",
20+
content: [{ type: "text", text: "hi" }],
21+
} as unknown as ConversationTurn,
22+
];
23+
24+
function bodyFor(options: InferenceOptions): Record<string, unknown> {
25+
const adapter = createDeepSeekV4Adapter(source);
26+
const built = adapter.buildRequest(messages, "deepseek-v4-pro", options);
27+
return JSON.parse(built.body) as Record<string, unknown>;
28+
}
29+
30+
describe("deepseek-v4 adapter effort wiring", () => {
31+
test.each(["xhigh", "max"] as const)(
32+
"wires V4 effort %s through raw and turns thinking on",
33+
(effort) => {
34+
const body = bodyFor({
35+
providerOptions: { reasoning_effort: effort },
36+
} as InferenceOptions);
37+
expect(body["reasoning_effort"]).toBe(effort);
38+
expect(body["chat_template_kwargs"]).toEqual({ thinking: true });
39+
},
40+
);
41+
42+
test("none → chat_template_kwargs.thinking false and NO reasoning_effort", () => {
43+
const body = bodyFor({
44+
providerOptions: { reasoning_effort: "none" },
45+
} as InferenceOptions);
46+
expect(body["chat_template_kwargs"]).toEqual({ thinking: false });
47+
expect("reasoning_effort" in body).toBe(false);
48+
});
49+
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);
56+
});
57+
58+
test("unset effort does not inject empty chat_template_kwargs", () => {
59+
const body = bodyFor({} as InferenceOptions);
60+
expect("chat_template_kwargs" in body).toBe(false);
61+
expect(body["reasoning_effort"]).toBeUndefined();
62+
});
63+
});
64+
65+
describe("deepseek-v4 adapter request settings", () => {
66+
test("stream_options include_usage is true", () => {
67+
const body = bodyFor({} as InferenceOptions);
68+
expect(body["stream_options"]).toEqual({ include_usage: true });
69+
});
70+
71+
test("top_p 0.95 and temperature 1.0 defaulted when absent", () => {
72+
const body = bodyFor({} as InferenceOptions);
73+
expect(body["top_p"]).toBe(0.95);
74+
expect(body["temperature"]).toBe(1.0);
75+
});
76+
77+
test("top_p and temperature preserved when providerOptions set them", () => {
78+
const body = bodyFor({
79+
providerOptions: { temperature: 0.7, top_p: 0.9 },
80+
} as InferenceOptions);
81+
expect(body["top_p"]).toBe(0.9);
82+
expect(body["temperature"]).toBe(0.7);
83+
});
84+
85+
test("provider-set stream_options is preserved (absent-only)", () => {
86+
const body = bodyFor({
87+
providerOptions: { stream_options: { include_usage: false } },
88+
} as InferenceOptions);
89+
expect(body["stream_options"]).toEqual({ include_usage: false });
90+
});
91+
92+
test("chat_template_kwargs merge preserves existing keys", () => {
93+
const body = bodyFor({
94+
providerOptions: {
95+
reasoning_effort: "xhigh",
96+
chat_template_kwargs: { some: "existing" },
97+
},
98+
} as InferenceOptions);
99+
expect(body["chat_template_kwargs"]).toEqual({
100+
thinking: true,
101+
some: "existing",
102+
});
103+
});
104+
105+
test("provider-set thinking is preserved (absent-only thinking)", () => {
106+
const noneThenTrue = bodyFor({
107+
providerOptions: {
108+
reasoning_effort: "none",
109+
chat_template_kwargs: { thinking: true },
110+
},
111+
} as InferenceOptions);
112+
expect(noneThenTrue["chat_template_kwargs"]).toEqual({ thinking: true });
113+
114+
const xhighThenFalse = bodyFor({
115+
providerOptions: {
116+
reasoning_effort: "xhigh",
117+
chat_template_kwargs: { thinking: false },
118+
},
119+
} as InferenceOptions);
120+
expect(xhighThenFalse["chat_template_kwargs"]).toEqual({ thinking: false });
121+
});
122+
});
123+
124+
describe("deepseek-v4 adapter inherits base quirks", () => {
125+
const withThinking: ConversationTurn[] = [
126+
{ role: "user", content: [{ type: "text", text: "hi" }] },
127+
{
128+
role: "assistant",
129+
model: "deepseek-v4",
130+
content: [
131+
{ type: "thinking", thinking: "ponder" },
132+
{ type: "text", text: "hello" },
133+
],
134+
},
135+
{ role: "user", content: [{ type: "text", text: "again" }] },
136+
] as unknown as ConversationTurn[];
137+
138+
test("keeps reasoning_content on replayed assistant turns (inherited base)", () => {
139+
const adapter = createDeepSeekV4Adapter(source);
140+
const built = adapter.buildRequest(
141+
withThinking,
142+
"deepseek-v4",
143+
{} as InferenceOptions,
144+
);
145+
const assistant = (
146+
JSON.parse(built.body) as { messages: Record<string, unknown>[] }
147+
).messages.find((m) => m["role"] === "assistant");
148+
expect(assistant?.["reasoning_content"]).toBe("ponder");
149+
});
150+
151+
test("vendor-qualified V4 id keeps reasoning_content on replays (org/ prefix)", () => {
152+
const adapter = createDeepSeekV4Adapter(source);
153+
const built = adapter.buildRequest(
154+
withThinking,
155+
"deepseek-ai/DeepSeek-V4-Flash-0731",
156+
{
157+
providerOptions: { reasoning_effort: "xhigh" },
158+
} as InferenceOptions,
159+
);
160+
const body = JSON.parse(built.body) as {
161+
messages: Record<string, unknown>[];
162+
reasoning_effort?: unknown;
163+
};
164+
const assistant = body.messages.find((m) => m["role"] === "assistant");
165+
expect(assistant?.["reasoning_content"]).toBe("ponder");
166+
expect(body.reasoning_effort).toBe("xhigh");
167+
});
168+
169+
test("non-V4 select a generic body build (base short-circuits, no V4 fields)", () => {
170+
const adapter = createDeepSeekV4Adapter({
171+
...source,
172+
model: "gpt-5",
173+
} as typeof source);
174+
const built = adapter.buildRequest(
175+
messages,
176+
"gpt-5",
177+
{} as InferenceOptions,
178+
);
179+
const body = JSON.parse(built.body) as Record<string, unknown>;
180+
expect("top_p" in body).toBe(false);
181+
expect("temperature" in body).toBe(false);
182+
expect("stream_options" in body).toBe(false);
183+
expect("chat_template_kwargs" in body).toBe(false);
184+
expect(body["model"]).toBe("gpt-5");
185+
});
186+
187+
test("keeps reasoning_content for non-DeepSeek models", () => {
188+
const adapter = createDeepSeekV4Adapter({
189+
...source,
190+
model: "kimi-k2",
191+
} as typeof source);
192+
const built = adapter.buildRequest(
193+
withThinking,
194+
"kimi-k2",
195+
{} as InferenceOptions,
196+
);
197+
const assistant = (
198+
JSON.parse(built.body) as { messages: Record<string, unknown>[] }
199+
).messages.find((m) => m["role"] === "assistant");
200+
expect(assistant?.["reasoning_content"]).toBe("ponder");
201+
});
202+
});
203+
204+
describe("deepseek-v4 adapter byte-identical to base for non-V4", () => {
205+
test("non-V4 body matches the patched generic base byte-for-byte", () => {
206+
const baseAdapter = createOpenAICompatibleAdapter({
207+
...source,
208+
model: "gpt-5",
209+
} as typeof source);
210+
const baseBuilt = baseAdapter.buildRequest(messages, "gpt-5", {
211+
providerOptions: { reasoning_effort: "none" },
212+
} as InferenceOptions);
213+
214+
const adapter = createDeepSeekV4Adapter({
215+
...source,
216+
model: "gpt-5",
217+
} as typeof source);
218+
const built = adapter.buildRequest(messages, "gpt-5", {
219+
providerOptions: { reasoning_effort: "none" },
220+
} as InferenceOptions);
221+
222+
const baseBody = JSON.parse(baseBuilt.body) as Record<string, unknown>;
223+
const body = JSON.parse(built.body) as Record<string, unknown>;
224+
expect(body).toEqual(baseBody);
225+
});
226+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { type ProviderAdapter } from "@intx/inference";
2+
import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js";
3+
import {
4+
DEEPSEEK_V4_MODEL_CARD,
5+
isDeepSeekV4Model,
6+
mapV4Effort,
7+
V4_STREAM_OPTIONS,
8+
} from "./deepseek-v4-effort.js";
9+
10+
export const DEEPSEEK_V4_PROVIDER = "deepseek-v4";
11+
12+
type AdapterSource = Parameters<typeof createOpenAICompatibleAdapter>[0];
13+
14+
// Wraps the patched openai-compatible adapter so V4 requests inherit the
15+
// 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+
export function createDeepSeekV4Adapter(
18+
source: AdapterSource,
19+
): ProviderAdapter {
20+
const base = createOpenAICompatibleAdapter(source);
21+
22+
const buildRequest: ProviderAdapter["buildRequest"] = (
23+
messages,
24+
model,
25+
options,
26+
) => {
27+
const req = base.buildRequest(messages, model, options);
28+
if (!isDeepSeekV4Model(model)) return req;
29+
const body = JSON.parse(req.body) as Record<string, unknown>;
30+
31+
// 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.
34+
if ("reasoning_effort" in body) {
35+
const effort = body["reasoning_effort"] as Parameters<
36+
typeof mapV4Effort
37+
>[0];
38+
const wire = mapV4Effort(effort);
39+
if (wire === null) {
40+
delete body["reasoning_effort"];
41+
mergeThinking(body, false);
42+
} else if (wire !== undefined) {
43+
body["reasoning_effort"] = wire;
44+
mergeThinking(body, true);
45+
}
46+
}
47+
48+
if (body["top_p"] === undefined)
49+
body["top_p"] = DEEPSEEK_V4_MODEL_CARD.topP;
50+
if (body["temperature"] === undefined)
51+
body["temperature"] = DEEPSEEK_V4_MODEL_CARD.temperature;
52+
body["stream_options"] ??= V4_STREAM_OPTIONS.stream_options;
53+
54+
return { ...req, body: JSON.stringify(body) };
55+
};
56+
57+
return { ...base, buildRequest };
58+
}
59+
60+
// chat_template_kwargs clone with an absent-only thinking toggle (never mutate
61+
// the caller's providerOptions; provider-set thinking wins).
62+
function mergeThinking(body: Record<string, unknown>, thinking: boolean): void {
63+
const existing =
64+
body["chat_template_kwargs"] !== null &&
65+
typeof body["chat_template_kwargs"] === "object"
66+
? (body["chat_template_kwargs"] as Record<string, unknown>)
67+
: {};
68+
const merged = { ...existing };
69+
merged.thinking ??= thinking;
70+
Object.assign(body, { chat_template_kwargs: merged });
71+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@ describe("isDeepSeekV4Model", () => {
1313
"deepseek-v4",
1414
"deepseek-v4-pro",
1515
"deepseek-v4-flash",
16+
"deepseek-v4.1-flash",
1617
"deepseek-v4-flash-vision-exp",
18+
"deepseek-ai/DeepSeek-V4-Flash-0731",
19+
"deepseek-ai/deepseek-v4-flash",
1720
])("true for V4 id %s", (model) => {
1821
expect(isDeepSeekV4Model(model)).toBe(true);
1922
});
2023
test.each([
2124
"deepseek-v3",
2225
"deepseek-r1",
26+
"deepseek-coder",
27+
"deepseek-chat",
2328
"gpt-5",
2429
"kimi-k2",
2530
"claude-sonnet-4.5",

0 commit comments

Comments
 (0)