chat: normalize tool call IDs across model switches (#325813)

* chat: normalize tool call IDs across model switches

Kimi requires function-indexed tool call IDs, while Anthropic accepts only a restricted character set. Normalize outbound request copies for each provider without mutating stored conversation history.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chat: avoid collisions in normalized tool call IDs

Reserve valid IDs before allocating sanitized Anthropic IDs, then reuse the request-scoped mapping for matching tool results.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Giuseppe Cianci
2026-07-14 17:11:45 +00:00
committed by GitHub
co-authored by Copilot
parent 971e1f6413
commit d4bfecdaa8
4 changed files with 314 additions and 4 deletions
@@ -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<string, string>();
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;
}
@@ -37,6 +37,58 @@ export function buildToolInputSchema(schema: Record<string, unknown> | 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<string>();
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<string, string>();
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<string, string>();
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) {
@@ -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<typeof createMockServices>;
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', () => {
@@ -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[] = [