From 4285881871cb78d148189625b2909a0432bbb01e Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Wed, 29 Jul 2026 03:40:09 +0000 Subject: [PATCH 1/2] fix(anthropic): omit incompatible tool schemas --- .changeset/clean-tools-anthropic.md | 5 ++ .../bases/anthropic/anthropic.contrib.ts | 1 + .../provider/bases/anthropic/anthropic.ts | 32 ++++++- .../test/kosong/provider/composition.test.ts | 88 ++++++++++++++++++- packages/kosong/src/providers/anthropic.ts | 28 +++++- packages/kosong/test/anthropic.test.ts | 82 +++++++++++++++++ 6 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 .changeset/clean-tools-anthropic.md diff --git a/.changeset/clean-tools-anthropic.md b/.changeset/clean-tools-anthropic.md new file mode 100644 index 0000000000..3d2d4b6583 --- /dev/null +++ b/.changeset/clean-tools-anthropic.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Prevent Anthropic requests from failing when MCP tools use unsupported top-level JSON Schema combinators. diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts index 4ac022d874..57d404f327 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts @@ -37,6 +37,7 @@ registerProtocolBase({ ? undefined : { ...config.providerOptions.metadata }, hooks: composeAnthropicHooks(traits), + filterUnsupportedRootSchemas: traits.length === 0 ? undefined : false, }), }); }, diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts index 952e125cb2..d0ab2191b3 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts @@ -148,6 +148,7 @@ export interface AnthropicOptions { thinkingEffort?: ThinkingEffort | undefined; clientFactory?: (auth: ProviderRequestAuth) => Anthropic; hooks?: AnthropicHooks | undefined; + filterUnsupportedRootSchemas?: boolean; } const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; @@ -397,6 +398,28 @@ interface AnthropicToolParam extends AnthropicTool { cache_control?: { type: 'ephemeral' } | null; } +const ANTHROPIC_UNSUPPORTED_ROOT_SCHEMA_KEYWORDS = new Set([ + 'oneOf', + 'anyOf', + 'allOf', +]); +const ANTHROPIC_API_BASE_URL = 'https://api.anthropic.com'; +const ANTHROPIC_API_BASE_URL_WITH_TRAILING_SLASH = `${ANTHROPIC_API_BASE_URL}/`; + +function hasAnthropicCompatibleSchema(tool: Tool): boolean { + return !Object.keys(tool.parameters).some((key) => + ANTHROPIC_UNSUPPORTED_ROOT_SCHEMA_KEYWORDS.has(key), + ); +} + +function isOfficialAnthropicEndpoint(baseUrl: string | undefined): boolean { + return ( + baseUrl === undefined || + baseUrl === ANTHROPIC_API_BASE_URL || + baseUrl === ANTHROPIC_API_BASE_URL_WITH_TRAILING_SLASH + ); +} + function convertTool(tool: Tool): AnthropicToolParam { return { name: tool.name, @@ -818,6 +841,7 @@ export class AnthropicChatProvider implements ChatProvider { private readonly _thinkingEffort: ThinkingEffort | undefined; private readonly _explicitMaxTokens: boolean; private readonly _hooks: AnthropicHooks | undefined; + private readonly _filterUnsupportedRootSchemas: boolean; constructor(options: AnthropicOptions) { this._model = options.model; @@ -828,6 +852,8 @@ export class AnthropicChatProvider implements ChatProvider { this._betaApi = options.betaApi ?? false; this._thinkingEffort = options.thinkingEffort; this._hooks = options.hooks; + this._filterUnsupportedRootSchemas = + options.filterUnsupportedRootSchemas ?? isOfficialAnthropicEndpoint(options.baseUrl); this._apiKey = options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; this._baseUrl = options.baseUrl; @@ -983,7 +1009,11 @@ export class AnthropicChatProvider implements ChatProvider { extraHeaders['anthropic-beta'] = betas.join(','); } - const anthropicTools: AnthropicToolParam[] = tools.map((t) => convertTool(t)); + const requestTools = this._filterUnsupportedRootSchemas + ? tools.filter(hasAnthropicCompatibleSchema) + : tools; + const anthropicTools: AnthropicToolParam[] = requestTools + .map((tool) => convertTool(tool)); if (anthropicTools.length > 0) { const lastTool = anthropicTools.at(-1); if (lastTool !== undefined) { diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index c7ebdb9feb..6f4926f990 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -50,6 +50,7 @@ import { isRetryableGenerateError, } from '#/kosong/contract/errors'; import type { Message } from '#/kosong/contract/message'; +import type { Tool } from '#/kosong/contract/tool'; import type { ChatProvider, GenerateOptions, @@ -603,6 +604,7 @@ async function captureOpenAIBody( async function captureAnthropicBody( provider: ChatProvider, options?: GenerateOptions, + tools: Tool[] = [], ): Promise<{ readonly params: Record; readonly requestOptions: Record | undefined; @@ -626,7 +628,7 @@ async function captureAnthropicBody( }); client.messages.create = create('standard'); client.beta.messages.create = create('beta'); - await drain(await provider.generate('', [], PROBE_HISTORY, options)); + await drain(await provider.generate('', tools, PROBE_HISTORY, options)); if (capturedParams === undefined || via === undefined) { throw new Error('expected messages.create to be called'); } @@ -803,6 +805,90 @@ describe('quota-exhausted classification through the real composition (behavior }); }); +describe('Anthropic tool schema compatibility', () => { + it('omits tools with unsupported root combinators and keeps compatible tools', async () => { + const provider = new AnthropicChatProvider({ + model: 'claude-opus-4-6', + apiKey: 'sk-probe', + stream: false, + }); + const tools: Tool[] = [ + { + name: 'one_of_tool', + description: 'Uses oneOf.', + parameters: { oneOf: [{ required: ['resource_id'] }] }, + }, + { + name: 'any_of_tool', + description: 'Uses anyOf.', + parameters: { anyOf: [{ required: ['resource_id'] }] }, + }, + { + name: 'all_of_tool', + description: 'Uses allOf.', + parameters: { allOf: [{ required: ['resource_id'] }] }, + }, + { + name: 'compatible_tool', + description: 'Uses a regular object schema.', + parameters: { + type: 'object', + properties: { resource_id: { type: 'string' } }, + }, + }, + ]; + const originalTools = structuredClone(tools); + + const { params } = await captureAnthropicBody(provider, undefined, tools); + const wireTools = params['tools'] as Array<{ name: string }>; + + expect(wireTools.map((tool) => tool.name)).toEqual(['compatible_tool']); + expect(tools).toEqual(originalTools); + + const kimiProvider = registry.createChatProvider({ + protocol: 'anthropic', + providerType: 'kimi', + modelName: 'kimi-for-coding', + apiKey: 'sk-probe', + }); + const { params: kimiParams } = await captureAnthropicBody( + kimiProvider, + undefined, + tools, + ); + const kimiTools = kimiParams['tools'] as Array<{ name: string }>; + expect(kimiTools.map((tool) => tool.name)).toEqual(tools.map((tool) => tool.name)); + }); + + it.each(['https://api.anthropic.com', 'https://api.anthropic.com/'])( + 'filters unsupported tools for explicit official endpoint %s', + async (baseUrl) => { + const provider = new AnthropicChatProvider({ + model: 'claude-opus-4-6', + apiKey: 'sk-probe', + baseUrl, + stream: false, + }); + const tools: Tool[] = [ + { + name: 'incompatible_tool', + description: 'Uses oneOf.', + parameters: { oneOf: [{ required: ['resource_id'] }] }, + }, + { + name: 'compatible_tool', + description: 'Uses a regular object schema.', + parameters: { type: 'object' }, + }, + ]; + + const { params } = await captureAnthropicBody(provider, undefined, tools); + const wireTools = params['tools'] as Array<{ name: string }>; + expect(wireTools.map((tool) => tool.name)).toEqual(['compatible_tool']); + }, + ); +}); + describe('reasoning dialect (behavior probes)', () => { it('yields think parts from the `reasoning` wire field', async () => { const provider = registry.createChatProvider({ diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 5af85106e2..fa36b5a892 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -461,6 +461,28 @@ interface AnthropicToolParam extends AnthropicTool { cache_control?: { type: 'ephemeral' } | null; } +const ANTHROPIC_UNSUPPORTED_ROOT_SCHEMA_KEYWORDS = new Set([ + 'oneOf', + 'anyOf', + 'allOf', +]); +const ANTHROPIC_API_BASE_URL = 'https://api.anthropic.com'; +const ANTHROPIC_API_BASE_URL_WITH_TRAILING_SLASH = `${ANTHROPIC_API_BASE_URL}/`; + +function hasAnthropicCompatibleSchema(tool: Tool): boolean { + return !Object.keys(tool.parameters).some((key) => + ANTHROPIC_UNSUPPORTED_ROOT_SCHEMA_KEYWORDS.has(key), + ); +} + +function isOfficialAnthropicEndpoint(baseUrl: string | undefined): boolean { + return ( + baseUrl === undefined || + baseUrl === ANTHROPIC_API_BASE_URL || + baseUrl === ANTHROPIC_API_BASE_URL_WITH_TRAILING_SLASH + ); +} + function convertTool(tool: Tool): AnthropicToolParam { return { name: tool.name, @@ -1076,7 +1098,11 @@ export class AnthropicChatProvider implements ChatProvider { } // Convert tools - const anthropicTools: AnthropicToolParam[] = tools.map((t) => convertTool(t)); + const requestTools = isOfficialAnthropicEndpoint(this._baseUrl) + ? tools.filter(hasAnthropicCompatibleSchema) + : tools; + const anthropicTools: AnthropicToolParam[] = requestTools + .map((tool) => convertTool(tool)); if (anthropicTools.length > 0) { const lastTool = anthropicTools.at(-1); if (lastTool !== undefined) { diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index 6b079a75a6..9c89dceaf7 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -1074,6 +1074,88 @@ describe('AnthropicChatProvider', () => { ]); }); + it('omits tools with unsupported root combinators and keeps compatible tools', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Process resource' }], toolCalls: [] }, + ]; + const tools: Tool[] = [ + { + name: 'one_of_tool', + description: 'Uses oneOf.', + parameters: { oneOf: [{ required: ['resource_id'] }] }, + }, + { + name: 'any_of_tool', + description: 'Uses anyOf.', + parameters: { anyOf: [{ required: ['resource_id'] }] }, + }, + { + name: 'all_of_tool', + description: 'Uses allOf.', + parameters: { allOf: [{ required: ['resource_id'] }] }, + }, + { + name: 'compatible_tool', + description: 'Uses a regular object schema.', + parameters: { + type: 'object', + properties: { resource_id: { type: 'string' } }, + }, + }, + ]; + const originalTools = structuredClone(tools); + + const body = await captureRequestBody(provider, '', tools, history); + const wireTools = body['tools'] as Array<{ name: string }>; + + expect(wireTools.map((tool) => tool.name)).toEqual(['compatible_tool']); + expect(tools).toEqual(originalTools); + + const customProvider = new AnthropicChatProvider({ + model: 'compatible-model', + apiKey: 'test-key', + baseUrl: 'https://compatible.example.test', + defaultMaxTokens: 1024, + stream: false, + }); + const customBody = await captureRequestBody(customProvider, '', tools, history); + const customTools = customBody['tools'] as Array<{ name: string }>; + expect(customTools.map((tool) => tool.name)).toEqual(tools.map((tool) => tool.name)); + }); + + it.each(['https://api.anthropic.com', 'https://api.anthropic.com/'])( + 'filters unsupported tools for explicit official endpoint %s', + async (baseUrl) => { + const provider = new AnthropicChatProvider({ + model: 'claude-opus-4-6', + apiKey: 'test-key', + baseUrl, + defaultMaxTokens: 1024, + stream: false, + }); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Process resource' }], toolCalls: [] }, + ]; + const tools: Tool[] = [ + { + name: 'incompatible_tool', + description: 'Uses oneOf.', + parameters: { oneOf: [{ required: ['resource_id'] }] }, + }, + { + name: 'compatible_tool', + description: 'Uses a regular object schema.', + parameters: { type: 'object' }, + }, + ]; + + const body = await captureRequestBody(provider, '', tools, history); + const wireTools = body['tools'] as Array<{ name: string }>; + expect(wireTools.map((tool) => tool.name)).toEqual(['compatible_tool']); + }, + ); + it('tool call and tool result (Anthropic wire format)', async () => { const provider = createProvider(); const toolCall: ToolCall = { From cd39572bc83e8c7fbaa7a25f904ec16203276af3 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 30 Jul 2026 19:08:57 +0000 Subject: [PATCH 2/2] fix(anthropic): keep registry filtering enabled --- .../bases/anthropic/anthropic.contrib.ts | 2 +- .../test/kosong/provider/composition.test.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts index 57d404f327..6bd5d75d9a 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts @@ -37,7 +37,7 @@ registerProtocolBase({ ? undefined : { ...config.providerOptions.metadata }, hooks: composeAnthropicHooks(traits), - filterUnsupportedRootSchemas: traits.length === 0 ? undefined : false, + filterUnsupportedRootSchemas: config.providerType === undefined ? undefined : false, }), }); }, diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index 6f4926f990..5248015525 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -860,6 +860,30 @@ describe('Anthropic tool schema compatibility', () => { expect(kimiTools.map((tool) => tool.name)).toEqual(tools.map((tool) => tool.name)); }); + it('filters unsupported tools for registry-created plain anthropic providers', async () => { + const provider = registry.createChatProvider({ + protocol: 'anthropic', + modelName: 'claude-opus-4-6', + apiKey: 'sk-probe', + }); + const tools: Tool[] = [ + { + name: 'incompatible_tool', + description: 'Uses oneOf.', + parameters: { oneOf: [{ required: ['resource_id'] }] }, + }, + { + name: 'compatible_tool', + description: 'Uses a regular object schema.', + parameters: { type: 'object' }, + }, + ]; + + const { params } = await captureAnthropicBody(provider, undefined, tools); + const wireTools = params['tools'] as Array<{ name: string }>; + expect(wireTools.map((tool) => tool.name)).toEqual(['compatible_tool']); + }); + it.each(['https://api.anthropic.com', 'https://api.anthropic.com/'])( 'filters unsupported tools for explicit official endpoint %s', async (baseUrl) => {