Skip to content

Commit 13e0fff

Browse files
authored
fix(kosong): preserve unsigned thinking in anthropic history serialization (#222)
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.
1 parent 2bbea75 commit 13e0fff

3 files changed

Lines changed: 67 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kosong": patch
3+
---
4+
5+
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.

‎packages/kosong/src/providers/anthropic.ts‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -461,15 +461,27 @@ function convertMessage(message: Message): MessageParam {
461461
} else if (part.type === 'image_url') {
462462
blocks.push(imageUrlPartToAnthropic(part.imageUrl.url) as unknown as ContentBlockParam);
463463
} else if (part.type === 'think') {
464-
// ThinkPart with encrypted -> ThinkingBlockParam; no encrypted -> skip
465-
if (part.encrypted === undefined) {
466-
continue;
464+
// ThinkPart -> ThinkingBlockParam.
465+
//
466+
// Signed: emit the block with its signature. api.anthropic.com requires a
467+
// valid signature and always supplies one, so Anthropic-sourced history
468+
// always takes this branch.
469+
//
470+
// Unsigned: still PRESERVE the thinking, emitted *without* a `signature`
471+
// field. Anthropic-compatible backends (e.g. Kimi) stream thinking with
472+
// no signature_delta, yet reject a tool-call turn whose thinking is gone
473+
// ("thinking is enabled but reasoning_content is missing"). Dropping it
474+
// here is what broke multi-step tool use on those backends. An unsigned
475+
// part with no text carries nothing, so it is skipped.
476+
if (part.encrypted !== undefined) {
477+
blocks.push({
478+
type: 'thinking',
479+
thinking: part.think,
480+
signature: part.encrypted,
481+
} satisfies ThinkingBlockParam);
482+
} else if (part.think !== '') {
483+
blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam);
467484
}
468-
blocks.push({
469-
type: 'thinking',
470-
thinking: part.think,
471-
signature: part.encrypted,
472-
} satisfies ThinkingBlockParam);
473485
}
474486
// audio_url, video_url: not supported by Anthropic, skip
475487
}

‎packages/kosong/test/anthropic.test.ts‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,7 @@ describe('AnthropicChatProvider', () => {
689689
]);
690690
});
691691

692-
it('thinking without signature is stripped', async () => {
692+
it('thinking without signature is preserved (no signature field)', async () => {
693693
const provider = createProvider();
694694
const history: Message[] = [
695695
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
@@ -706,10 +706,17 @@ describe('AnthropicChatProvider', () => {
706706
const body = await captureRequestBody(provider, '', [], history);
707707
const messages = body['messages'] as unknown[];
708708

709-
// Assistant message should have thinking stripped (no encrypted)
709+
// Unsigned thinking must be PRESERVED, emitted without a `signature`
710+
// field — not stripped. Anthropic-compatible backends (e.g. Kimi) reject
711+
// a tool-call turn whose thinking is missing ("reasoning_content is
712+
// missing"); api.anthropic.com never emits unsigned thinking, so the
713+
// signed branch always handles its history and this path is backend-neutral.
710714
expect(messages[1]).toEqual({
711715
role: 'assistant',
712-
content: [{ type: 'text', text: 'Hello!' }],
716+
content: [
717+
{ type: 'thinking', thinking: 'Thinking...' },
718+
{ type: 'text', text: 'Hello!' },
719+
],
713720
});
714721
});
715722

@@ -770,6 +777,38 @@ describe('AnthropicChatProvider', () => {
770777
],
771778
});
772779
});
780+
781+
it('unsigned thinking is preserved before a tool_use block', async () => {
782+
// Reproduces the real failure: a streamed assistant turn whose thinking
783+
// arrived without a signature_delta, followed by a tool_use. Dropping the
784+
// thinking made Kimi reject the *next* request with
785+
// "thinking is enabled but reasoning_content is missing".
786+
const provider = createProvider();
787+
const history: Message[] = [
788+
{ role: 'user', content: [{ type: 'text', text: 'Search for 429' }], toolCalls: [] },
789+
{
790+
role: 'assistant',
791+
content: [{ type: 'think', think: 'Let me grep for 429.' }],
792+
toolCalls: [
793+
{ type: 'function', id: 'toolu_1', name: 'Grep', arguments: '{"pattern":"429"}' },
794+
],
795+
},
796+
{
797+
role: 'tool',
798+
content: [{ type: 'text', text: 'found in chat.go' }],
799+
toolCallId: 'toolu_1',
800+
toolCalls: [],
801+
},
802+
];
803+
const body = await captureRequestBody(provider, '', [], history);
804+
const messages = body['messages'] as Array<{ role: string; content: unknown[] }>;
805+
806+
expect(messages[1]!.role).toBe('assistant');
807+
expect(messages[1]!.content).toEqual([
808+
{ type: 'thinking', thinking: 'Let me grep for 429.' },
809+
{ type: 'tool_use', id: 'toolu_1', name: 'Grep', input: { pattern: '429' } },
810+
]);
811+
});
773812
});
774813

775814
describe('generation kwargs', () => {

0 commit comments

Comments
 (0)