Skip to content

Commit 3388ffe

Browse files
Scope Kimi tool call compatibility (#560)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent e8b9af6 commit 3388ffe

5 files changed

Lines changed: 202 additions & 2 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import { addKimiToolCompatibilityFields, isKimiModel } from '../kimi-tool-compat'
4+
5+
import type { ChatCompletionRequestBody } from '../types'
6+
7+
describe('addKimiToolCompatibilityFields', () => {
8+
it('adds declaration ids and tool-result names without mutating input', () => {
9+
const body: ChatCompletionRequestBody = {
10+
model: 'moonshotai/kimi-k2.6',
11+
messages: [
12+
{
13+
role: 'assistant',
14+
content: '',
15+
tool_calls: [
16+
{
17+
id: 'call_123',
18+
type: 'function',
19+
function: {
20+
name: 'read_files',
21+
arguments: JSON.stringify({ paths: ['README.md'] }),
22+
},
23+
},
24+
],
25+
},
26+
{
27+
role: 'tool',
28+
tool_call_id: 'call_123',
29+
content: JSON.stringify({ message: 'ok' }),
30+
},
31+
],
32+
tools: [
33+
{
34+
type: 'function',
35+
function: {
36+
name: 'read_files',
37+
description: 'Read files',
38+
parameters: { type: 'object' },
39+
},
40+
},
41+
],
42+
}
43+
44+
const result = addKimiToolCompatibilityFields(body)
45+
46+
expect(result.tools?.[0]).toEqual({
47+
id: 'tool_1',
48+
type: 'function',
49+
function: {
50+
name: 'read_files',
51+
description: 'Read files',
52+
parameters: { type: 'object' },
53+
},
54+
})
55+
expect(result.messages[1]).toEqual({
56+
role: 'tool',
57+
tool_call_id: 'call_123',
58+
name: 'read_files',
59+
content: JSON.stringify({ message: 'ok' }),
60+
})
61+
expect(body.tools?.[0]).not.toHaveProperty('id')
62+
expect(body.messages[1]).not.toHaveProperty('name')
63+
})
64+
65+
it('preserves existing ids and names', () => {
66+
const body: ChatCompletionRequestBody = {
67+
model: 'moonshotai/kimi-k2.6',
68+
messages: [
69+
{
70+
role: 'assistant',
71+
content: '',
72+
tool_calls: [
73+
{
74+
id: 'call_456',
75+
type: 'function',
76+
function: {
77+
name: 'write_todos',
78+
arguments: JSON.stringify({ todos: [] }),
79+
},
80+
},
81+
],
82+
},
83+
{
84+
role: 'tool',
85+
tool_call_id: 'call_456',
86+
name: 'existing_name',
87+
content: '{}',
88+
},
89+
],
90+
tools: [
91+
{
92+
id: 'existing_tool_id',
93+
type: 'function',
94+
function: {
95+
name: 'write_todos',
96+
parameters: { type: 'object' },
97+
},
98+
},
99+
],
100+
}
101+
102+
expect(addKimiToolCompatibilityFields(body)).toEqual(body)
103+
})
104+
})
105+
106+
describe('isKimiModel', () => {
107+
it('matches only Moonshot model ids', () => {
108+
expect(isKimiModel('moonshotai/kimi-k2.6')).toBe(true)
109+
expect(isKimiModel('anthropic/claude-sonnet-4.5')).toBe(false)
110+
expect(isKimiModel(undefined)).toBe(false)
111+
})
112+
})

web/src/llm-api/canopywave.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
extractRequestMetadata,
1010
insertMessageToBigQuery,
1111
} from './helpers'
12+
import { addKimiToolCompatibilityFields, isKimiModel } from './kimi-tool-compat'
1213

