Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-tools-anthropic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Prevent Anthropic requests from failing when MCP tools use unsupported top-level JSON Schema combinators.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ registerProtocolBase({
? undefined
: { ...config.providerOptions.metadata },
hooks: composeAnthropicHooks(traits),
filterUnsupportedRootSchemas: config.providerType === undefined ? undefined : false,
}),
});
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
112 changes: 111 additions & 1 deletion packages/agent-core-v2/test/kosong/provider/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -603,6 +604,7 @@ async function captureOpenAIBody(
async function captureAnthropicBody(
provider: ChatProvider,
options?: GenerateOptions,
tools: Tool[] = [],
): Promise<{
readonly params: Record<string, unknown>;
readonly requestOptions: Record<string, unknown> | undefined;
Expand All @@ -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');
}
Expand Down Expand Up @@ -803,6 +805,114 @@ 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('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) => {
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({
Expand Down
28 changes: 27 additions & 1 deletion packages/kosong/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
82 changes: 82 additions & 0 deletions packages/kosong/test/anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down