diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index e58229a528f..7cb34e9f070 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -37,6 +37,43 @@ import { createMessagesRequestBody, processResponseFromMessagesEndpoint } from ' import { createResponsesRequestBody, getResponsesApiCompactionThreshold, processResponseFromChatEndpoint } from './responsesApi'; import { filterHistoryImages } from './imageLimits'; +/** + * Rewrites tool call IDs into Kimi's native function-indexed format while preserving tool result pairings. + */ +function normalizeKimiToolCallIds(messages: CAPIChatMessage[]): CAPIChatMessage[] { + let nextIndex = 0; + const mappedToolCallIds = new Map(); + + return messages.map(message => { + if (message.role === OpenAI.ChatRole.Assistant && message.tool_calls) { + const toolCalls = message.tool_calls.map(toolCall => { + const toolName = toolCall.function.name; + if (!toolName) { + return toolCall; + } + + const id = `functions.${toolName}:${nextIndex++}`; + if (toolCall.id) { + mappedToolCallIds.set(toolCall.id, id); + } + return { ...toolCall, id }; + }); + return { ...message, tool_calls: toolCalls }; + } + + if (message.role === OpenAI.ChatRole.Tool) { + if (message.tool_call_id) { + const toolCallId = mappedToolCallIds.get(message.tool_call_id); + if (toolCallId) { + return { ...message, tool_call_id: toolCallId }; + } + } + } + + return message; + }); +} + /** * The default processor for the stream format from CAPI */ @@ -416,6 +453,9 @@ export class ChatEndpoint implements IChatEndpoint { // Force temperature and top_p for Kimi models regardless of what the client would otherwise send (per Moonshot recommendations). Temperature 0 strongly increases chances of looping. if (isKimiFamily(this)) { + if (body.messages) { + body.messages = normalizeKimiToolCallIds(body.messages); + } body.temperature = 1; body.top_p = 0.95; } diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index cf5e93e79fa..15ea1fefb6a 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -37,6 +37,58 @@ export function buildToolInputSchema(schema: Record | undefined return { type: 'object', properties: {}, ...rest }; } +/** + * Anthropic only accepts ASCII letters, digits, underscores, and hyphens in tool call IDs. + */ +function sanitizeToolCallId(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/gu, '_'); +} + +/** + * Allocates Anthropic-compatible tool call IDs while preserving call/result pairing. + */ +function createAnthropicToolCallIdMapper(messages: readonly Raw.ChatMessage[]): (id: string) => string { + const validIdPattern = /^[a-zA-Z0-9_-]+$/u; + const usedIds = new Set(); + + for (const message of messages) { + if (message.role === Raw.ChatRole.Assistant) { + for (const toolCall of message.toolCalls ?? []) { + if (validIdPattern.test(toolCall.id)) { + usedIds.add(toolCall.id); + } + } + } else if (message.role === Raw.ChatRole.Tool && validIdPattern.test(message.toolCallId)) { + usedIds.add(message.toolCallId); + } + } + + const mappedIds = new Map(); + return id => { + const existingId = mappedIds.get(id); + if (existingId !== undefined) { + return existingId; + } + + if (validIdPattern.test(id)) { + mappedIds.set(id, id); + usedIds.add(id); + return id; + } + + const baseId = sanitizeToolCallId(id) || 'tool_call'; + let mappedId = baseId; + let suffix = 1; + while (usedIds.has(mappedId)) { + mappedId = `${baseId}_${suffix++}`; + } + + mappedIds.set(id, mappedId); + usedIds.add(mappedId); + return mappedId; + }; +} + /** IP Code Citation annotation from Messages API copilot_annotations */ interface AnthropicIPCodeCitation { id: number; @@ -263,6 +315,7 @@ export function rawMessagesToMessagesAPI(messages: readonly Raw.ChatMessage[], v const unmergedMessages: MessageParam[] = []; const systemBlocks: TextBlockParam[] = []; const toolCallIdToName = new Map(); + const mapToolCallId = createAnthropicToolCallIdMapper(messages); for (const message of messages) { switch (message.role) { @@ -292,7 +345,7 @@ export function rawMessagesToMessagesAPI(messages: readonly Raw.ChatMessage[], v } content.push({ type: 'tool_use', - id: toolCall.id, + id: mapToolCallId(toolCall.id), name: toolCall.function.name, input: parsedInput, }); @@ -338,7 +391,7 @@ export function rawMessagesToMessagesAPI(messages: readonly Raw.ChatMessage[], v const toolResultBlock: ToolResultBlockParam = { type: 'tool_result', - tool_use_id: message.toolCallId, + tool_use_id: mapToolCallId(message.toolCallId), content: validContent.length > 0 ? validContent : undefined, }; if (hasCacheControl) { diff --git a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts index 905d5bbb714..f73f49e452d 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Raw } from '@vscode/prompt-tsx'; +import { OpenAI, Raw } from '@vscode/prompt-tsx'; import { beforeEach, describe, expect, it } from 'vitest'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { IAuthenticationService } from '../../../authentication/common/authentication'; @@ -19,6 +19,7 @@ import { IEnvService } from '../../../env/common/envService'; import { ILogService } from '../../../log/common/logService'; import { IFetcherService } from '../../../networking/common/fetcherService'; import { ICreateEndpointBodyOptions } from '../../../networking/common/networking'; +import { CAPIChatMessage } from '../../../networking/common/openai'; import { IChatWebSocketManager } from '../../../networking/node/chatWebSocketManager'; import { NullExperimentationService } from '../../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../../telemetry/common/telemetry'; @@ -454,7 +455,7 @@ describe('ChatEndpoint - Image Count Validation', () => { }); }); -describe('ChatEndpoint - Kimi temperature and top_p override', () => { +describe('ChatEndpoint - Kimi CAPI customization', () => { let mockServices: ReturnType; beforeEach(() => { @@ -479,6 +480,50 @@ describe('ChatEndpoint - Kimi temperature and top_p override', () => { content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] }); + const createAssistantToolCallMessage = (...toolCalls: { id: string; name: string }[]): Raw.ChatMessage => ({ + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: toolCalls.map(toolCall => ({ + id: toolCall.id, + function: { name: toolCall.name, arguments: '{}' }, + type: 'function' + })) + }); + + const createToolResultMessage = (toolCallId: string): Raw.ChatMessage => ({ + role: Raw.ChatRole.Tool, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'result' }], + toolCallId + }); + + const createToolHistory = (): Raw.ChatMessage[] => [ + createAssistantToolCallMessage( + { id: 'toolu_read', name: 'read_file' }, + { id: 'call_edit', name: 'replace_string_in_file' } + ), + createToolResultMessage('toolu_read'), + createToolResultMessage('call_edit'), + createAssistantToolCallMessage({ id: 'toolu_test', name: 'run_in_terminal' }), + createToolResultMessage('toolu_test'), + createToolResultMessage('unmatched_tool_call') + ]; + + const getToolCallIds = (messages: CAPIChatMessage[]) => messages.map(message => { + if (message.role === OpenAI.ChatRole.Assistant) { + return { + role: message.role, + toolCallIds: message.tool_calls?.map(toolCall => toolCall.id) + }; + } + if (message.role === OpenAI.ChatRole.Tool) { + return { + role: message.role, + toolCallId: message.tool_call_id + }; + } + return { role: message.role }; + }); + const createOptionsWithPostOptions = (): ICreateEndpointBodyOptions => ({ ...createTestOptions([createTextMessage()]), postOptions: { temperature: 0, top_p: 1 } @@ -491,12 +536,56 @@ describe('ChatEndpoint - Kimi temperature and top_p override', () => { expect(body.top_p).toBe(0.95); }); + it.each(['kimi-k2.6', 'kimi-k2.7-code'])('should normalize tool call IDs for %s', family => { + const history = createToolHistory(); + const endpoint = createEndpoint(createNonAnthropicModelMetadata(family)); + const body = endpoint.createRequestBody(createTestOptions(history)); + + expect({ + body: getToolCallIds(body.messages as CAPIChatMessage[]), + history: history.map(message => message.role === Raw.ChatRole.Assistant + ? message.toolCalls?.map(toolCall => toolCall.id) + : message.role === Raw.ChatRole.Tool ? message.toolCallId : undefined) + }).toEqual({ + body: [ + { role: OpenAI.ChatRole.Assistant, toolCallIds: ['functions.read_file:0', 'functions.replace_string_in_file:1'] }, + { role: OpenAI.ChatRole.Tool, toolCallId: 'functions.read_file:0' }, + { role: OpenAI.ChatRole.Tool, toolCallId: 'functions.replace_string_in_file:1' }, + { role: OpenAI.ChatRole.Assistant, toolCallIds: ['functions.run_in_terminal:2'] }, + { role: OpenAI.ChatRole.Tool, toolCallId: 'functions.run_in_terminal:2' }, + { role: OpenAI.ChatRole.Tool, toolCallId: 'unmatched_tool_call' } + ], + history: [ + ['toolu_read', 'call_edit'], + 'toolu_read', + 'call_edit', + ['toolu_test'], + 'toolu_test', + 'unmatched_tool_call' + ] + }); + }); + it('should not override temperature or top_p for non-Kimi models', () => { const endpoint = createEndpoint(createNonAnthropicModelMetadata('gpt-4o')); const body = endpoint.createRequestBody(createOptionsWithPostOptions()); expect(body.temperature).toBe(0); expect(body.top_p).toBe(1); }); + + it('should preserve tool call IDs for non-Kimi models', () => { + const endpoint = createEndpoint(createNonAnthropicModelMetadata('gpt-4o')); + const body = endpoint.createRequestBody(createTestOptions(createToolHistory())); + + expect(getToolCallIds(body.messages as CAPIChatMessage[])).toEqual([ + { role: OpenAI.ChatRole.Assistant, toolCallIds: ['toolu_read', 'call_edit'] }, + { role: OpenAI.ChatRole.Tool, toolCallId: 'toolu_read' }, + { role: OpenAI.ChatRole.Tool, toolCallId: 'call_edit' }, + { role: OpenAI.ChatRole.Assistant, toolCallIds: ['toolu_test'] }, + { role: OpenAI.ChatRole.Tool, toolCallId: 'toolu_test' }, + { role: OpenAI.ChatRole.Tool, toolCallId: 'unmatched_tool_call' } + ]); + }); }); describe('ChatEndpoint - CAPI reasoning effort', () => { diff --git a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts index fbf4854612a..90e118fd701 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts @@ -106,6 +106,134 @@ suite('rawMessagesToMessagesAPI', function () { expect(toolResult!.cache_control).toBeUndefined(); }); + test('sanitizes provider-specific tool call IDs for Anthropic', function () { + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [ + { + id: 'functions.read_file:0', + type: 'function', + function: { name: 'read_file', arguments: '{}' }, + }, + { + id: 'call_valid-1', + type: 'function', + function: { name: 'edit_file', arguments: '{}' }, + }, + ], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'functions.read_file:0', + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'contents' }], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'call_valid-1', + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'edited' }], + }, + ]; + + const result = rawMessagesToMessagesAPI(messages); + const ids: { type: 'tool_use' | 'tool_result'; id: string }[] = []; + for (const message of result.messages) { + const content = Array.isArray(message.content) ? message.content : []; + for (const block of content) { + if (block.type === 'tool_use') { + ids.push({ type: block.type, id: block.id }); + } + if (block.type === 'tool_result') { + ids.push({ type: block.type, id: block.tool_use_id }); + } + } + } + + expect({ + ids, + history: messages.map(message => message.role === Raw.ChatRole.Assistant + ? message.toolCalls?.map(toolCall => toolCall.id) + : message.role === Raw.ChatRole.Tool ? message.toolCallId : undefined) + }).toEqual({ + ids: [ + { type: 'tool_use', id: 'functions_read_file_0' }, + { type: 'tool_use', id: 'call_valid-1' }, + { type: 'tool_result', id: 'functions_read_file_0' }, + { type: 'tool_result', id: 'call_valid-1' }, + ], + history: [ + ['functions.read_file:0', 'call_valid-1'], + 'functions.read_file:0', + 'call_valid-1', + ], + }); + }); + + test('allocates unique IDs when sanitized tool call IDs collide', function () { + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [ + { + id: 'functions.read_file:0', + type: 'function', + function: { name: 'read_file', arguments: '{}' }, + }, + { + id: 'functions_read_file_0', + type: 'function', + function: { name: 'read_file', arguments: '{}' }, + }, + ], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'functions.read_file:0', + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'first result' }], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'functions_read_file_0', + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'second result' }], + }, + ]; + + const result = rawMessagesToMessagesAPI(messages); + const ids: { type: 'tool_use' | 'tool_result'; id: string }[] = []; + for (const message of result.messages) { + const content = Array.isArray(message.content) ? message.content : []; + for (const block of content) { + if (block.type === 'tool_use') { + ids.push({ type: block.type, id: block.id }); + } + if (block.type === 'tool_result') { + ids.push({ type: block.type, id: block.tool_use_id }); + } + } + } + + expect({ + ids, + history: messages.map(message => message.role === Raw.ChatRole.Assistant + ? message.toolCalls?.map(toolCall => toolCall.id) + : message.role === Raw.ChatRole.Tool ? message.toolCallId : undefined) + }).toEqual({ + ids: [ + { type: 'tool_use', id: 'functions_read_file_0_1' }, + { type: 'tool_use', id: 'functions_read_file_0' }, + { type: 'tool_result', id: 'functions_read_file_0_1' }, + { type: 'tool_result', id: 'functions_read_file_0' }, + ], + history: [ + ['functions.read_file:0', 'functions_read_file_0'], + 'functions.read_file:0', + 'functions_read_file_0', + ], + }); + }); + test('converts base64 data URL image to Anthropic base64 image source', function () { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk'; const messages: Raw.ChatMessage[] = [