1314
import type { UsageData } from './helpers'
1415
import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/bigquery'
@@ -88,8 +89,11 @@ function createCanopyWaveRequest(params: {
8889
fetch: typeof globalThis.fetch
8990
}) {
9091
const { body, originalModel, fetch } = params
92+
const providerBody = isKimiModel(originalModel)
93+
? addKimiToolCompatibilityFields(body)
94+
: body
9195
const canopywaveBody: Record<string, unknown> = {
92-
...body,
96+
...providerBody,
9397
model: getCanopyWaveModelId(originalModel),
9498
}
9599

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { ChatCompletionRequestBody } from './types'
2+
3+
export function isKimiModel(model: unknown): model is string {
4+
return typeof model === 'string' && model.startsWith('moonshotai/')
5+
}
6+
7+
function getToolCallNamesById(
8+
messages: ChatCompletionRequestBody['messages'],
9+
): Map<string, string> {
10+
const namesById = new Map<string, string>()
11+
12+
for (const message of messages) {
13+
if (message.role !== 'assistant') {
14+
continue
15+
}
16+
for (const toolCall of message.tool_calls ?? []) {
17+
if (toolCall.id && toolCall.function.name) {
18+
namesById.set(toolCall.id, toolCall.function.name)
19+
}
20+
}
21+
}
22+
23+
return namesById
24+
}
25+
26+
/**
27+
* Kimi-compatible providers require two OpenAI-compatible extensions that are
28+
* not part of the strict Chat Completions schema: ids on tool declarations and
29+
* names on tool-result messages.
30+
*/
31+
export function addKimiToolCompatibilityFields(
32+
body: ChatCompletionRequestBody,
33+
): ChatCompletionRequestBody {
34+
const namesByToolCallId = getToolCallNamesById(body.messages)
35+
36+
return {
37+
...body,
38+
tools: body.tools?.map((tool, index) => {
39+
if (tool.type !== 'function' || tool.id) {
40+
return tool
41+
}
42+
return {
43+
...tool,
44+
id: `tool_${index + 1}`,
45+
}
46+
}),
47+
messages: body.messages.map((message) => {
48+
if (
49+
message.role !== 'tool' ||
50+
message.name ||
51+
typeof message.tool_call_id !== 'string'
52+
) {
53+
return message
54+
}
55+
56+
const name = namesByToolCallId.get(message.tool_call_id)
57+
if (!name) {
58+
return message
59+
}
60+
61+
return {
62+
...message,
63+
name,
64+
}
65+
}),
66+
}
67+
}

web/src/llm-api/openrouter.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
extractRequestMetadata,
1010
insertMessageToBigQuery,
1111
} from './helpers'
12+
import { addKimiToolCompatibilityFields, isKimiModel } from './kimi-tool-compat'
1213
import {
1314
OpenRouterErrorResponseSchema,
1415
OpenRouterStreamChatCompletionChunkSchema,
@@ -61,6 +62,10 @@ function createOpenRouterRequest(params: {
6162
fetch: typeof globalThis.fetch
6263
}) {
6364
const { body, openrouterApiKey, fetch } = params
65+
const providerBody = isKimiModel(body.model)
66+
? addKimiToolCompatibilityFields(body)
67+
: body
68+
6469
return fetch('https://openrouter.ai/api/v1/chat/completions', {
6570
method: 'POST',
6671
headers: {
@@ -69,7 +74,7 @@ function createOpenRouterRequest(params: {
6974
'X-Title': 'Codebuff',
7075
'Content-Type': 'application/json',
7176
},
72-
body: JSON.stringify(body),
77+
body: JSON.stringify(providerBody),
7378
// Use custom agent with extended headers timeout for deep-thinking models
7479
// @ts-expect-error - dispatcher is a valid undici option not in fetch types
7580
dispatcher: openrouterAgent,

web/src/llm-api/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,21 @@ export interface ChatMessage {
2828
tool_call_id?: string
2929
}
3030

31+
export interface ChatCompletionTool {
32+
id?: string
33+
type: string
34+
function?: {
35+
name: string
36+
description?: string
37+
parameters?: unknown
38+
strict?: boolean
39+
}
40+
}
41+
3142
export interface ChatCompletionRequestBody {
3243
model: string
3344
messages: ChatMessage[]
45+
tools?: ChatCompletionTool[]
3446
stream?: boolean
3547
temperature?: number
3648
max_tokens?: number

0 commit comments

Comments
 (0)