Skip to content

Commit 455941e

Browse files
fix(deepseek): normalize mcp union tool schemas
1 parent d124c5a commit 455941e

5 files changed

Lines changed: 247 additions & 2 deletions

File tree

extensions/deepseek/index.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,51 @@ describe("deepseek provider plugin", () => {
222222
expect(replayPolicy?.validateAnthropicTurns).toBe(true);
223223
});
224224

225+
it("owns DeepSeek tool schema compatibility for MCP union schemas", async () => {
226+
const provider = await registerSingleProviderPlugin(deepseekPlugin);
227+
const mcpTool = {
228+
name: "unusual-whales__get_balance_sheet_screener",
229+
description: "",
230+
parameters: {
231+
type: "object",
232+
properties: {
233+
date: {
234+
anyOf: [{ type: "string" }, { type: "integer" }],
235+
},
236+
period: {
237+
oneOf: [{ type: "string" }, { type: "null" }],
238+
},
239+
},
240+
},
241+
execute: () => undefined,
242+
} as never;
243+
244+
const normalized = provider.normalizeToolSchemas?.({
245+
provider: "deepseek",
246+
modelId: "deepseek-v4-pro",
247+
modelApi: "openai-completions",
248+
model: deepSeekV4Model("deepseek-v4-pro"),
249+
tools: [mcpTool],
250+
} as never);
251+
252+
expect(normalized?.[0]?.parameters).toEqual({
253+
type: "object",
254+
properties: {
255+
date: { type: "string" },
256+
period: { type: "string", nullable: true },
257+
},
258+
});
259+
expect(
260+
provider.inspectToolSchemas?.({
261+
provider: "deepseek",
262+
modelId: "deepseek-v4-pro",
263+
modelApi: "openai-completions",
264+
model: deepSeekV4Model("deepseek-v4-pro"),
265+
tools: normalized ?? [],
266+
} as never),
267+
).toStrictEqual([]);
268+
});
269+
225270
it("advertises max thinking levels for DeepSeek V4 models only", async () => {
226271
const provider = await registerSingleProviderPlugin(deepseekPlugin);
227272
const resolveThinkingProfile = requireThinkingProfileResolver(provider);

extensions/deepseek/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provider-catalog-shared";
22
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
33
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
4+
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
45
import { applyDeepSeekConfig, DEEPSEEK_DEFAULT_MODEL_REF } from "./onboard.js";
56
import { buildDeepSeekProvider } from "./provider-catalog.js";
67
import { createDeepSeekV4ThinkingWrapper } from "./stream.js";
@@ -49,6 +50,7 @@ export default defineSingleProviderPluginEntry({
4950
family: "openai-compatible",
5051
dropReasoningFromHistory: false,
5152
}),
53+
...buildProviderToolCompatFamilyHooks("deepseek"),
5254
wrapStreamFn: (ctx) => createDeepSeekV4ThinkingWrapper(ctx.streamFn, ctx.thinkingLevel),
5355
resolveThinkingProfile: ({ modelId }) => resolveDeepSeekV4ThinkingProfile(modelId),
5456
isModernModelRef: ({ modelId }) => Boolean(resolveDeepSeekV4ThinkingProfile(modelId)),

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1677,7 +1677,21 @@ export async function runEmbeddedAttempt(
16771677
ownerOnlyToolAllowlist: params.ownerOnlyToolAllowlist,
16781678
warn: (message) => log.warn(message),
16791679
});
1680-
const uncompactedEffectiveTools = [...tools, ...filteredBundledTools];
1680+
const normalizedBundledTools =
1681+
filteredBundledTools.length > 0
1682+
? normalizeAgentRuntimeTools({
1683+
runtimePlan: params.runtimePlan,
1684+
tools: filteredBundledTools,
1685+
provider: params.provider,
1686+
config: params.config,
1687+
workspaceDir: effectiveWorkspace,
1688+
env: process.env,
1689+
modelId: params.modelId,
1690+
modelApi: params.model.api,
1691+
model: params.model,
1692+
})
1693+
: filteredBundledTools;
1694+
const uncompactedEffectiveTools = [...tools, ...normalizedBundledTools];
16811695
let effectiveTools = uncompactedEffectiveTools;
16821696
const catalogToolHookContext = {
16831697
agentId: sessionAgentId,

src/plugin-sdk/provider-tools.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
buildProviderToolCompatFamilyHooks,
4+
inspectDeepSeekToolSchemas,
45
findOpenAIStrictSchemaViolations,
56
inspectGeminiToolSchemas,
67
inspectOpenAIToolSchemas,
8+
normalizeDeepSeekToolSchemas,
79
normalizeGeminiToolSchemas,
810
normalizeOpenAIToolSchemas,
911
} from "./provider-tools.js";
@@ -29,6 +31,11 @@ describe("buildProviderToolCompatFamilyHooks", () => {
2931

3032
it("covers the tool compat family matrix", () => {
3133
const cases = [
34+
{
35+
family: "deepseek" as const,
36+
normalizeToolSchemas: normalizeDeepSeekToolSchemas,
37+
inspectToolSchemas: inspectDeepSeekToolSchemas,
38+
},
3239
{
3340
family: "gemini" as const,
3441
normalizeToolSchemas: normalizeGeminiToolSchemas,
@@ -49,6 +56,69 @@ describe("buildProviderToolCompatFamilyHooks", () => {
4956
}
5057
});
5158

59+
it("collapses anyOf and oneOf unions for the deepseek family", () => {
60+
const hooks = buildProviderToolCompatFamilyHooks("deepseek");
61+
const tools = [
62+
{
63+
name: "unusual-whales__get_balance_sheet_screener",
64+
description: "",
65+
parameters: {
66+
type: "object",
67+
properties: {
68+
date: {
69+
description: "Balance sheet date",
70+
anyOf: [{ type: "string" }, { type: "integer" }],
71+
},
72+
ticker: {
73+
oneOf: [{ type: "string" }, { type: "null" }],
74+
},
75+
},
76+
required: ["date"],
77+
},
78+
},
79+
] as never;
80+
81+
const normalized = hooks.normalizeToolSchemas({
82+
provider: "deepseek",
83+
modelId: "deepseek-v4-pro",
84+
modelApi: "openai-completions",
85+
model: {
86+
provider: "deepseek",
87+
api: "openai-completions",
88+
id: "deepseek-v4-pro",
89+
} as never,
90+
tools,
91+
});
92+
93+
expect(normalized[0]?.parameters).toEqual({
94+
type: "object",
95+
properties: {
96+
date: {
97+
description: "Balance sheet date",
98+
type: "string",
99+
},
100+
ticker: {
101+
type: "string",
102+
nullable: true,
103+
},
104+
},
105+
required: ["date"],
106+
});
107+
expect(
108+
hooks.inspectToolSchemas({
109+
provider: "deepseek",
110+
modelId: "deepseek-v4-pro",
111+
modelApi: "openai-completions",
112+
model: {
113+
provider: "deepseek",
114+
api: "openai-completions",
115+
id: "deepseek-v4-pro",
116+
} as never,
117+
tools: normalized,
118+
}),
119+
).toStrictEqual([]);
120+
});
121+
52122
it("normalizes parameter-free and typed-object schemas for the openai family", () => {
53123
const hooks = buildProviderToolCompatFamilyHooks("openai");
54124
const tools = [

src/plugin-sdk/provider-tools.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,13 +394,127 @@ export function inspectOpenAIToolSchemas(
394394
return [];
395395
}
396396

397-
export type ProviderToolCompatFamily = "gemini" | "openai";
397+
export const DEEPSEEK_UNSUPPORTED_SCHEMA_KEYWORDS = new Set(["anyOf", "oneOf"]);
398+
399+
function isNullSchemaVariant(schema: unknown): boolean {
400+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
401+
return false;
402+
}
403+
const record = schema as Record<string, unknown>;
404+
if (record.type === "null") {
405+
return true;
406+
}
407+
if (Array.isArray(record.type) && record.type.length === 1 && record.type[0] === "null") {
408+
return true;
409+
}
410+
if ("const" in record && record.const === null) {
411+
return true;
412+
}
413+
return Array.isArray(record.enum) && record.enum.length === 1 && record.enum[0] === null;
414+
}
415+
416+
function normalizeDeepSeekSchema(schema: unknown): unknown {
417+
if (Array.isArray(schema)) {
418+
let changed = false;
419+
const normalized = schema.map((entry) => {
420+
const next = normalizeDeepSeekSchema(entry);
421+
changed ||= next !== entry;
422+
return next;
423+
});
424+
return changed ? normalized : schema;
425+
}
426+
if (!schema || typeof schema !== "object") {
427+
return schema;
428+
}
429+
430+
const record = schema as Record<string, unknown>;
431+
const unionKey = Array.isArray(record.anyOf)
432+
? "anyOf"
433+
: Array.isArray(record.oneOf)
434+
? "oneOf"
435+
: undefined;
436+
437+
let changed = false;
438+
const normalized: Record<string, unknown> = {};
439+
for (const [key, value] of Object.entries(record)) {
440+
if (key === "anyOf" || key === "oneOf") {
441+
if (key === unionKey) {
442+
changed = true;
443+
continue;
444+
}
445+
}
446+
const next = normalizeDeepSeekSchema(value);
447+
normalized[key] = next;
448+
changed ||= next !== value;
449+
}
450+
451+
if (!unionKey) {
452+
return changed ? normalized : schema;
453+
}
454+
455+
const variants = record[unionKey] as unknown[];
456+
const normalizedVariants = variants.map((entry) => normalizeDeepSeekSchema(entry));
457+
const nonNullVariants = normalizedVariants.filter((entry) => !isNullSchemaVariant(entry));
458+
const selected = nonNullVariants[0] ?? normalizedVariants[0];
459+
if (!selected || typeof selected !== "object" || Array.isArray(selected)) {
460+
return normalized;
461+
}
462+
463+
const merged = {
464+
...(selected as Record<string, unknown>),
465+
...normalized,
466+
};
467+
if (nonNullVariants.length < normalizedVariants.length) {
468+
merged.nullable = true;
469+
}
470+
return merged;
471+
}
472+
473+
export function normalizeDeepSeekToolSchemas(
474+
ctx: ProviderNormalizeToolSchemasContext,
475+
): AnyAgentTool[] {
476+
return ctx.tools.map((tool) => {
477+
if (!tool.parameters || typeof tool.parameters !== "object") {
478+
return tool;
479+
}
480+
const parameters = normalizeDeepSeekSchema(tool.parameters);
481+
return parameters === tool.parameters
482+
? tool
483+
: {
484+
...tool,
485+
parameters: parameters as TSchema,
486+
};
487+
});
488+
}
489+
490+
export function inspectDeepSeekToolSchemas(
491+
ctx: ProviderNormalizeToolSchemasContext,
492+
): ProviderToolSchemaDiagnostic[] {
493+
return ctx.tools.flatMap((tool, toolIndex) => {
494+
const violations = findUnsupportedSchemaKeywords(
495+
tool.parameters,
496+
`${tool.name}.parameters`,
497+
DEEPSEEK_UNSUPPORTED_SCHEMA_KEYWORDS,
498+
);
499+
if (violations.length === 0) {
500+
return [];
501+
}
502+
return [{ toolName: tool.name, toolIndex, violations }];
503+
});
504+
}
505+
506+
export type ProviderToolCompatFamily = "deepseek" | "gemini" | "openai";
398507

399508
export function buildProviderToolCompatFamilyHooks(family: ProviderToolCompatFamily): {
400509
normalizeToolSchemas: (ctx: ProviderNormalizeToolSchemasContext) => AnyAgentTool[];
401510
inspectToolSchemas: (ctx: ProviderNormalizeToolSchemasContext) => ProviderToolSchemaDiagnostic[];
402511
} {
403512
switch (family) {
513+
case "deepseek":
514+
return {
515+
normalizeToolSchemas: normalizeDeepSeekToolSchemas,
516+
inspectToolSchemas: inspectDeepSeekToolSchemas,
517+
};
404518
case "gemini":
405519
return {
406520
normalizeToolSchemas: normalizeGeminiToolSchemas,

0 commit comments

Comments
 (0)