mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-27 16:37:18 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user