Skip to content

@ai-sdk/open-responses: convertToOpenResponsesInput silently drops assistant reasoning parts (asymmetric round trip) #18513

Description

@Astro-Han

The Open Responses input converter reads reasoning items out of a provider response but cannot write them back. Feeding the SDK's own output straight back into it — what every agent loop does — loses the reasoning part.

This is a serialization defect, reproducible with no API key, no network and no provider. It is not an HTTP-status bug.

Reproduction

@ai-sdk/open-responses@2.0.23. Exits non-zero on main today.

import assert from 'node:assert/strict';
import { createOpenResponses } from '@ai-sdk/open-responses';

const REASONING = 'REASONING THAT MUST SURVIVE THE ROUND TRIP';
let lastBody;

function modelReturning(output) {
  return createOpenResponses({
    apiKey: 'not-used',
    baseURL: 'https://example.invalid/v1',
    fetch: async (_url, init) => {
      lastBody = 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' } });
    },
  })('any-model');
}

// Step 1: the provider returns a reasoning item alongside a tool call.
const step1 = await modelReturning([
  { type: 'reasoning', id: 'rs_1', content: [{ type: 'reasoning_text', text: REASONING }], summary: [] },
  { type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 't', arguments: '{}' },
]).doGenerate({
  prompt: [{ role: 'user', content: [{ type: 'text', text: 'q' }] }],
  tools: [{ type: 'function', name: 't', inputSchema: { type: 'object', properties: {} } }],
});

const reasoningRead = step1.content.filter((p) => p.type === 'reasoning');
console.log('step 1 — reasoning parts the SDK produced:', reasoningRead.length,
            JSON.stringify(reasoningRead.map((p) => p.text)));

// Step 2: hand the SDK's own output straight back to it, as an agent loop does.
await modelReturning([]).doGenerate({
  prompt: [
    { role: 'user', content: [{ type: 'text', text: 'q' }] },
    { role: 'assistant', content: step1.content },
    { 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: {} } }],
});

const sent = lastBody.input.filter((i) => i.type === 'reasoning');
console.log('step 2 — request input items:', lastBody.input.map((i) => i.type ?? `message:${i.role}`).join(', '));
console.log('step 2 — reasoning items sent:', sent.length);

assert.ok(reasoningRead.length > 0, 'precondition: the SDK did not read the reasoning item back');
assert.equal(sent.length, reasoningRead.length,
  `asymmetric round trip: the SDK produced ${reasoningRead.length} reasoning part(s) from the response, ` +
  `but serialized ${sent.length} back into the next request.`);

Actual output:

step 1 — reasoning parts the SDK produced: 1 ["REASONING THAT MUST SURVIVE THE ROUND TRIP"]
step 2 — request input items: message, function_call, function_call_output
step 2 — reasoning items sent: 0

AssertionError: asymmetric round trip: the SDK produced 1 reasoning part(s) from the
response, but serialized 0 back into the next request.

Expected: step 2 sends 1 reasoning item. The pass/fail signal is the assertion's exit code — nothing depends on a provider's reply.

Cause

convertToOpenResponsesInput, assistant branch (packages/open-responses/src/responses/convert-to-open-responses-input.ts; quoted here from the published dist/index.js so it can be checked without the repo):

case "assistant": {
  const assistantContent = [];
  const toolCalls = [];
  for (const part of content) {
    switch (part.type) {
      case "text": { ... }
      case "tool-call": { ... }
    }
  }

The switch has arms for text and tool-call only — no reasoning arm and no default, so a reasoning part falls through with no output and no warning. The response side does build reasoning parts, which is why the round trip is asymmetric rather than uniformly unsupported.

Why it matters

Providers on this wire may require the reasoning chain to be replayed across a tool loop. Where they do, the omitted item either raises an error or is silently reconstructed by the provider from its own records — the latter masks the loss while the request is still billed for reasoning the SDK never sent.

Note on #18511

This was reported in #18511 and closed as not reproducible. That report led with an HTTP 400, and the triage correctly found the 400 did not occur — DeepSeek recovers reasoning associated with a call_id it issued, so the omission is masked in an end-to-end loop. The same triage report recorded the underlying gap directly: "The captured round-two request omitted the reasoning item, confirming the secondary serialization gap in convert-to-open-responses-input.ts." This issue drops the provider and the status code entirely and asserts only the serialization invariant, so the reproduction no longer depends on whether a provider compensates.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions