Skip to content

Commit 6f7d973

Browse files
clawsweeper[bot]TurboTheTurtleTakhoffman
authored
fix(deepseek): normalize mcp union tool schemas (#83848)
Summary: - The PR adds DeepSeek provider-owned `anyOf`/`oneOf` tool-schema normalization, normalizes late materialized bundled tools, and updates focused tests, docs, and changelog. - Reproducibility: yes. Source inspection shows current main appends materialized bundled MCP tools after prov ... aw/issues/83361 provides the concrete DeepSeek `400 Invalid schema` failure for an MCP `anyOf` tool schema. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(deepseek): normalize mcp union tool schemas Validation: - ClawSweeper review passed for head 1bbbb44. - Required merge gates passed before the squash merge. Prepared head SHA: 1bbbb44 Review: #83848 (comment) Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 8eb0a17 commit 6f7d973

10 files changed

Lines changed: 256 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
5858
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
5959
- Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys.
6060
- Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.
61+
- Providers/DeepSeek: normalize MCP tool schemas with `anyOf`/`oneOf` unions before normal and compaction requests reach DeepSeek, preventing union-shaped parameters from being rejected. (#83766) Thanks @TurboTheTurtle.
6162
- Control UI: render live tool progress from session-scoped `session.tool` Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.
6263
- Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle.
6364
- Control UI: keep the chat delete confirmation popover clamped inside the visible viewport on small screens. (#83804) Thanks @ThiagoCAltoe.

docs/plugins/sdk-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ releases.
605605
| `plugin-sdk/provider-web-search-config-contract` | Provider web-search config helpers | Narrow web-search config/credential helpers for providers that do not need plugin-enable wiring |
606606
| `plugin-sdk/provider-web-search-contract` | Provider web-search contract helpers | Narrow web-search config/credential contract helpers such as `createWebSearchProviderContractFields`, `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
607607
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/runtime helpers |
608-
| `plugin-sdk/provider-tools` | Provider tool/schema compat helpers | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and Gemini schema cleanup + diagnostics |
608+
| `plugin-sdk/provider-tools` | Provider tool/schema compat helpers | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and DeepSeek/Gemini/OpenAI schema cleanup + diagnostics |
609609
| `plugin-sdk/provider-usage` | Provider usage helpers | `fetchClaudeUsage`, `fetchGeminiUsage`, `fetchGithubCopilotUsage`, and other provider usage helpers |
610610
| `plugin-sdk/provider-stream` | Provider stream wrapper helpers | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
611611
| `plugin-sdk/provider-transport-runtime` | Provider transport helpers | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams |

docs/plugins/sdk-provider-plugins.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ API key auth, and dynamic model resolution.
369369

370370
- `openclaw/plugin-sdk/provider-model-shared` - `ProviderReplayFamily`, `buildProviderReplayFamilyHooks(...)`, and the raw replay builders (`buildOpenAICompatibleReplayPolicy`, `buildAnthropicReplayPolicyForModel`, `buildGoogleGeminiReplayPolicy`, `buildHybridAnthropicOrOpenAIReplayPolicy`). Also exports Gemini replay helpers (`sanitizeGoogleGeminiReplayHistory`, `resolveTaggedReasoningOutputMode`) and endpoint/model helpers (`resolveProviderEndpoint`, `normalizeProviderId`, `normalizeGooglePreviewModelId`).
371371
- `openclaw/plugin-sdk/provider-stream` - `ProviderStreamFamily`, `buildProviderStreamFamilyHooks(...)`, `composeProviderStreamWrappers(...)`, plus the shared OpenAI/Codex wrappers (`createOpenAIAttributionHeadersWrapper`, `createOpenAIFastModeWrapper`, `createOpenAIServiceTierWrapper`, `createOpenAIResponsesContextManagementWrapper`, `createCodexNativeWebSearchWrapper`), DeepSeek V4 OpenAI-compatible wrapper (`createDeepSeekV4OpenAICompatibleThinkingWrapper`), Anthropic Messages thinking prefill cleanup (`createAnthropicThinkingPrefillPayloadWrapper`), and shared proxy/provider wrappers (`createOpenRouterWrapper`, `createToolStreamWrapper`, `createMinimaxFastModeWrapper`).
372-
- `openclaw/plugin-sdk/provider-tools` - `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks("gemini")`, and underlying Gemini schema helpers (`normalizeGeminiToolSchemas`, `inspectGeminiToolSchemas`).
372+
- `openclaw/plugin-sdk/provider-tools` - `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks("deepseek" | "gemini" | "openai")`, and underlying provider schema helpers.
373373

374374
Some stream helpers stay provider-local on purpose. `@openclaw/anthropic-provider` keeps `wrapAnthropicProviderStream`, `resolveAnthropicBetas`, `resolveAnthropicFastMode`, `resolveAnthropicServiceTier`, and the lower-level Anthropic wrapper builders in its own public `api.ts` / `contract-api.ts` seam because they encode Claude OAuth beta handling and `context1m` gating. The xAI plugin similarly keeps native xAI Responses shaping in its own `wrapStreamFn` (`/fast` aliases, default `tool_stream`, unsupported strict-tool cleanup, xAI-specific reasoning-payload removal).
375375

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
175175
| `plugin-sdk/provider-web-search-config-contract` | Narrow web-search config/credential helpers for providers that do not need plugin-enable wiring |
176176
| `plugin-sdk/provider-web-search-contract` | Narrow web-search config/credential contract helpers such as `createWebSearchProviderContractFields`, `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
177177
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/runtime helpers |
178-
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and Gemini schema cleanup + diagnostics |
178+
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and DeepSeek/Gemini/OpenAI schema cleanup + diagnostics |
179179
| `plugin-sdk/provider-usage` | `fetchClaudeUsage` and similar |
180180
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
181181
| `plugin-sdk/provider-transport-runtime` | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams |

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/compact.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -811,7 +811,11 @@ async function compactEmbeddedPiSessionDirectOnce(
811811
senderIsOwner: params.senderIsOwner,
812812
warn: (message) => log.warn(message),
813813
});
814-
const effectiveTools = [...tools, ...filteredBundledTools];
814+
const normalizedBundledTools =
815+
filteredBundledTools.length > 0
816+
? runtimePlan.tools.normalize(filteredBundledTools, runtimePlanModelContext)
817+
: filteredBundledTools;
818+
const effectiveTools = [...tools, ...normalizedBundledTools];
815819
const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools });
816820
runtimePlan.tools.logDiagnostics(effectiveTools, runtimePlanModelContext);
817821
const machineName = await getMachineDisplayName();

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)