diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts index 285c86f76..ac5add4fa 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts @@ -364,6 +364,95 @@ describe('ClaudeProvider.complete request shape', () => { expect(toolUse!.input).toEqual({ q: 'puter' }); }); + it('splices round-tripped reasoning_details back in ahead of the content', async () => { + // The replay contract for a normalized Claude turn: the caller resends + // the whole message, and the thinking blocks have to reach Anthropic + // with their signature intact and leading the content array (Anthropic + // rejects both a missing signature and a trailing thinking block). + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: 'claude-haiku-4-5-20251001', + messages: [ + { role: 'user', content: 'think then call a tool' }, + { + role: 'assistant', + content: 'here you go', + reasoning: 'step one', + refusal: null, + reasoning_details: [ + { + type: 'thinking', + thinking: 'step one', + signature: 'sig_1', + }, + { type: 'redacted_thinking', data: 'ENC' }, + ], + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup', + arguments: '{\"q\":\"puter\"}', + }, + }, + ], + } as never, + ], + }), + ); + + const [args] = messagesCreateMock.mock.calls[0]!; + const assistant = args.messages[1] as Record; + const content = assistant.content as Array>; + // Thinking blocks lead, verbatim; string content became a text block; + // the tool_use block is appended after. + expect(content).toEqual([ + { type: 'thinking', thinking: 'step one', signature: 'sig_1' }, + { type: 'redacted_thinking', data: 'ENC' }, + { type: 'text', text: 'here you go' }, + { + type: 'tool_use', + id: 'call_1', + name: 'lookup', + input: { q: 'puter' }, + }, + ]); + // Output-only fields Anthropic rejects are stripped. + expect('reasoning_details' in assistant).toBe(false); + expect('reasoning' in assistant).toBe(false); + expect('refusal' in assistant).toBe(false); + }); + + it('strips output-only reasoning fields even with no reasoning_details', async () => { + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: 'claude-haiku-4-5-20251001', + messages: [ + { + role: 'assistant', + content: 'plain reply', + reasoning: 'leftover', + refusal: null, + } as never, + ], + }), + ); + + const [args] = messagesCreateMock.mock.calls[0]!; + const assistant = args.messages[0] as Record; + expect('reasoning' in assistant).toBe(false); + expect('refusal' in assistant).toBe(false); + // Content is untouched when there was nothing to splice. + expect(assistant.content).toBe('plain reply'); + }); + it('converts a tool-role message with tool_call_id into a user-role tool_result block', async () => { const { provider } = makeProvider(); messagesCreateMock.mockResolvedValueOnce(baseResponse); diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts index 2f01c7968..1f589694f 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts @@ -139,6 +139,37 @@ export class ClaudeProvider implements IChatProvider { return message; }); + // Splice round-tripped reasoning artifacts back into the assistant + // content. Anthropic rejects an extended-thinking tool-use + // continuation whose thinking blocks lost their `signature`, and + // requires those blocks to lead the content array — so they are + // prepended here, before the tool_use blocks are appended below. + // `reasoning`/`refusal` are output-only fields Anthropic rejects + // outright, and a caller replaying a normalized message carries them. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messages = messages.map((message: any) => { + const details = message.reasoning_details; + delete message.reasoning_details; + delete message.reasoning; + delete message.refusal; + if (!Array.isArray(details)) return message; + const blocks = details.filter( + (block: unknown) => + (block as { type?: string })?.type === 'thinking' || + (block as { type?: string })?.type === 'redacted_thinking', + ); + if (blocks.length === 0) return message; + if (typeof message.content === 'string') { + message.content = message.content + ? [{ type: 'text', text: message.content }] + : []; + } else if (!Array.isArray(message.content)) { + message.content = message.content ? [message.content] : []; + } + message.content = [...blocks, ...message.content]; + return message; + }); + // Convert OpenAI-style tool calls/results to Claude format // eslint-disable-next-line @typescript-eslint/no-explicit-any messages = messages.map((message: any) => { diff --git a/src/backend/drivers/ai-chat/types.ts b/src/backend/drivers/ai-chat/types.ts index 92dcb95cd..d9e83d8c2 100644 --- a/src/backend/drivers/ai-chat/types.ts +++ b/src/backend/drivers/ai-chat/types.ts @@ -96,9 +96,9 @@ export interface ICompleteArguments { max_tokens?: number; temperature?: number; reasoning?: { effort: 'low' | 'medium' | 'high' } | undefined; - text?: string & { verbosity?: 'concise' | 'detailed' | undefined }; + text?: { verbosity?: 'low' | 'medium' | 'high' | undefined } | undefined; reasoning_effort?: 'low' | 'medium' | 'high' | undefined; - verbosity?: 'concise' | 'detailed' | undefined; + verbosity?: 'low' | 'medium' | 'high' | undefined; moderation?: boolean; custom?: unknown; /** diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js index 6bd975d4d..7cb1800f5 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js @@ -92,7 +92,47 @@ export const process_input_messages_responses_api = async (messages) => { // collapsing the whole message into a single compaction item and dropping // the rest of its content. const expanded = []; - for (const msg of messages) { + for (let msg of messages) { + // Round-tripped reasoning artifacts become standalone `reasoning` + // input items — the shape the Responses API expects them back in — + // and precede the message they were attached to, same as compaction. + // `reasoning`/`refusal`/`normalized` are output-only fields the input + // schema rejects, and a caller replaying a normalized message carries + // them along with the details. + if (msg && typeof msg === 'object') { + const details = msg.reasoning_details; + if ( + details !== undefined || + msg.reasoning !== undefined || + msg.refusal !== undefined || + msg.normalized !== undefined + ) { + const { + reasoning_details: _details, + reasoning: _reasoning, + refusal: _refusal, + normalized: _normalized, + ...rest + } = msg; + msg = rest; + } + if (Array.isArray(details)) { + for (const block of details) { + if (!block || block.type !== 'reasoning') continue; + expanded.push({ + type: 'reasoning', + ...(block.id !== undefined ? { id: block.id } : {}), + ...(block.encrypted_content !== undefined + ? { encrypted_content: block.encrypted_content } + : {}), + summary: Array.isArray(block.summary) + ? block.summary + : [], + }); + } + } + } + if (msg && Array.isArray(msg.content)) { const compactionBlocks = msg.content.filter( (c) => c && c.type === 'compaction', @@ -439,6 +479,25 @@ export const create_chat_stream_handler_responses_api = continue; } + // Reasoning summaries stream as their own delta events; route + // them to the same `reasoning` channel the chat-completions + // handler uses for Deepseek/OpenRouter, so a streamed reasoning + // model reads identically whichever API served it. + if (chunk.type === 'response.reasoning_summary_text.delta') { + textblock.addReasoning(chunk.delta); + continue; + } + + // Each summary part is a separate delta stream; separate them with + // a blank line, matching the non-stream handler's join. + if ( + chunk.type === 'response.reasoning_summary_part.added' && + chunk.summary_index > 0 + ) { + textblock.addReasoning('\n\n'); + continue; + } + if (chunk.type === 'response.completed') { last_usage = chunk.response.usage; } @@ -638,11 +697,31 @@ export const handle_completion_output_responses_api = async ({ // Reasoning models return `reasoning` output items; their human-readable // text only exists when the caller requested summaries via // `reasoning: { summary: ... }` (raw chain-of-thought is never returned). - const reasoningText = output - .filter((item) => item?.type === 'reasoning') + const reasoningItems = output.filter((item) => item?.type === 'reasoning'); + const reasoningText = reasoningItems .flatMap((item) => (Array.isArray(item.summary) ? item.summary : [])) .map((part) => (typeof part?.text === 'string' ? part.text : '')) - .join(''); + .filter(Boolean) + .join('\n\n'); + + // The item `id` and `encrypted_content` are what let a caller replay a + // reasoning turn into the next request; they are opaque to us and would + // otherwise be lost, so they ride `reasoning_details` verbatim — the same + // round-trip contract as the `compaction` artifact below and as the + // Anthropic thinking blocks the coercer preserves. + const reasoningDetails = reasoningItems + .filter( + (item) => + item.id !== undefined || item.encrypted_content !== undefined, + ) + .map((item) => ({ + type: 'reasoning', + ...(item.id !== undefined ? { id: item.id } : {}), + ...(item.encrypted_content !== undefined + ? { encrypted_content: item.encrypted_content } + : {}), + ...(Array.isArray(item.summary) ? { summary: item.summary } : {}), + })); const ret = { finish_reason: responseToolCalls.length ? 'tool_calls' : 'stop', @@ -651,6 +730,9 @@ export const handle_completion_output_responses_api = async ({ content: completion.output_text, // String-or-absent, matching every other provider's `reasoning`. ...(reasoningText ? { reasoning: reasoningText } : {}), + ...(reasoningDetails.length + ? { reasoning_details: reasoningDetails } + : {}), refusal: null, role: 'assistant', ...(responseToolCalls.length diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts b/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts index fe68d0bb7..fe5a057fd 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts @@ -292,6 +292,58 @@ describe('process_input_messages_responses_api', () => { ]); }); + it('expands round-tripped reasoning_details into reasoning input items', async () => { + // The replay contract documented in Objects/chatresponse.md: a caller + // resends the whole normalized assistant message, reasoning_details + // included, and the Responses input schema gets back the item shape it + // issued — output-only fields stripped so the request is accepted. + const messages: Array> = [ + { + role: 'assistant', + content: 'earlier reply', + reasoning: 'thought', + refusal: null, + reasoning_details: [ + { + type: 'reasoning', + id: 'rs_1', + encrypted_content: 'ENC', + summary: [{ type: 'summary_text', text: 'thought' }], + }, + ], + }, + ]; + + const out = (await process_input_messages_responses_api( + messages, + )) as Array>; + + expect(out).toHaveLength(2); + // Reasoning item precedes the message it belonged to. + expect(out[0]).toEqual({ + type: 'reasoning', + id: 'rs_1', + encrypted_content: 'ENC', + summary: [{ type: 'summary_text', text: 'thought' }], + }); + expect(out[1]!.role).toBe('assistant'); + // Output-only fields would be rejected by the input schema. + expect('reasoning_details' in out[1]!).toBe(false); + expect('reasoning' in out[1]!).toBe(false); + expect('refusal' in out[1]!).toBe(false); + }); + + it('leaves messages without reasoning artifacts alone', async () => { + const messages: Array> = [ + { role: 'user', content: 'hi' }, + ]; + const out = (await process_input_messages_responses_api( + messages, + )) as Array>; + expect(out).toHaveLength(1); + expect(out[0]!.role).toBe('user'); + }); + it('upgrades user/system text blocks to input_text', async () => { const messages: Array> = [ { @@ -599,6 +651,78 @@ describe('create_chat_stream_handler_responses_api', () => { }); }); + it('routes reasoning-summary deltas to the reasoning channel', async () => { + const completion = asAsyncIterable([ + { + type: 'response.reasoning_summary_text.delta', + delta: 'first thought', + }, + { + type: 'response.reasoning_summary_part.added', + summary_index: 1, + }, + { + type: 'response.reasoning_summary_text.delta', + delta: 'second thought', + }, + { type: 'response.output_text.delta', delta: 'answer' }, + { + type: 'response.completed', + response: { usage: { input_tokens: 1, output_tokens: 2 } }, + }, + ]); + const init = create_chat_stream_handler_responses_api({ + deviations: undefined, + completion, + usage_calculator: () => ({}), + }); + const harness = makeCapturingChatStream(); + await init({ chatStream: harness.chatStream }); + + const events = harness.events(); + // Same event type the chat-completions handler emits, so a streamed + // reasoning model reads identically whichever API served it. + expect( + events + .filter((e) => e.type === 'reasoning') + .map((e) => e.reasoning) + .join(''), + ).toBe('first thought\n\nsecond thought'); + expect( + events.filter((e) => e.type === 'text').map((e) => e.text), + ).toEqual(['answer']); + }); + + it('does not separate the first summary part with a blank line', async () => { + const completion = asAsyncIterable([ + { + type: 'response.reasoning_summary_part.added', + summary_index: 0, + }, + { type: 'response.reasoning_summary_text.delta', delta: 'only' }, + { type: 'response.output_text.delta', delta: 'answer' }, + { + type: 'response.completed', + response: { usage: { input_tokens: 1, output_tokens: 2 } }, + }, + ]); + const init = create_chat_stream_handler_responses_api({ + deviations: undefined, + completion, + usage_calculator: () => ({}), + }); + const harness = makeCapturingChatStream(); + await init({ chatStream: harness.chatStream }); + + expect( + harness + .events() + .filter((e) => e.type === 'reasoning') + .map((e) => e.reasoning) + .join(''), + ).toBe('only'); + }); + it('emits a compaction event when a compaction output_item completes', async () => { const completion = asAsyncIterable([ { @@ -901,6 +1025,92 @@ describe('handle_completion_output_responses_api non-stream', () => { expect(moderate).toHaveBeenCalledWith('questionable content'); }); + it('joins multi-part reasoning summaries with a blank line', async () => { + const completion = { + output: [ + { + type: 'reasoning', + summary: [ + { type: 'summary_text', text: 'First thought.' }, + { type: 'summary_text', text: 'Second thought.' }, + ], + }, + { role: 'assistant', type: 'message' }, + ], + output_text: 'answer', + usage: { input_tokens: 1, output_tokens: 2 }, + }; + const result = await handle_completion_output_responses_api({ + deviations: undefined, + stream: false, + completion, + }); + expect(result.message.reasoning).toBe( + 'First thought.\n\nSecond thought.', + ); + }); + + it('carries reasoning item id and encrypted_content for replay', async () => { + const completion = { + output: [ + { + type: 'reasoning', + id: 'rs_1', + encrypted_content: 'ENC', + summary: [{ type: 'summary_text', text: 'thought' }], + }, + { role: 'assistant', type: 'message' }, + ], + output_text: 'answer', + usage: { input_tokens: 1, output_tokens: 2 }, + }; + const result = await handle_completion_output_responses_api({ + deviations: undefined, + stream: false, + completion, + }); + expect(result.message.reasoning_details).toEqual([ + { + type: 'reasoning', + id: 'rs_1', + encrypted_content: 'ENC', + summary: [{ type: 'summary_text', text: 'thought' }], + }, + ]); + expect(result.message.reasoning).toBe('thought'); + }); + + it('omits reasoning_details when there are no reasoning items', async () => { + const completion = { + output: [{ role: 'assistant', type: 'message' }], + output_text: 'answer', + usage: { input_tokens: 1, output_tokens: 2 }, + }; + const result = await handle_completion_output_responses_api({ + deviations: undefined, + stream: false, + completion, + }); + expect('reasoning_details' in result.message).toBe(false); + }); + + it('omits reasoning entirely when no summaries were requested', async () => { + const completion = { + output: [ + { type: 'reasoning', summary: [] }, + { role: 'assistant', type: 'message' }, + ], + output_text: 'answer', + usage: { input_tokens: 1, output_tokens: 2 }, + }; + const result = await handle_completion_output_responses_api({ + deviations: undefined, + stream: false, + completion, + }); + expect('reasoning' in result.message).toBe(false); + }); + it('returns a stream init descriptor when stream=true', async () => { const completion = asAsyncIterable([]); const result = await handle_completion_output_responses_api({ diff --git a/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts index 393dc8df5..5324b9e1f 100644 --- a/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts +++ b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts @@ -181,11 +181,19 @@ describe('normalizeResultToOpenAI', () => { expect(out.finish_reason).toBe(expected); }); - it('keeps the existing finish_reason for unknown stop_reasons', () => { + it('passes an unmapped vendor stop_reason through verbatim', () => { + // `pause_turn` means "continue this turn"; mapping it to `stop` would + // erase that. Objects/chatresponse.md documents the passthrough. const out = normalizeResultToOpenAI( claudeResult([{ type: 'text', text: 'x' }], 'pause_turn'), ); - expect(out.finish_reason).toBe('stop'); + expect(out.finish_reason).toBe('pause_turn'); + }); + + it('falls back to the existing finish_reason when stop_reason is absent', () => { + const res = claudeResult([{ type: 'text', text: 'x' }]); + delete (res.message as Record).stop_reason; + expect(normalizeResultToOpenAI(res).finish_reason).toBe('stop'); }); it('converts tool_use blocks into OpenAI tool_calls with stringified arguments', () => { @@ -247,10 +255,32 @@ describe('normalizeResultToOpenAI', () => { expect(out.message.content).toBe('answer'); }); - it('drops redacted_thinking, compaction, and unknown blocks', () => { + it('preserves thinking blocks verbatim in reasoning_details for replay', () => { + // Anthropic rejects an extended-thinking continuation whose thinking + // blocks lost their signature, so the raw blocks have to survive. + const out = normalizeResultToOpenAI( + claudeResult([ + { type: 'thinking', thinking: 'step one.', signature: 's1' }, + { type: 'redacted_thinking', data: 'ENC' }, + { type: 'text', text: 'answer' }, + ]), + ); + expect(out.message.reasoning_details).toEqual([ + { type: 'thinking', thinking: 'step one.', signature: 's1' }, + { type: 'redacted_thinking', data: 'ENC' }, + ]); + }); + + it('omits reasoning_details when there was no reasoning', () => { + const out = normalizeResultToOpenAI( + claudeResult([{ type: 'text', text: 'answer' }]), + ); + expect('reasoning_details' in (out.message as object)).toBe(false); + }); + + it('drops compaction and unknown blocks', () => { const out = normalizeResultToOpenAI({ ...claudeResult([ - { type: 'redacted_thinking', data: 'ENC' }, { type: 'compaction', content: 'ENC2' }, { type: 'server_tool_use', id: 'x', name: 'y', input: {} }, { type: 'text', text: 'visible' }, diff --git a/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts index 39c685db1..3629cb61d 100644 --- a/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts +++ b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts @@ -80,13 +80,26 @@ const mapStopReason = ( stop_reason: unknown, fallback: string | undefined, ): string => { - if (typeof stop_reason === 'string') { - const mapped = STOP_REASON_TO_FINISH_REASON[stop_reason]; - if (mapped) return mapped; + if (typeof stop_reason === 'string' && stop_reason !== '') { + // A vendor reason with no OpenAI analog passes through verbatim — the + // same contract the Mistral remap follows and the one + // Objects/chatresponse.md documents. Collapsing e.g. Anthropic's + // `pause_turn` to `stop` would erase a "continue this turn" signal + // the caller needs to act on. + return STOP_REASON_TO_FINISH_REASON[stop_reason] ?? stop_reason; } return fallback ?? 'stop'; }; +/** + * Verbatim provider reasoning blocks, preserved so a normalized message can + * still be replayed into an extended-thinking tool-use continuation. Anthropic + * rejects a continuation whose thinking blocks lost their `signature`, and + * `redacted_thinking` is opaque but must round-trip intact. Modelled on the + * top-level `compaction` artifact: opaque to us, drop-in for the caller. + */ +type ReasoningDetail = Record; + type OpenAIToolCall = { id: unknown; type: 'function'; @@ -98,10 +111,11 @@ type OpenAIToolCall = { * * Returns `res` by reference when the message is already OpenAI-shaped. * Otherwise rebuilds `message` (text blocks joined into a string `content`, - * `tool_use` blocks into `tool_calls`, `thinking` blocks into `reasoning`) and - * remaps `finish_reason` from the Anthropic `stop_reason`. Everything else on - * the result — `usage`, the top-level `compaction` artifact — passes through - * unchanged. The caller owns the `normalized` marker. + * `tool_use` blocks into `tool_calls`, `thinking` blocks into `reasoning` plus + * verbatim `reasoning_details` for replay) and remaps `finish_reason` from the + * Anthropic `stop_reason`, passing an unmapped vendor reason through verbatim. + * Everything else on the result — `usage`, the top-level `compaction` artifact + * — passes through unchanged. The caller owns the `normalized` marker. */ export const normalizeResultToOpenAI = ( res: IChatMessageResult, @@ -127,6 +141,7 @@ export const normalizeResultToOpenAI = ( const textParts: string[] = []; const reasoningParts: string[] = []; + const reasoningDetails: ReasoningDetail[] = []; const toolCalls: OpenAIToolCall[] = []; for (const block of blocks) { @@ -140,6 +155,12 @@ export const normalizeResultToOpenAI = ( if (typeof b.thinking === 'string') { reasoningParts.push(b.thinking); } + reasoningDetails.push({ ...b }); + break; + // Encrypted, so there is no text to surface — but it still has to + // survive the round trip, so it rides `reasoning_details` too. + case 'redacted_thinking': + reasoningDetails.push({ ...b }); break; case 'tool_use': toolCalls.push({ @@ -154,10 +175,10 @@ export const normalizeResultToOpenAI = ( }, }); break; - // `redacted_thinking` is encrypted, and `compaction` already - // rides the result's top-level `compaction` field; both — and - // any block type introduced later — are dropped rather than - // leaked into a shape that has nowhere to put them. + // `compaction` already rides the result's top-level + // `compaction` field; it — and any block type introduced + // later — is dropped rather than leaked into a shape that has + // nowhere to put it. default: break; } @@ -177,6 +198,9 @@ export const normalizeResultToOpenAI = ( refusal: null, ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), ...(reasoning !== undefined ? { reasoning } : {}), + ...(reasoningDetails.length > 0 + ? { reasoning_details: reasoningDetails } + : {}), }, finish_reason: mapStopReason(native.stop_reason, res.finish_reason), }; diff --git a/src/docs/src/AI/chat.md b/src/docs/src/AI/chat.md index a11a35433..76598de99 100755 --- a/src/docs/src/AI/chat.md +++ b/src/docs/src/AI/chat.md @@ -142,9 +142,11 @@ puter.ai.normalize = false; // every chat() call returns the vendor-native form A `normalize` option on an individual call always overrides `puter.ai.normalize`. When neither is set, the release-date rule above decides. Normalized responses carry `normalized: true`. -On a normalized response, extended-thinking output (from reasoning models that expose it) is joined into `message.reasoning`, and Anthropic stop reasons are mapped to OpenAI values (`end_turn` → `stop`, `max_tokens` → `length`, `tool_use` → `tool_calls`, `refusal` → `content_filter`). +On a normalized response, extended-thinking output (from reasoning models that expose it) is joined into `message.reasoning`, and Anthropic stop reasons are mapped to OpenAI values (`end_turn` → `stop`, `max_tokens` → `length`, `tool_use` → `tool_calls`, `refusal` → `content_filter`). A vendor stop reason with no OpenAI equivalent — Anthropic's `pause_turn`, for instance — passes through unchanged, so treat `finish_reason` as an open set. See [`finish_reason`](/Objects/chatresponse) for the full mapping. -Two caveats. The release-date rule applies to the model that actually serves the request — if a request is rerouted to a fallback provider, the served model's release date decides. And normalization drops Anthropic thinking-block signatures, so agentic loops that resend Claude extended-thinking messages in tool-use continuations should request the native format (`normalize: false`). +Normalization does not cost you the ability to continue a reasoning turn. The opaque parts a provider needs back — Anthropic thinking-block signatures, OpenAI reasoning item ids and encrypted content — are preserved verbatim on `message.reasoning_details`. Resend that array as-is alongside the message when you continue an extended-thinking tool-use loop. The artifacts are vendor-specific and only meaningful to the model that produced them, so replay them to the same model — don't carry them across vendors. + +One caveat. The release-date rule applies to the model that actually serves the request — if a request is rerouted to a fallback provider, the served model's release date decides. Streaming is unaffected by normalization: streamed [`ChatResponseChunk`](/Objects/chatresponsechunk) objects already share one format across all vendors. diff --git a/src/docs/src/Objects/chatresponse.md b/src/docs/src/Objects/chatresponse.md index d0ba3383d..a37b0cfc6 100644 --- a/src/docs/src/Objects/chatresponse.md +++ b/src/docs/src/Objects/chatresponse.md @@ -17,7 +17,9 @@ An object containing the chat message data. - `tool_calls` (Array) - An optional array of [`ToolCall`](/Objects/toolcall) objects if the model wants to call tools. -- `reasoning` (String) - Optional extended-thinking output, when the model exposes it. +- `reasoning` (String) - Optional extended-thinking output, when the model exposes it. Multiple reasoning segments are joined with a blank line between them. + +- `reasoning_details` (Array) - Optional opaque reasoning artifacts, present on normalized responses from models that expose them: Anthropic `thinking`/`redacted_thinking` blocks with their `signature`, or OpenAI reasoning items with their `id` and `encrypted_content`. Treat the contents as opaque and resend the array verbatim to continue an extended-thinking turn — providers reject a continuation whose reasoning lost its signature. The human-readable text is in `reasoning`; this field is only for the round trip. - `tool_call_id` (String) - An optional identifier linking this message to the tool call it responds to. @@ -27,7 +29,20 @@ An object containing the chat message data. #### `finish_reason` (String) -Why generation stopped. On normalized responses, known vendor stop reasons map to `stop`, `length`, `tool_calls`, or `content_filter`; a vendor value with no OpenAI analog passes through unchanged. +Why generation stopped. On normalized responses, known vendor stop reasons map to the OpenAI vocabulary — `stop`, `length`, `tool_calls`, or `content_filter` — and a vendor value with no OpenAI analog passes through unchanged rather than being flattened to `stop`. + +Anthropic models are the main source of both cases. Their stop reasons map as follows: + +| Anthropic `stop_reason` | Normalized `finish_reason` | Meaning | +| --- | --- | --- | +| `end_turn` | `stop` | The model finished its turn. | +| `stop_sequence` | `stop` | One of your stop sequences was produced. | +| `max_tokens` | `length` | The token limit was hit mid-answer. | +| `tool_use` | `tool_calls` | The model wants to call a tool; see `message.tool_calls`. | +| `refusal` | `content_filter` | The model declined to continue. | +| `pause_turn` | `pause_turn` | A long-running server-side tool turn was paused — it has no OpenAI analog, so it passes through unchanged. Send the response back as-is to let the model continue. | + +Because unmapped values pass through, treat `finish_reason` as an open set: branch on the four OpenAI values you care about and handle anything else as vendor-specific rather than assuming it means `stop`. #### `normalized` (Boolean) diff --git a/src/puter-js/src/modules/ai/types.js b/src/puter-js/src/modules/ai/types.js index 5d452b3b7..a527066af 100644 --- a/src/puter-js/src/modules/ai/types.js +++ b/src/puter-js/src/modules/ai/types.js @@ -45,6 +45,13 @@ * @property {{ type: string }} [cache_control] * @property {ImageContent[]} [images] Images attached to the message. Present on responses from * image-capable models. + * @property {string} [reasoning] Reasoning/thinking text, when the model exposes it and the + * request asked for it. Present on responses only. + * @property {object[]} [reasoning_details] Opaque provider reasoning artifacts (Anthropic thinking + * signatures, OpenAI reasoning item ids/encrypted content). Resend them verbatim to continue an + * extended-thinking turn. Present on responses only. + * @property {string | null} [refusal] Refusal message when the model declined, otherwise `null`. + * Present on responses only. */ /**