Skip to content

fix(ai): replayed Responses reasoning items are sent without id when the server's item ids have no prefix_suffix shape #55357

Open

Description

@GenosseFlosse

Description

Description

With an OpenAI Responses-compatible gateway that issues opaque item ids (for example 8a482c218291d43b), every follow-up turn after a reasoning response fails with HTTP 400:

JSON deserialization for type '...Responses.Input.ReasoningInputItem' was missing required properties including: 'id'.

Cause: itemID() in packages/ai/src/protocols/open-responses.ts only keeps a stored itemId if it contains a _ that is neither the first nor the last character. The gateway's ids don't, so the id is dropped and the replayed reasoning item is sent as {type: "reasoning", summary, encrypted_content} with no id.

The OpenAI spec doesn't define an id format. In openai/openai-openapi (manual_spec), ReasoningItem.id and OutputMessage.id are plain type: string ("The unique identifier of the reasoning content" / "The unique ID of the output message"), with no pattern, prefix or length. Both are in required. msg_abc123 only appears as an example:. A gateway that issues bare ids and validates id on reasoning input is spec-compliant. api.openai.com itself tolerates a missing id, which is why this isn't noticed there.

Related: the same gateway rejected assistant messages without status (also required in the spec). That is already fixed on dev.

Steps to reproduce

  1. Configure a custom provider (npm: @ai-sdk/openai) pointing at a Responses endpoint whose item ids are bare hex and which validates input against the spec.
  2. Send a prompt to a reasoning model, then a second prompt in the same session.
  3. The second request fails with the 400 above.

The stored reasoning part from the failing session:

{"type":"reasoning","text":"…","state":{"itemId":"8a482c218291d43b","reasoningEncryptedContent":null}}

A minimal request that the gateway rejects, and accepts once id is added:

{"type":"reasoning","summary":[{"type":"summary_text","text":"…"}],"encrypted_content":null}

Expected behavior

A server-issued id is replayed verbatim. If none is available, the item is not sent as an invalid reasoning input.

Notes

The current behavior is covered by the test "preserves provider-issued item ids and removes malformed ids without dropping items", which expects 550e8400-e29b-41d4-a716-446655440000 to be dropped as "No prefix separator". That treats the id shape as a validity signal, which the spec doesn't support. Possible fixes: accept any non-empty id that is a safe token, or keep the heuristic and fall back to a generated rs_… id for reasoning items that would otherwise have none. I'm happy to send a PR for whichever you prefer.

Plugins

No response

OpenCode version

