Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-tool-call-422-strict-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix 422 errors from some OpenAI-compatible providers when a conversation includes tool calls.
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export interface OpenAILegacyGenerationKwargs {

interface OpenAIMessage {
role: string;
content?: string | OpenAIContentPart[] | undefined;
content?: string | OpenAIContentPart[] | null | undefined;
tool_calls?: OpenAIToolCallOut[] | undefined;
tool_call_id?: string | undefined;
name?: string | undefined;
Expand Down Expand Up @@ -250,6 +250,10 @@ function convertMessage(
result.content = '';
}

if (message.role === 'assistant' && result.content === undefined) {
result.content = null;
}

if (hasReasoningPart || (preserveThinking && message.role === 'assistant')) {
result[reasoningKey] = reasoningContent;
}
Expand Down
46 changes: 46 additions & 0 deletions packages/agent-core-v2/test/kosong/provider/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,52 @@ describe('reasoning-only assistant history projection', () => {
});
});

describe('tool-call-only assistant history projection (issue #3017)', () => {
it('emits content: null for an assistant message carrying only tool_calls', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'sk-probe',
stream: false,
});

const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [
{ type: 'function', id: 'call_abc123', name: 'add', arguments: '{"a": 2, "b": 3}' },
],
},
{
role: 'tool',
content: [{ type: 'text', text: '5' }],
toolCallId: 'call_abc123',
toolCalls: [],
},
];

const body = await captureOpenAIBody(provider, undefined, history);
const messages = body['messages'] as Array<Record<string, unknown>>;

expect(messages).toEqual([
{ role: 'user', content: 'Add 2 and 3' },
{
role: 'assistant',
content: null,
tool_calls: [
{
type: 'function',
id: 'call_abc123',
function: { name: 'add', arguments: '{"a": 2, "b": 3}' },
},
],
},
{ role: 'tool', content: '5', tool_call_id: 'call_abc123' },
]);
});
});

describe('quota-exhausted classification through the real composition (behavior probes)', () => {
const MOONSHOT_QUOTA_BODY = {
type: 'error',
Expand Down
10 changes: 9 additions & 1 deletion packages/kosong/src/providers/openai-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export interface OpenAILegacyGenerationKwargs {
}
interface OpenAIMessage {
role: string;
content?: string | OpenAIContentPart[] | undefined;
content?: string | OpenAIContentPart[] | null | undefined;
tool_calls?: OpenAIToolCallOut[] | undefined;
tool_call_id?: string | undefined;
name?: string | undefined;
Expand Down Expand Up @@ -227,6 +227,14 @@ function convertMessage(
}));
}

// A missing `content` key is dropped by JSON.stringify, and strict
// chat-completions validators (e.g. LiteLLM) reject such assistant messages
// with a 422. OpenAI's own responses echo `content: null` alongside
// tool_calls, so normalize the absent field to that spec-legal shape.
if (message.role === 'assistant' && result.content === undefined) {
result.content = null;
}

if (message.toolCallId !== undefined) {
result.tool_call_id = message.toolCallId;
}
Expand Down
71 changes: 71 additions & 0 deletions packages/kosong/test/openai-legacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ describe('OpenAILegacyChatProvider', () => {
{ role: 'user', content: 'Run bash' },
{
role: 'assistant',
content: null,
tool_calls: [
{
type: 'function',
Expand All @@ -291,6 +292,76 @@ describe('OpenAILegacyChatProvider', () => {
]);
});

it('serializes a tool-call-only assistant message with content: null (issue #3017)', async () => {
// Regression: an assistant message carrying only tool_calls used to be
// serialized without a `content` key (JSON.stringify drops undefined),
// which strict validators like LiteLLM reject with a 422. OpenAI's own
// responses echo `content: null` alongside tool_calls, so emit that.
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'call_abc123',
name: 'add',
arguments: '{"a": 2, "b": 3}',
},
],
},
{
role: 'tool',
content: [{ type: 'text', text: '5' }],
toolCallId: 'call_abc123',
toolCalls: [],
},
];

const body = await captureRequestBody(provider, '', [], history);

expect(body['messages']).toEqual([
{ role: 'user', content: 'Add 2 and 3' },
{
role: 'assistant',
content: null,
tool_calls: [
{
type: 'function',
id: 'call_abc123',
function: { name: 'add', arguments: '{"a": 2, "b": 3}' },
},
],
},
{ role: 'tool', content: '5', tool_call_id: 'call_abc123' },
]);
});

it('serializes a think-only assistant message with content: null', async () => {
// Pinning the accepted #3017 delta: a think-only assistant message (no
// tool calls) also used to lose its `content` key on the wire; it now
// carries `content: null` while reasoning keeps round-tripping under
// `reasoning_content`.
const provider = createProvider();
const history: Message[] = [
{
role: 'assistant',
content: [{ type: 'think', think: 'Thinking...' }],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);

const messages = body['messages'] as Array<Record<string, unknown>>;
expect(messages[0]).toEqual({
role: 'assistant',
content: null,
reasoning_content: 'Thinking...',
});
});

it('tool call with image result keeps the tool result textual and reattaches images as user input', async () => {
// OpenAI Chat Completions `tool` messages only accept text content.
// Even when toolMessageConversion is unset, a tool result containing
Expand Down
Loading