mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-15 02:07:31 +01:00
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:
co-authored by
Copilot
parent
971e1f6413
commit
d4bfecdaa8
@@ -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[] = [
|
||||
|
||||
Reference in New Issue
Block a user