v2.0.29 (built from dev at 34d8ded0c5

Steps to reproduce

No response

Screenshot and/or share link

No response

Operating System

Ubuntu (WSL2)

Terminal

bash

Activity

  1. opencode-agent commented on Oct 14, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on 2.0.29, and it still happens on the latest dev (34d8ded0c5).

    Setup: a custom @ai-sdk/openai provider pointing at a mock Responses server. The mock issues bare ids (reasoning 8a482c218291d43b, message f00dbeef12345678) and, like your gateway, rejects reasoning input items that have no id. That rejection is mocked, as your report describes it; what I checked is the request OpenCode sends.

    • The first opencode run succeeds.
    • The second turn (opencode run -c) fails with JSON deserialization for type 'Responses.Input.ReasoningInputItem' was missing required properties including: 'id'.
    • In the captured request, the replayed reasoning item is {type: "reasoning", summary: [...], encrypted_content: null} with no id, and the assistant message's id is dropped too.

    This matches your analysis of itemID() in packages/ai/src/protocols/open-responses.ts. The id handling there was last changed in #44587. The script below reproduces it.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #55357: replayed Responses reasoning items lose a bare (no "_") server id.
    # Mock Responses server issues ids like 8a482c218291d43b and, like the reporter's
    # gateway, rejects reasoning input items without `id` (spec marks it required).
    # Usage: ./repro.sh            (uses `opencode` on PATH)
    #        OC="bun ... packages/cli/src/index.ts" ./repro.sh   (run from source)
    set -u
    OC=${OC:-opencode}
    DIR=$(mktemp -d /tmp/opencode/repro55357.XXXX); cd "$DIR"
    cat > server.ts <<'TS'
    let n = 0
    Bun.serve({ port: 4555, async fetch(req) {
      const body = await req.json()
      n++
      await Bun.write(`req${n}.json`, JSON.stringify(body, null, 2))
      const bad = (body.input ?? []).filter((i: any) => i.type === "reasoning" && !i.id)
      if (bad.length) return new Response(JSON.stringify({ error: { message: "JSON deserialization for type 'Responses.Input.ReasoningInputItem' was missing required properties including: 'id'.", type: "invalid_request_error" } }), { status: 400, headers: { "content-type": "application/json" } })
      const rid = "8a482c218291d43b", mid = "f00dbeef12345678", resp = "resp" + n
      const ev = (o: any) => `event: ${o.type}\ndata: ${JSON.stringify(o)}\n\n`
      const base = { id: resp, object: "response", created_at: 1, model: "mock-reasoner", status: "in_progress", output: [] }
      const rItem = { id: rid, type: "reasoning", summary: [{ type: "summary_text", text: "thinking" }], encrypted_content: null }
      const mItem = { id: mid, type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "hello " + n, annotations: [] }] }
      const s = [
        ev({ type: "response.created", sequence_number: 0, response: base }),
        ev({ type: "response.output_item.added", sequence_number: 1, output_index: 0, item: { ...rItem, summary: [] } }),
        ev({ type: "response.reasoning_summary_part.added", sequence_number: 2, item_id: rid, output_index: 0, summary_index: 0, part: { type: "summary_text", text: "" } }),
        ev({ type: "response.reasoning_summary_text.delta", sequence_number: 3, item_id: rid, output_index: 0, summary_index: 0, delta: "thinking" }),
        ev({ type: "response.reasoning_summary_text.done", sequence_number: 4, item_id: rid, output_index: 0, summary_index: 0, text: "thinking" }),
        ev({ type: "response.reasoning_summary_part.done", sequence_number: 5, item_id: rid, output_index: 0, summary_index: 0, part: { type: "summary_text", text: "thinking" } }),
        ev({ type: "response.output_item.done", sequence_number: 6, output_index: 0, item: rItem }),
        ev({ type: "response.output_item.added", sequence_number: 7, output_index: 1, item: { ...mItem, status: "in_progress", content: [] } }),
        ev({ type: "response.content_part.added", sequence_number: 8, item_id: mid, output_index: 1, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }),
        ev({ type: "response.output_text.delta", sequence_number: 9, item_id: mid, output_index: 1, content_index: 0, delta: "hello " + n }),
        ev({ type: "response.output_text.done", sequence_number: 10, item_id: mid, output_index: 1, content_index: 0, text: "hello " + n }),
        ev({ type: "response.content_part.done", sequence_number: 11, item_id: mid, output_index: 1, content_index: 0, part: mItem.content[0] }),
        ev({ type: "response.output_item.done", sequence_number: 12, output_index: 1, item: mItem }),
        ev({ type: "response.completed", sequence_number: 13, response: { ...base, status: "completed", output: [rItem, mItem], usage: { input_tokens: 5, output_tokens: 5, total_tokens: 10, output_tokens_details: { reasoning_tokens: 1 }, input_tokens_details: { cached_tokens: 0 } } } }),
      ].join("")
      return new Response(s, { headers: { "content-type": "text/event-stream" } })
    } })
    TS
    cat > opencode.json <<'JSON'
    { "$schema": "https://opencode.ai/config.json",
      "provider": { "gw": { "npm": "@ai-sdk/openai", "name": "Gateway",
        "options": { "baseURL": "http://127.0.0.1:4555/v1", "apiKey": "x" },
        "models": { "mock-reasoner": { "name": "mock", "reasoning": true } } } } }
    JSON
    bun server.ts & SRV=$!; sleep 1
    export XDG_DATA_HOME=$DIR/data XDG_CONFIG_HOME=$DIR/cfg XDG_STATE_HOME=$DIR/state XDG_CACHE_HOME=$DIR/cache
    # SERVE_CMD: optional command to start an opencode server separately (for running from source)
    export OPENCODE_PASSWORD=${OPENCODE_PASSWORD:-repro}
    if [ -n "${SERVE_CMD:-}" ]; then $SERVE_CMD serve --port 4777 >serve.log 2>&1 & OCS=$!; sleep 8; RUNARGS="--server http://127.0.0.1:4777"; else RUNARGS="--standalone"; fi
    $OC run $RUNARGS -m gw/mock-reasoner "first prompt"
    echo "---- second turn ----"
    $OC run $RUNARGS -c -m gw/mock-reasoner "second prompt"
    kill $SRV ${OCS:-}
    echo "---- reasoning input items in second request ----"
    for f in $(ls req*.json | sort -V); do echo "$f:"; bun -e 'const b=JSON.parse(await Bun.file(process.argv[1]).text()); console.log(JSON.stringify((b.input??[]).filter(i=>i.type==="reasoning"||i.role==="assistant"),null,1))' "$f"; done

    Run it: bash repro.sh

  2. self-assigned this
    on Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions