From 87477b9c083548a791d75648288337f97c312701 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 21:32:59 +0800 Subject: [PATCH] fix(kosong): preserve unsigned thinking in anthropic history serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When converting assistant history to the Anthropic wire format, convertMessage() dropped any thinking block that had no signature. That was meant to satisfy api.anthropic.com (which requires a valid signature on thinking blocks), but it broke Anthropic-compatible backends. Kimi's Anthropic-protocol endpoint streams thinking without a signature_delta, yet requires the thinking to be present on a tool-call turn — once it was dropped, the next request failed with "thinking is enabled but reasoning_content is missing in assistant tool call message", making multi-step tool use unusable on those backends. Preserve unsigned thinking instead, emitting it without a `signature` field. The two backends are partitioned by signature presence: api.anthropic.com always supplies a signature (its history takes the signed branch unchanged), while Kimi never does (its thinking is now kept). Empty-and-unsigned parts carry nothing and are still skipped. --- .changeset/preserve-unsigned-thinking.md | 5 +++ packages/kosong/src/providers/anthropic.ts | 28 ++++++++++---- packages/kosong/test/anthropic.test.ts | 45 ++++++++++++++++++++-- 3 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 .changeset/preserve-unsigned-thinking.md diff --git a/.changeset/preserve-unsigned-thinking.md b/.changeset/preserve-unsigned-thinking.md new file mode 100644 index 0000000000..c489a021f1 --- /dev/null +++ b/.changeset/preserve-unsigned-thinking.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kosong": patch +--- + +Preserve unsigned assistant thinking when serializing history for the Anthropic provider, instead of dropping it. Anthropic-compatible backends (e.g. Kimi) stream thinking without a signature yet reject a tool-call turn whose thinking is missing ("thinking is enabled but reasoning_content is missing"). api.anthropic.com always supplies a signature, so its behavior is unchanged. diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 1abc5ab737..6240a51a07 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -461,15 +461,27 @@ function convertMessage(message: Message): MessageParam { } else if (part.type === 'image_url') { blocks.push(imageUrlPartToAnthropic(part.imageUrl.url) as unknown as ContentBlockParam); } else if (part.type === 'think') { - // ThinkPart with encrypted -> ThinkingBlockParam; no encrypted -> skip - if (part.encrypted === undefined) { - continue; + // ThinkPart -> ThinkingBlockParam. + // + // Signed: emit the block with its signature. api.anthropic.com requires a + // valid signature and always supplies one, so Anthropic-sourced history + // always takes this branch. + // + // Unsigned: still PRESERVE the thinking, emitted *without* a `signature` + // field. Anthropic-compatible backends (e.g. Kimi) stream thinking with + // no signature_delta, yet reject a tool-call turn whose thinking is gone + // ("thinking is enabled but reasoning_content is missing"). Dropping it + // here is what broke multi-step tool use on those backends. An unsigned + // part with no text carries nothing, so it is skipped. + if (part.encrypted !== undefined) { + blocks.push({ + type: 'thinking', + thinking: part.think, + signature: part.encrypted, + } satisfies ThinkingBlockParam); + } else if (part.think !== '') { + blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); } - blocks.push({ - type: 'thinking', - thinking: part.think, - signature: part.encrypted, - } satisfies ThinkingBlockParam); } // audio_url, video_url: not supported by Anthropic, skip } diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index b28e71cee2..b68dbbd077 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -689,7 +689,7 @@ describe('AnthropicChatProvider', () => { ]); }); - it('thinking without signature is stripped', async () => { + it('thinking without signature is preserved (no signature field)', async () => { const provider = createProvider(); const history: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, @@ -706,10 +706,17 @@ describe('AnthropicChatProvider', () => { const body = await captureRequestBody(provider, '', [], history); const messages = body['messages'] as unknown[]; - // Assistant message should have thinking stripped (no encrypted) + // Unsigned thinking must be PRESERVED, emitted without a `signature` + // field — not stripped. Anthropic-compatible backends (e.g. Kimi) reject + // a tool-call turn whose thinking is missing ("reasoning_content is + // missing"); api.anthropic.com never emits unsigned thinking, so the + // signed branch always handles its history and this path is backend-neutral. expect(messages[1]).toEqual({ role: 'assistant', - content: [{ type: 'text', text: 'Hello!' }], + content: [ + { type: 'thinking', thinking: 'Thinking...' }, + { type: 'text', text: 'Hello!' }, + ], }); }); @@ -770,6 +777,38 @@ describe('AnthropicChatProvider', () => { ], }); }); + + it('unsigned thinking is preserved before a tool_use block', async () => { + // Reproduces the real failure: a streamed assistant turn whose thinking + // arrived without a signature_delta, followed by a tool_use. Dropping the + // thinking made Kimi reject the *next* request with + // "thinking is enabled but reasoning_content is missing". + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Search for 429' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'think', think: 'Let me grep for 429.' }], + toolCalls: [ + { type: 'function', id: 'toolu_1', name: 'Grep', arguments: '{"pattern":"429"}' }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'found in chat.go' }], + toolCallId: 'toolu_1', + toolCalls: [], + }, + ]; + const body = await captureRequestBody(provider, '', [], history); + const messages = body['messages'] as Array<{ role: string; content: unknown[] }>; + + expect(messages[1]!.role).toBe('assistant'); + expect(messages[1]!.content).toEqual([ + { type: 'thinking', thinking: 'Let me grep for 429.' }, + { type: 'tool_use', id: 'toolu_1', name: 'Grep', input: { pattern: '429' } }, + ]); + }); }); describe('generation kwargs', () => {