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/preserve-unsigned-thinking.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 20 additions & 8 deletions packages/kosong/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +482 to +483

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate unsigned thinking to compatible backends

When this provider targets api.anthropic.com and the caller supplies history containing an unsigned ThinkPart (for example after restoring or switching from an Anthropic-compatible backend that does not emit signature_delta), this now serializes a thinking block with no signature. Anthropic's extended-thinking flow returns a signature/signature_delta and requires the complete original thinking block to be passed back unchanged (https://platform.claude.com/docs/en/build-with-claude/extended-thinking), so these requests can be rejected where the old code skipped the unsigned block. Please keep the unsigned preservation behind a backend/capability check or otherwise avoid sending unsigned thinking to the official Anthropic API.

Useful? React with 👍 / 👎.

}
blocks.push({
type: 'thinking',
thinking: part.think,
signature: part.encrypted,
} satisfies ThinkingBlockParam);
}
// audio_url, video_url: not supported by Anthropic, skip
}
Expand Down
45 changes: 42 additions & 3 deletions packages/kosong/test/anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
Expand All @@ -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!' },
],
});
});

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading