From 8c63e6ae6472d563c62a308a21d52999198fcceb Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 28 Apr 2026 16:55:33 -0700 Subject: [PATCH 1/4] include tool call ids --- ...to-openai-compatible-chat-messages.test.ts | 4 ++ ...vert-to-openai-compatible-chat-messages.ts | 1 + .../chat/openai-compatible-api-types.ts | 1 + .../openai-compatible-prepare-tools.test.ts | 45 +++++++++++++++++++ .../chat/openai-compatible-prepare-tools.ts | 5 ++- ...onvert-to-openrouter-chat-messages.test.ts | 1 + .../convert-to-openrouter-chat-messages.ts | 1 + .../src/openrouter-ai-sdk/chat/index.test.ts | 1 + .../src/openrouter-ai-sdk/chat/index.ts | 3 +- .../openrouter-chat-completions-input.ts | 1 + web/src/llm-api/types.ts | 12 +++++ 11 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.test.ts diff --git a/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts b/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts index a24d724990..c2ff8cc2e1 100644 --- a/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts +++ b/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts @@ -147,6 +147,7 @@ describe('tool calls', () => { { role: 'tool', content: JSON.stringify({ oof: '321rab' }), + name: 'thwomp', tool_call_id: 'quux', }, ]) @@ -196,6 +197,7 @@ describe('tool calls', () => { { role: 'tool', content: 'It is sunny today', + name: 'getWeather', tool_call_id: 'call-1', }, ]) @@ -541,11 +543,13 @@ describe('provider-specific metadata merging', () => { { role: 'tool', tool_call_id: 'call123', + name: 'calculator', content: JSON.stringify({ stepOne: 'data chunk 1' }), }, { role: 'tool', tool_call_id: 'call123', + name: 'calculator', content: JSON.stringify({ stepTwo: 'data chunk 2' }), partial: true, }, diff --git a/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts b/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts index 30a27cf6c4..465756655e 100644 --- a/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts +++ b/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts @@ -124,6 +124,7 @@ export function convertToOpenAICompatibleChatMessages( messages.push({ role: 'tool', tool_call_id: toolResponse.toolCallId, + name: toolResponse.toolName, content: contentValue, ...toolResponseMetadata, }) diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts b/packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts index 87afbd575a..3d885873b5 100644 --- a/packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts +++ b/packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts @@ -59,5 +59,6 @@ export interface OpenAICompatibleMessageToolCall extends JsonRecord { export interface OpenAICompatibleToolMessage extends JsonRecord { role: 'tool'; content: string; + name?: string; tool_call_id: string; } diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.test.ts b/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.test.ts new file mode 100644 index 0000000000..90a3ed36ed --- /dev/null +++ b/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test' + +import { prepareTools } from './openai-compatible-prepare-tools' + +describe('prepareTools', () => { + it('adds stable ids to function tool definitions', () => { + const result = prepareTools({ + tools: [ + { + type: 'function', + name: 'read_files', + description: 'Read files', + inputSchema: { type: 'object' }, + }, + { + type: 'function', + name: 'write_todos', + description: 'Write todos', + inputSchema: { type: 'object' }, + }, + ], + }) + + expect(result.tools).toEqual([ + { + id: 'tool_1', + type: 'function', + function: { + name: 'read_files', + description: 'Read files', + parameters: { type: 'object' }, + }, + }, + { + id: 'tool_2', + type: 'function', + function: { + name: 'write_todos', + description: 'Write todos', + parameters: { type: 'object' }, + }, + }, + ]) + }) +}) diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts b/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts index e48c8ec06c..71daea6669 100644 --- a/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts +++ b/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts @@ -16,6 +16,7 @@ export function prepareTools({ tools: | undefined | Array<{ + id: string; type: 'function'; function: { name: string; @@ -41,6 +42,7 @@ export function prepareTools({ } const openaiCompatTools: Array<{ + id: string; type: 'function'; function: { name: string; @@ -49,11 +51,12 @@ export function prepareTools({ }; }> = []; - for (const tool of tools) { + for (const [index, tool] of tools.entries()) { if (tool.type === 'provider-defined') { toolWarnings.push({ type: 'unsupported-tool', tool }); } else { openaiCompatTools.push({ + id: `tool_${index + 1}`, type: 'function', function: { name: tool.name, diff --git a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts b/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts index a6897db596..88b1aaac25 100644 --- a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts +++ b/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts @@ -481,6 +481,7 @@ describe('cache control', () => { { role: 'tool', tool_call_id: 'call-123', + name: 'calculator', content: JSON.stringify({ answer: 42 }), cache_control: { type: 'ephemeral' }, }, diff --git a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts b/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts index 41cf10d76a..c5fc54d701 100644 --- a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts +++ b/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts @@ -198,6 +198,7 @@ export function convertToOpenRouterChatMessages( messages.push({ role: 'tool', tool_call_id: toolResponse.toolCallId, + name: toolResponse.toolName, content, cache_control: getCacheControl(providerOptions) ?? diff --git a/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts b/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts index d2143a7533..1ff2ba667b 100644 --- a/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts +++ b/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts @@ -629,6 +629,7 @@ describe('doGenerate', () => { messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], tools: [ { + id: 'tool_1', type: 'function', function: { name: 'test-tool', diff --git a/packages/internal/src/openrouter-ai-sdk/chat/index.ts b/packages/internal/src/openrouter-ai-sdk/chat/index.ts index 593a369c99..cf62247a4b 100644 --- a/packages/internal/src/openrouter-ai-sdk/chat/index.ts +++ b/packages/internal/src/openrouter-ai-sdk/chat/index.ts @@ -166,7 +166,8 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 { // TODO: support built-in tools const mappedTools = tools .filter((tool) => tool.type === 'function') - .map((tool) => ({ + .map((tool, index) => ({ + id: `tool_${index + 1}`, type: 'function' as const, function: { name: tool.name, diff --git a/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts b/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts index 4187661d3a..6fb7e82f3d 100644 --- a/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts +++ b/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts @@ -73,6 +73,7 @@ export interface ChatCompletionMessageToolCall { export interface ChatCompletionToolMessageParam { role: 'tool' content: string + name?: string tool_call_id: string cache_control?: OpenRouterCacheControl } diff --git a/web/src/llm-api/types.ts b/web/src/llm-api/types.ts index b3bb1eaf97..dd3b89a4d7 100644 --- a/web/src/llm-api/types.ts +++ b/web/src/llm-api/types.ts @@ -28,9 +28,21 @@ export interface ChatMessage { tool_call_id?: string } +export interface ChatCompletionTool { + id?: string + type: string + function?: { + name: string + description?: string + parameters?: unknown + strict?: boolean + } +} + export interface ChatCompletionRequestBody { model: string messages: ChatMessage[] + tools?: ChatCompletionTool[] stream?: boolean temperature?: number max_tokens?: number From b1d8b311597886477534e91b3e2c0d89e4122134 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 28 Apr 2026 17:14:55 -0700 Subject: [PATCH 2/4] Revert "include tool call ids" This reverts commit 8c63e6ae6472d563c62a308a21d52999198fcceb. --- ...to-openai-compatible-chat-messages.test.ts | 4 -- ...vert-to-openai-compatible-chat-messages.ts | 1 - .../chat/openai-compatible-api-types.ts | 1 - .../openai-compatible-prepare-tools.test.ts | 45 ------------------- .../chat/openai-compatible-prepare-tools.ts | 5 +-- ...onvert-to-openrouter-chat-messages.test.ts | 1 - .../convert-to-openrouter-chat-messages.ts | 1 - .../src/openrouter-ai-sdk/chat/index.test.ts | 1 - .../src/openrouter-ai-sdk/chat/index.ts | 3 +- .../openrouter-chat-completions-input.ts | 1 - web/src/llm-api/types.ts | 12 ----- 11 files changed, 2 insertions(+), 73 deletions(-) delete mode 100644 packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.test.ts diff --git a/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts b/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts index c2ff8cc2e1..a24d724990 100644 --- a/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts +++ b/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts @@ -147,7 +147,6 @@ describe('tool calls', () => { { role: 'tool', content: JSON.stringify({ oof: '321rab' }), - name: 'thwomp', tool_call_id: 'quux', }, ]) @@ -197,7 +196,6 @@ describe('tool calls', () => { { role: 'tool', content: 'It is sunny today', - name: 'getWeather', tool_call_id: 'call-1', }, ]) @@ -543,13 +541,11 @@ describe('provider-specific metadata merging', () => { { role: 'tool', tool_call_id: 'call123', - name: 'calculator', content: JSON.stringify({ stepOne: 'data chunk 1' }), }, { role: 'tool', tool_call_id: 'call123', - name: 'calculator', content: JSON.stringify({ stepTwo: 'data chunk 2' }), partial: true, }, diff --git a/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts b/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts index 465756655e..30a27cf6c4 100644 --- a/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts +++ b/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts @@ -124,7 +124,6 @@ export function convertToOpenAICompatibleChatMessages( messages.push({ role: 'tool', tool_call_id: toolResponse.toolCallId, - name: toolResponse.toolName, content: contentValue, ...toolResponseMetadata, }) diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts b/packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts index 3d885873b5..87afbd575a 100644 --- a/packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts +++ b/packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts @@ -59,6 +59,5 @@ export interface OpenAICompatibleMessageToolCall extends JsonRecord { export interface OpenAICompatibleToolMessage extends JsonRecord { role: 'tool'; content: string; - name?: string; tool_call_id: string; } diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.test.ts b/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.test.ts deleted file mode 100644 index 90a3ed36ed..0000000000 --- a/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'bun:test' - -import { prepareTools } from './openai-compatible-prepare-tools' - -describe('prepareTools', () => { - it('adds stable ids to function tool definitions', () => { - const result = prepareTools({ - tools: [ - { - type: 'function', - name: 'read_files', - description: 'Read files', - inputSchema: { type: 'object' }, - }, - { - type: 'function', - name: 'write_todos', - description: 'Write todos', - inputSchema: { type: 'object' }, - }, - ], - }) - - expect(result.tools).toEqual([ - { - id: 'tool_1', - type: 'function', - function: { - name: 'read_files', - description: 'Read files', - parameters: { type: 'object' }, - }, - }, - { - id: 'tool_2', - type: 'function', - function: { - name: 'write_todos', - description: 'Write todos', - parameters: { type: 'object' }, - }, - }, - ]) - }) -}) diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts b/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts index 71daea6669..e48c8ec06c 100644 --- a/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts +++ b/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts @@ -16,7 +16,6 @@ export function prepareTools({ tools: | undefined | Array<{ - id: string; type: 'function'; function: { name: string; @@ -42,7 +41,6 @@ export function prepareTools({ } const openaiCompatTools: Array<{ - id: string; type: 'function'; function: { name: string; @@ -51,12 +49,11 @@ export function prepareTools({ }; }> = []; - for (const [index, tool] of tools.entries()) { + for (const tool of tools) { if (tool.type === 'provider-defined') { toolWarnings.push({ type: 'unsupported-tool', tool }); } else { openaiCompatTools.push({ - id: `tool_${index + 1}`, type: 'function', function: { name: tool.name, diff --git a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts b/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts index 88b1aaac25..a6897db596 100644 --- a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts +++ b/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts @@ -481,7 +481,6 @@ describe('cache control', () => { { role: 'tool', tool_call_id: 'call-123', - name: 'calculator', content: JSON.stringify({ answer: 42 }), cache_control: { type: 'ephemeral' }, }, diff --git a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts b/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts index c5fc54d701..41cf10d76a 100644 --- a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts +++ b/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts @@ -198,7 +198,6 @@ export function convertToOpenRouterChatMessages( messages.push({ role: 'tool', tool_call_id: toolResponse.toolCallId, - name: toolResponse.toolName, content, cache_control: getCacheControl(providerOptions) ?? diff --git a/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts b/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts index 1ff2ba667b..d2143a7533 100644 --- a/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts +++ b/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts @@ -629,7 +629,6 @@ describe('doGenerate', () => { messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], tools: [ { - id: 'tool_1', type: 'function', function: { name: 'test-tool', diff --git a/packages/internal/src/openrouter-ai-sdk/chat/index.ts b/packages/internal/src/openrouter-ai-sdk/chat/index.ts index cf62247a4b..593a369c99 100644 --- a/packages/internal/src/openrouter-ai-sdk/chat/index.ts +++ b/packages/internal/src/openrouter-ai-sdk/chat/index.ts @@ -166,8 +166,7 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 { // TODO: support built-in tools const mappedTools = tools .filter((tool) => tool.type === 'function') - .map((tool, index) => ({ - id: `tool_${index + 1}`, + .map((tool) => ({ type: 'function' as const, function: { name: tool.name, diff --git a/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts b/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts index 6fb7e82f3d..4187661d3a 100644 --- a/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts +++ b/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts @@ -73,7 +73,6 @@ export interface ChatCompletionMessageToolCall { export interface ChatCompletionToolMessageParam { role: 'tool' content: string - name?: string tool_call_id: string cache_control?: OpenRouterCacheControl } diff --git a/web/src/llm-api/types.ts b/web/src/llm-api/types.ts index dd3b89a4d7..b3bb1eaf97 100644 --- a/web/src/llm-api/types.ts +++ b/web/src/llm-api/types.ts @@ -28,21 +28,9 @@ export interface ChatMessage { tool_call_id?: string } -export interface ChatCompletionTool { - id?: string - type: string - function?: { - name: string - description?: string - parameters?: unknown - strict?: boolean - } -} - export interface ChatCompletionRequestBody { model: string messages: ChatMessage[] - tools?: ChatCompletionTool[] stream?: boolean temperature?: number max_tokens?: number From 4452eabb72df6c7b143359f3549f04599325151a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 28 Apr 2026 17:18:35 -0700 Subject: [PATCH 3/4] scope kimi tool compatibility --- .../__tests__/kimi-tool-compat.test.ts | 104 ++++++++++++++++++ web/src/llm-api/canopywave.ts | 6 +- web/src/llm-api/kimi-tool-compat.ts | 63 +++++++++++ web/src/llm-api/openrouter.ts | 7 +- web/src/llm-api/types.ts | 12 ++ 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 web/src/llm-api/__tests__/kimi-tool-compat.test.ts create mode 100644 web/src/llm-api/kimi-tool-compat.ts diff --git a/web/src/llm-api/__tests__/kimi-tool-compat.test.ts b/web/src/llm-api/__tests__/kimi-tool-compat.test.ts new file mode 100644 index 0000000000..919d49d054 --- /dev/null +++ b/web/src/llm-api/__tests__/kimi-tool-compat.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'bun:test' + +import { addKimiToolCompatibilityFields } from '../kimi-tool-compat' + +import type { ChatCompletionRequestBody } from '../types' + +describe('addKimiToolCompatibilityFields', () => { + it('adds declaration ids and tool-result names without mutating input', () => { + const body: ChatCompletionRequestBody = { + model: 'moonshotai/kimi-k2.6', + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'read_files', + arguments: JSON.stringify({ paths: ['README.md'] }), + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_123', + content: JSON.stringify({ message: 'ok' }), + }, + ], + tools: [ + { + type: 'function', + function: { + name: 'read_files', + description: 'Read files', + parameters: { type: 'object' }, + }, + }, + ], + } + + const result = addKimiToolCompatibilityFields(body) + + expect(result.tools?.[0]).toEqual({ + id: 'tool_1', + type: 'function', + function: { + name: 'read_files', + description: 'Read files', + parameters: { type: 'object' }, + }, + }) + expect(result.messages[1]).toEqual({ + role: 'tool', + tool_call_id: 'call_123', + name: 'read_files', + content: JSON.stringify({ message: 'ok' }), + }) + expect(body.tools?.[0]).not.toHaveProperty('id') + expect(body.messages[1]).not.toHaveProperty('name') + }) + + it('preserves existing ids and names', () => { + const body: ChatCompletionRequestBody = { + model: 'moonshotai/kimi-k2.6', + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_456', + type: 'function', + function: { + name: 'write_todos', + arguments: JSON.stringify({ todos: [] }), + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_456', + name: 'existing_name', + content: '{}', + }, + ], + tools: [ + { + id: 'existing_tool_id', + type: 'function', + function: { + name: 'write_todos', + parameters: { type: 'object' }, + }, + }, + ], + } + + expect(addKimiToolCompatibilityFields(body)).toEqual(body) + }) +}) diff --git a/web/src/llm-api/canopywave.ts b/web/src/llm-api/canopywave.ts index 9a5b2ba125..49e3df4fce 100644 --- a/web/src/llm-api/canopywave.ts +++ b/web/src/llm-api/canopywave.ts @@ -9,6 +9,7 @@ import { extractRequestMetadata, insertMessageToBigQuery, } from './helpers' +import { addKimiToolCompatibilityFields } from './kimi-tool-compat' import type { UsageData } from './helpers' import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/bigquery' @@ -88,8 +89,11 @@ function createCanopyWaveRequest(params: { fetch: typeof globalThis.fetch }) { const { body, originalModel, fetch } = params + const providerBody = originalModel.startsWith('moonshotai/') + ? addKimiToolCompatibilityFields(body) + : body const canopywaveBody: Record = { - ...body, + ...providerBody, model: getCanopyWaveModelId(originalModel), } diff --git a/web/src/llm-api/kimi-tool-compat.ts b/web/src/llm-api/kimi-tool-compat.ts new file mode 100644 index 0000000000..892598b37c --- /dev/null +++ b/web/src/llm-api/kimi-tool-compat.ts @@ -0,0 +1,63 @@ +import type { ChatCompletionRequestBody } from './types' + +function getToolCallNamesById( + messages: ChatCompletionRequestBody['messages'], +): Map { + const namesById = new Map() + + for (const message of messages) { + if (message.role !== 'assistant') { + continue + } + for (const toolCall of message.tool_calls ?? []) { + if (toolCall.id && toolCall.function.name) { + namesById.set(toolCall.id, toolCall.function.name) + } + } + } + + return namesById +} + +/** + * Kimi-compatible providers require two OpenAI-compatible extensions that are + * not part of the strict Chat Completions schema: ids on tool declarations and + * names on tool-result messages. + */ +export function addKimiToolCompatibilityFields( + body: ChatCompletionRequestBody, +): ChatCompletionRequestBody { + const namesByToolCallId = getToolCallNamesById(body.messages) + + return { + ...body, + tools: body.tools?.map((tool, index) => { + if (tool.type !== 'function' || tool.id) { + return tool + } + return { + ...tool, + id: `tool_${index + 1}`, + } + }), + messages: body.messages.map((message) => { + if ( + message.role !== 'tool' || + message.name || + typeof message.tool_call_id !== 'string' + ) { + return message + } + + const name = namesByToolCallId.get(message.tool_call_id) + if (!name) { + return message + } + + return { + ...message, + name, + } + }), + } +} diff --git a/web/src/llm-api/openrouter.ts b/web/src/llm-api/openrouter.ts index 2762a60d8d..5e6dff6da2 100644 --- a/web/src/llm-api/openrouter.ts +++ b/web/src/llm-api/openrouter.ts @@ -9,6 +9,7 @@ import { extractRequestMetadata, insertMessageToBigQuery, } from './helpers' +import { addKimiToolCompatibilityFields } from './kimi-tool-compat' import { OpenRouterErrorResponseSchema, OpenRouterStreamChatCompletionChunkSchema, @@ -61,6 +62,10 @@ function createOpenRouterRequest(params: { fetch: typeof globalThis.fetch }) { const { body, openrouterApiKey, fetch } = params + const providerBody = body.model.startsWith('moonshotai/') + ? addKimiToolCompatibilityFields(body) + : body + return fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { @@ -69,7 +74,7 @@ function createOpenRouterRequest(params: { 'X-Title': 'Codebuff', 'Content-Type': 'application/json', }, - body: JSON.stringify(body), + body: JSON.stringify(providerBody), // Use custom agent with extended headers timeout for deep-thinking models // @ts-expect-error - dispatcher is a valid undici option not in fetch types dispatcher: openrouterAgent, diff --git a/web/src/llm-api/types.ts b/web/src/llm-api/types.ts index b3bb1eaf97..dd3b89a4d7 100644 --- a/web/src/llm-api/types.ts +++ b/web/src/llm-api/types.ts @@ -28,9 +28,21 @@ export interface ChatMessage { tool_call_id?: string } +export interface ChatCompletionTool { + id?: string + type: string + function?: { + name: string + description?: string + parameters?: unknown + strict?: boolean + } +} + export interface ChatCompletionRequestBody { model: string messages: ChatMessage[] + tools?: ChatCompletionTool[] stream?: boolean temperature?: number max_tokens?: number From ec9676d29a504608cee51c8694e9762ac7d4eb2f Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 28 Apr 2026 17:36:28 -0700 Subject: [PATCH 4/4] guard missing model in kimi compat --- web/src/llm-api/__tests__/kimi-tool-compat.test.ts | 10 +++++++++- web/src/llm-api/canopywave.ts | 4 ++-- web/src/llm-api/kimi-tool-compat.ts | 4 ++++ web/src/llm-api/openrouter.ts | 4 ++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/web/src/llm-api/__tests__/kimi-tool-compat.test.ts b/web/src/llm-api/__tests__/kimi-tool-compat.test.ts index 919d49d054..9e4fbdabb0 100644 --- a/web/src/llm-api/__tests__/kimi-tool-compat.test.ts +++ b/web/src/llm-api/__tests__/kimi-tool-compat.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test' -import { addKimiToolCompatibilityFields } from '../kimi-tool-compat' +import { addKimiToolCompatibilityFields, isKimiModel } from '../kimi-tool-compat' import type { ChatCompletionRequestBody } from '../types' @@ -102,3 +102,11 @@ describe('addKimiToolCompatibilityFields', () => { expect(addKimiToolCompatibilityFields(body)).toEqual(body) }) }) + +describe('isKimiModel', () => { + it('matches only Moonshot model ids', () => { + expect(isKimiModel('moonshotai/kimi-k2.6')).toBe(true) + expect(isKimiModel('anthropic/claude-sonnet-4.5')).toBe(false) + expect(isKimiModel(undefined)).toBe(false) + }) +}) diff --git a/web/src/llm-api/canopywave.ts b/web/src/llm-api/canopywave.ts index 49e3df4fce..341bc239ce 100644 --- a/web/src/llm-api/canopywave.ts +++ b/web/src/llm-api/canopywave.ts @@ -9,7 +9,7 @@ import { extractRequestMetadata, insertMessageToBigQuery, } from './helpers' -import { addKimiToolCompatibilityFields } from './kimi-tool-compat' +import { addKimiToolCompatibilityFields, isKimiModel } from './kimi-tool-compat' import type { UsageData } from './helpers' import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/bigquery' @@ -89,7 +89,7 @@ function createCanopyWaveRequest(params: { fetch: typeof globalThis.fetch }) { const { body, originalModel, fetch } = params - const providerBody = originalModel.startsWith('moonshotai/') + const providerBody = isKimiModel(originalModel) ? addKimiToolCompatibilityFields(body) : body const canopywaveBody: Record = { diff --git a/web/src/llm-api/kimi-tool-compat.ts b/web/src/llm-api/kimi-tool-compat.ts index 892598b37c..334a41b914 100644 --- a/web/src/llm-api/kimi-tool-compat.ts +++ b/web/src/llm-api/kimi-tool-compat.ts @@ -1,5 +1,9 @@ import type { ChatCompletionRequestBody } from './types' +export function isKimiModel(model: unknown): model is string { + return typeof model === 'string' && model.startsWith('moonshotai/') +} + function getToolCallNamesById( messages: ChatCompletionRequestBody['messages'], ): Map { diff --git a/web/src/llm-api/openrouter.ts b/web/src/llm-api/openrouter.ts index 5e6dff6da2..bf7231abd9 100644 --- a/web/src/llm-api/openrouter.ts +++ b/web/src/llm-api/openrouter.ts @@ -9,7 +9,7 @@ import { extractRequestMetadata, insertMessageToBigQuery, } from './helpers' -import { addKimiToolCompatibilityFields } from './kimi-tool-compat' +import { addKimiToolCompatibilityFields, isKimiModel } from './kimi-tool-compat' import { OpenRouterErrorResponseSchema, OpenRouterStreamChatCompletionChunkSchema, @@ -62,7 +62,7 @@ function createOpenRouterRequest(params: { fetch: typeof globalThis.fetch }) { const { body, openrouterApiKey, fetch } = params - const providerBody = body.model.startsWith('moonshotai/') + const providerBody = isKimiModel(body.model) ? addKimiToolCompatibilityFields(body) : body