Description
convertToOpenResponsesInput() handles only text and tool-call parts in the assistant branch — there is no reasoning case. Reasoning received from the provider is parsed correctly on the response side but is silently dropped when the conversation is sent back, so the round trip is asymmetric.
Reproduction
Deterministic, and needs no API key, no network and no particular provider:
import { createOpenResponses } from '@ai-sdk/open-responses';
let body;
const fetch = async (_url, init) => {
body = JSON.parse(String(init.body));
return new Response(
JSON.stringify({ id: 'r', object: 'response', created_at: 0, model: 'm',
status: 'completed', output: [], usage: { input_tokens: 1, output_tokens: 1 } }),
{ status: 200, headers: { 'content-type': 'application/json' } });
};
const model = createOpenResponses({
apiKey: 'test', baseURL: 'https://example.invalid/v1', fetch,
})('any-model');
await model.doGenerate({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'q' }] },
{ role: 'assistant', content: [
{ type: 'reasoning', text: 'REASONING THAT MUST BE REPLAYED' },
{ type: 'tool-call', toolCallId: 'call_1', toolName: 't', input: {} },
]},
{ role: 'tool', content: [
{ type: 'tool-result', toolCallId: 'call_1', toolName: 't',
output: { type: 'json', value: { ok: true } } },
]},
],
tools: [{ type: 'function', name: 't', inputSchema: { type: 'object', properties: {} } }],
});
console.log('reasoning items sent:', body.input.filter((i) => i.type === 'reasoning').length);
console.log(body.input.map((i) => i.type ?? `message:${i.role}`).join(', '));
reasoning items sent: 0
message, function_call, function_call_output
A reasoning part goes in; no reasoning item comes out.
Why it matters
For providers that merely tolerate missing reasoning this is invisible. For providers that require reasoning to be replayed inside a tool loop it makes multi-step tool use impossible.
DeepSeek (deepseek-v4-flash, https://api.deepseek.com/v1/responses) requires it — thinking mode is on by default there, so any tool loop is affected. Omitting the reasoning item returns:
The `reasoning_text` in the thinking mode must be passed back to the API.
Replaying it by hand as {"type":"reasoning","content":[{"type":"reasoning_text","text":...}]}, using a call_id the server cannot associate with a record of its own, and varying only the replayed length:
| replayed reasoning |
HTTP |
usage.input_tokens |
| omitted |
400 |
— |
| empty string |
400 |
— |
| 1 char |
200 |
472 |
| 3653 chars |
200 |
1388 |
| 7307 chars |
200 |
2189 |
| 14615 chars |
200 |
4022 |
Input token accounting scales with the replayed payload, so it is loaded into context rather than accepted and ignored.
Note on server-side recovery
A tool loop run end to end against DeepSeek will often not surface the 400. DeepSeek recovers the reasoning associated with a call_id it issued itself, so as long as the SDK forwards that id unchanged, the omission is masked and the request succeeds — while still being billed for reasoning the SDK never sent.
That recovery is DeepSeek's own behaviour, not part of the Open Responses shape, and another provider implementing that shape need not offer anything comparable. Isolating it with an unassociated call_id is what makes the omission observable, which is why the table above uses one. The serialization gap itself is independent of all of this — see the reproduction above.
Expected
The assistant branch should emit reasoning parts as Open Responses reasoning items, positioned with the function_call they belong to:
{"type": "reasoning", "content": [{"type": "reasoning_text", "text": "..."}]}
This is the same shape the package already parses on the response side (case "reasoning": for (const contentPart of part.content) ...).
Notes
Environment
@ai-sdk/open-responses 2.0.23
Description
convertToOpenResponsesInput()handles onlytextandtool-callparts in theassistantbranch — there is noreasoningcase. Reasoning received from the provider is parsed correctly on the response side but is silently dropped when the conversation is sent back, so the round trip is asymmetric.Reproduction
Deterministic, and needs no API key, no network and no particular provider:
A reasoning part goes in; no reasoning item comes out.
Why it matters
For providers that merely tolerate missing reasoning this is invisible. For providers that require reasoning to be replayed inside a tool loop it makes multi-step tool use impossible.
DeepSeek (
deepseek-v4-flash,https://api.deepseek.com/v1/responses) requires it — thinking mode is on by default there, so any tool loop is affected. Omitting the reasoning item returns:Replaying it by hand as
{"type":"reasoning","content":[{"type":"reasoning_text","text":...}]}, using acall_idthe server cannot associate with a record of its own, and varying only the replayed length:usage.input_tokensInput token accounting scales with the replayed payload, so it is loaded into context rather than accepted and ignored.
Note on server-side recovery
A tool loop run end to end against DeepSeek will often not surface the 400. DeepSeek recovers the reasoning associated with a
call_idit issued itself, so as long as the SDK forwards that id unchanged, the omission is masked and the request succeeds — while still being billed for reasoning the SDK never sent.That recovery is DeepSeek's own behaviour, not part of the Open Responses shape, and another provider implementing that shape need not offer anything comparable. Isolating it with an unassociated
call_idis what makes the omission observable, which is why the table above uses one. The serialization gap itself is independent of all of this — see the reproduction above.Expected
The
assistantbranch should emit reasoning parts as Open Responses reasoning items, positioned with thefunction_callthey belong to:{"type": "reasoning", "content": [{"type": "reasoning_text", "text": "..."}]}This is the same shape the package already parses on the response side (
case "reasoning": for (const contentPart of part.content) ...).Notes
@ai-sdk/openai's Responses implementation is not an alternative here: it speaks OpenAI's proprietarysummary[]+encrypted_contentextension, and its request-side pass drops any reasoning item whoseencrypted_contentis null whenstoreis nottrue. Providers following the Open Responses shape lose reasoning in both directions there.Environment
@ai-sdk/open-responses2.0.23