From cf41d12bf8a38957ffb4d479a84e3584b6609675 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 18 Aug 2026 21:51:03 +0800 Subject: [PATCH] fix(kosong): emit null content for assistant messages with no text in chat completions Assistant messages carrying only tool calls were serialized without a content key (JSON.stringify drops undefined), which strict chat-completions validators such as LiteLLM reject with a 422, permanently poisoning the session. Emit content: null for such messages in both the kosong and agent-core-v2 converters, matching the shape OpenAI responses use alongside tool_calls. The think-only empty-string behavior in agent-core-v2 and both Kimi providers' deliberate content omission are unchanged. --- .../fix-tool-call-422-strict-providers.md | 5 ++ .../provider/bases/openai/openai-legacy.ts | 6 +- .../test/kosong/provider/composition.test.ts | 46 ++++++++++++ .../kosong/src/providers/openai-legacy.ts | 10 ++- packages/kosong/test/openai-legacy.test.ts | 71 +++++++++++++++++++ 5 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-tool-call-422-strict-providers.md diff --git a/.changeset/fix-tool-call-422-strict-providers.md b/.changeset/fix-tool-call-422-strict-providers.md new file mode 100644 index 00000000000..5449e85ee03 --- /dev/null +++ b/.changeset/fix-tool-call-422-strict-providers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix 422 errors from some OpenAI-compatible providers when a conversation includes tool calls. diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts index f59301e211e..f2b36111cab 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts @@ -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; @@ -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; } diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index 5198b65613f..c80c62f6a82 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -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>; + + 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', diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index f40e59df7df..f8401c1b0d8 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -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; @@ -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; } diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 3f5d8b5bf83..55dfd8c36c2 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -279,6 +279,7 @@ describe('OpenAILegacyChatProvider', () => { { role: 'user', content: 'Run bash' }, { role: 'assistant', + content: null, tool_calls: [ { type: 'function', @@ -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>; + 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