fix(ai): round-trip reasoning artifacts and pass unmapped stop reasons through

Closes the reasoning gaps left open by the normalization work.

Reasoning replay. The coercer dropped Anthropic thinking-block signatures and
the Responses handler dropped reasoning item ids/encrypted_content, so a
normalized reasoning turn could not be replayed — Anthropic rejects an
extended-thinking tool-use continuation whose thinking blocks lost their
signature. Both now ride `message.reasoning_details` verbatim, and both input
paths accept them back: ClaudeProvider splices the blocks ahead of the content
(Anthropic requires them to lead), and the Responses input processor expands
them into standalone `reasoning` items. Output-only fields a replayed message
carries (`reasoning`, `refusal`, `normalized`) are stripped on both paths,
since neither upstream accepts them. The docs caveat recommending
`normalize: false` for agentic Claude loops is gone; it is no longer true.

Unmapped stop reasons. chatresponse.md promised a vendor `finish_reason` with
no OpenAI analog "passes through unchanged" — true for the Mistral remap, false
for the Anthropic coercer, which discarded it. Anthropic's `pause_turn` means
"continue this turn", so flattening it to `stop` destroyed the signal. The
coercer now passes unmapped values through verbatim, matching both the doc and
the Mistral path, and the docs gain the full Anthropic stop-reason table.

Reasoning summaries. Multi-part summaries joined with '' instead of a blank
line, and the streaming Responses path emitted no reasoning at all;
`response.reasoning_summary_text.delta` now feeds the same `reasoning` stream
channel the chat-completions handler uses.

Types. `text?: string & { verbosity?: ... }` was an uninhabitable intersection
(providers read `text?.verbosity` as an object), and the verbosity enum was
`'concise' | 'detailed'` where OpenAI accepts `'low' | 'medium' | 'high'`.
Adds `reasoning`, `reasoning_details`, and `refusal` to the SDK ChatMessage
typedef.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
404oops
2026-08-27 15:18:34 +02:00
co-authored by Claude Opus 5
parent ee00554777
commit 2b19ca4440
10 changed files with 515 additions and 25 deletions
@@ -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<string, unknown>;
const content = assistant.content as Array<Record<string, unknown>>;
// 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<string, unknown>;
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);
@@ -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) => {
+2 -2
View File
@@ -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;
/**
@@ -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
@@ -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<Record<string, unknown>> = [
{
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<Record<string, unknown>>;
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<Record<string, unknown>> = [
{ role: 'user', content: 'hi' },
];
const out = (await process_input_messages_responses_api(
messages,
)) as Array<Record<string, unknown>>;
expect(out).toHaveLength(1);
expect(out[0]!.role).toBe('user');
});
it('upgrades user/system text blocks to input_text', async () => {
const messages: Array<Record<string, unknown>> = [
{
@@ -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({
@@ -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<string, unknown>).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' },
@@ -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<string, unknown>;
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),
};
+4 -2
View File
@@ -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.
+17 -2
View File
@@ -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)
+7
View File
@@ -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.
*/
/**