---
url: 'https://adk.nht.io/batteries/llm/openai-responses.md'
description: >-
  OpenAI's Responses API wire: flat input items instead of messages[], sibling
  function_call/function_call_output items, native reasoning-item replay with
  signed encrypted_content, document (input_file) media, and a stateless
  store:false design.
---

# OpenAI Responses

## LLM summary — OpenAI Responses

* [`OpenAIResponsesAdapter`](https://adk.nht.io/api/@nhtio/adk/batteries/llm/openai_responses/adapter/classes/OpenAIResponsesAdapter) from `@nhtio/adk/batteries/llm/openai_responses`. Drives OpenAI's Responses API directly — a flat `input: Item[]` array, not `messages[]`. No `openai` SDK dependency; hand-rolled `fetch` + SSE, mirroring `openai_chat_completions`'s transport.
* Required: `model`. The battery owns `instructions` (system prompt) and `store` (always `false`) — neither is a settable option. `previous_response_id`, `conversation`, `prompt`, and `context_management` are all hard-rejected by the schema (`.unknown(false)`); ADK owns history, the full `input` array is resent every iteration.
* A tool call is two sibling top-level items: `function_call` then `function_call_output` — not one message holding both. Composite `ToolCall.id`s pack `` `${callId}|${itemId}` ``.
* `systemPromptChannel: 'instructions' | 'developer-item' | 'system-item'` (default `'instructions'`) controls where the ADK-rendered system prompt lands; it never changes what it contains.
* Reasoning replay (`reasoningReplay: 'off' | 'encrypted' | 'summary-only'`, default `'off'`) replays signed `reasoning` items subject to an adjacency-sweep pass enforcing the undocumented pairing constraint (`openai/openai-node#1791`): an unpaired `reasoning` item is dropped, never sent. OpenAI's own docs contradict that constraint (they say stray reasoning items are silently discarded), so a reasoning-related 400 — pairing violation or `invalid_encrypted_content` — is recoverable, not fatal: the adapter strips every reasoning item and retries once, degrading to no-replay rather than failing the turn.
* `max_output_tokens` has an undocumented API minimum of 16 (`400 Invalid 'max_output_tokens'... Expected a value >= 16`) — enforced via `.min(16)` at config time.
* Media: image → `input_image` (data URI). Document → `input_file` with `file_data: 'data:<mime>;base64,<b64>'` — confirmed shipped via a live probe against the real API. Audio and video have no Responses representation and route through `unsupportedMediaPolicy` / `E_OPENAI_RESPONSES_UNSUPPORTED_MEDIA_MODALITY` — unlike `openai_chat_completions`, which supports audio natively.
* Exceptions: `E_INVALID_OPENAI_RESPONSES_OPTIONS`, `E_OPENAI_RESPONSES_{CONTEXT_OVERFLOW,HTTP_ERROR,STREAM_ERROR,STREAM_STALLED,REQUEST_TIMEOUT,INVALID_TOOL_CALL_ARGS,UNSUPPORTED_MEDIA_MODALITY,REASONING_REPLAY_REJECTED}`.

The Responses API is not Chat Completions with a different endpoint. It replaces the flat `messages[]`
array with a flat `input: Item[]` array, where a tool call and its result are two **sibling top-level
items** rather than one message that contains both, and it introduces a native `reasoning` item with a
server-signed `encrypted_content` payload that can be replayed across turns under specific, undocumented
constraints. This battery speaks that wire directly.

```ts
import { OpenAIResponsesAdapter } from '@nhtio/adk/batteries/llm/openai_responses'

const executor = new OpenAIResponsesAdapter({
  model: 'gpt-5.6-luna',
  apiKey: process.env.OPENAI_API_KEY,
  autoAck: true,
})

// executor.executor() is the DispatchExecutorFn you hand to TurnRunner.
```

No `openai` SDK dependency is required — like `openai_chat_completions`, this battery hand-rolls
`fetch` + SSE parsing rather than wrapping the official client.

## Why this battery exists alongside Chat Completions

Chat Completions is simpler, more widely compatible with OpenAI-shaped gateways, and — unlike this
battery — supports audio input natively. It is still the right default for most use.

Reach for this battery instead when you specifically need what only the Responses wire exposes:

* **Reasoning-item continuity.** A `reasoning` item's `encrypted_content` lets a reasoning model's
  internal chain-of-thought survive across turns even under `store: false`, subject to the
  adjacency/pairing rules below. Chat Completions has no equivalent structure.
* **Hosted, server-side tools.** Responses models can invoke built-in tools (web search, code
  interpreter, MCP) that emit their own output items directly into the response, independent of the
  ADK tool-call loop.
* **Document input.** Native `input_file` support for documents (see Media below), which Chat
  Completions does not expose in the same shape.

If none of that applies to your use case, `openai_chat_completions` is simpler to reason about and
covers more ground (including audio).

## `input` is items, not messages

Chat Completions nests a tool call and its result inside a `messages[]` entry with a `tool_calls` array
and a following `tool` message. The Responses wire has no such nesting — a tool call and its result are
two independent, sibling entries in a flat `input` array:

```ts
// Chat Completions shape — tool call + result live inside messages[]
{
  messages: [
    { role: 'assistant', content: null, tool_calls: [{ id: 'call_1', function: { name: 'lookup', arguments: '{"q":"weather"}' } }] },
    { role: 'tool', tool_call_id: 'call_1', content: '72F and sunny' },
  ]
}

// Responses shape — the SAME turn, as two sibling top-level items in `input`
{
  input: [
    { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"q":"weather"}' },
    { type: 'function_call_output', call_id: 'call_1', output: '72F and sunny' },
  ]
}
```

`buildOpenAIResponsesInput` produces this directly: a `ToolCall` renders as a `function_call` item
immediately followed by its `function_call_output` sibling. ADK's composite tool-call id
(`` `${callId}|${itemId}` ``) is split back into the wire's separate `call_id` and item `id` fields; a
non-`fc_`-prefixed item id is dropped rather than sent as an invalid one.

The system prompt has a parallel structural difference: by default it is not an `input` item at all,
but a top-level `instructions` string. `systemPromptChannel` can redirect it to a leading
`developer`-role or `system`-role item instead, for gateways that only understand the item form — but
either way, the content is always ADK-rendered; there is no consumer-facing `instructions` option to
hand-author it directly.

## Statelessness: `store: false`, always

This battery never uses the Responses API's server-side conversation state. `store: false` is sent on
every request, unconditionally — it is not a default that can be overridden. The options schema
hard-rejects an explicit `store: true` at validation time with a clear
`E_INVALID_OPENAI_RESPONSES_OPTIONS` message, because the adapter never reads `previous_response_id`
back, so `store: true` would silently accomplish nothing.

For the same reason, the options schema is `.unknown(false)` at the top level and rejects every
server-side-state key outright, rather than silently ignoring them:

* `previous_response_id`
* `conversation`
* `prompt`
* `context_management`
* `instructions` (ADK-rendered only — see above; not a settable option at all)
* `store` (always `false` on the wire; setting it to anything fails validation)

ADK owns history, the system prompt, standing instructions, memories, and retrievables on every LLM
battery — this one simply has no escape hatch for a consumer to hand off either history or the system
prompt to OpenAI's own state. The full `input` array — including every prior tool call and its
result — is resent on every iteration.

### `background` is not supported

The Responses API's `background: true` option runs generation asynchronously: the initial `POST`
returns immediately with a `queued`/`in_progress` status and no output, and the caller is expected to
poll `GET /v1/responses/{id}` until the response reaches a terminal status. This battery has no such
polling/resumption logic, so `background: true` is rejected at validation time with
`E_INVALID_OPENAI_RESPONSES_OPTIONS` — accepting it silently would otherwise treat the initial
`queued`/`in_progress` response as a completed, empty answer and discard whatever the background job
eventually produces. `background: false` (the default) is unaffected.

## The reasoning-replay gotchas

`reasoningReplay` (default `'off'`) controls whether persisted `Thought` records replay back onto the
wire as native `reasoning` items: `'off'` never replays (thoughts render as plain text instead),
`'encrypted'` replays via `encrypted_content` (auto-adding `'reasoning.encrypted_content'` to
`include`), and `'summary-only'` replays via the summary text only.

**Setting the mode alone is not enough.** Native replay also requires
`replayCompatibility: ['openai-responses-reasoning-v1']`. That option defaults to an empty list, and
a persisted `Thought` whose `replayCompatibility` tag is absent from it is silently skipped — so
`reasoningReplay: 'encrypted'` on its own performs no native replay at all, and thoughts fall back
to rendering as plain text. Getting this right is the
highest-risk part of the battery, because the constraints below are undocumented and were confirmed
against real GitHub issues, not the API reference:

1. **Reasoning/output item pairing is an undocumented hard constraint.** A `reasoning` item not
   immediately followed by its paired output item gets rejected with a 400 — reproduced across five
   official SDKs (`openai/openai-node#1791`). `buildOpenAIResponsesInput` runs an adjacency-sweep pass
   after assembling `input`: it walks the array left to right, drops any `reasoning` item not
   immediately followed by its paired item, and strips the `id` from the paired item if its reasoning
   partner was just dropped (an id-less item isn't subject to the pairing check). This is why
   `reasoningReplay` defaults to `'off'` — replay is opt-in specifically because of this constraint.
   The constraint's provenance, the fact that OpenAI's published docs contradict it, and what that
   means for how a rejection is handled are covered in
   [Where the pairing strategy comes from](#where-the-pairing-strategy-comes-from-and-why-it-degrades-instead-of-failing)
   below.
2. **`max_output_tokens` has an undocumented minimum of 16.** OpenAI returns `400 Invalid
   'max_output_tokens'... Expected a value >= 16` (`earendil-works/pi#6265`). The schema enforces
   `.min(16)` so this fails at config time, not mid-dispatch.
3. **Reasoning-signature capture must happen at `output_item.done`, never `.added`.** Capturing a
   reasoning item's signature/`encrypted_content` when the item is first announced (`.added`) reads it
   before the field is populated, permanently losing the reasoning chain under `store: false` — a
   documented bug in another project's Responses integration (`langchain-ai/langchainjs#10844`). This
   battery's output-slot state machine only finalizes and captures `encrypted_content` at
   `output_item.done`, with a cheap backfill from `response.output` at `response.completed`/
   `.incomplete` for any reasoning item whose `.done` event omitted it.
4. **No `name` field on Responses message items.** The structural half of the cross-battery
   dual-channel-identity convention is unavailable here, so this battery always emits the full identity
   envelope for non-self identities rather than relying on a `name` shortcut.
5. **64-character item-id limit.** Ids over 64 characters are rejected. `normalizeOpenAIResponsesItemId`
   hashes and truncates an oversized id (`msg_<shorthash>`) rather than sending it verbatim.
6. **Hosted server-side tool output items can appear unbidden.** Models with built-ins enabled (web
   search, code interpreter, MCP, etc.) can emit their own item types even without the consumer asking.
   The output-slot state machine opens no slot for an unrecognized item type — it's logged at debug and
   never replayed back into `input`.

### Where the pairing strategy comes from, and why it degrades instead of failing

The adjacency sweep exists because of one specific upstream report, and it is worth stating plainly
that the evidence for it **conflicts with OpenAI's own documentation** — the battery is written
against observed behavior, not against a spec.

**The source.** [`openai/openai-node#1791`](https://github.com/openai/openai-node/issues/1791)
(filed March 2026, closed via PR #1808) reports that reasoning and message items must travel as
adjacent pairs in `input`, and that the requirement is written down nowhere. The reported error is
`400: Item 'msg_...' of type 'message' was provided without its required preceding item of type
'reasoning'`. The constraint is **bidirectional** — the mirror case, a reasoning item rejected for
missing its required *following* item, is
[`openai/openai-agents-python#1660`](https://github.com/openai/openai-agents-python/issues/1660).
The filer reproduced it across the JS, Python, Go, Java, and .NET SDKs and concluded it is an
API-level constraint rather than an SDK bug. The ordinary pattern that triggers it — looping over
`response.output` and keeping only the `message` items — orphans whatever it discards.

**The contradiction.** OpenAI's published guidance says the opposite. The
[reasoning guide](https://developers.openai.com/api/docs/guides/reasoning) states that "our systems
will smartly ignore any reasoning items that aren't relevant to your functions", and the
[reasoning-items cookbook](https://developers.openai.com/cookbook/examples/responses_api/reasoning_items)
says that including stray items "is harmless — the API will simply discard any reasoning items that
aren't relevant for the current turn." What the docs *do* require is span completeness ("ensure all
items between the last user message and your function call output are passed into the next response
untouched") — a weaker rule than pairwise adjacency.

**What this battery does about it.** The sweep enforces the stricter rule, because being stricter can
only drop more than necessary — it cannot itself provoke a 400. But since the constraint may not hold
(and #1791 is closed against a merged PR, so upstream behavior may already have moved), a rejection
is treated as **recoverable rather than fatal**: replay degrades to no-replay and the turn completes,
instead of the turn dying on a rule we cannot verify.

Accordingly the adapter collapses both reasoning-related 400s into one recovery path:

* A 400 matching `invalid_encrypted_content` (a persisted `Thought`'s signature outliving a
  server-side key rotation) **or** a known pairing-violation phrase (`"of type 'reasoning' was
  provided without"`, `"Items are not persisted when store is set to false"`) drops every reasoning
  item from the resolved input and retries the request **once**, rather than failing the dispatch.
  The retry is one-shot and sits outside the `retry.maxAttempts` budget, so a genuinely broken
  request cannot loop.
* `E_OPENAI_RESPONSES_REASONING_REPLAY_REJECTED` is reserved for a pairing 400 that survives that
  reasoning-free retry. At that point the rejection is demonstrably *not* attributable to a replayed
  reasoning item, so the self-explaining error — naming the offending item and suggesting
  `reasoningReplay: 'off'` — is the honest answer rather than a recovery already attempted.

## Media

| `Media.kind` | Responses shape |
|---|---|
| `image` | `{ type: 'input_image', detail: 'auto', image_url: 'data:<mime>;base64,<b64>' }` |
| `document` | `{ type: 'input_file', filename, file_data: 'data:<mime>;base64,<b64>' }` |
| `audio` | `unsupportedMediaPolicy` (`throw` / `fallback-stash` / `synthetic-description`) |
| `video` | `unsupportedMediaPolicy` |

Document support **ships in v1**: the exact `file_data` shape — a full `data:<mime>;base64,<b64>` data
URI (not bare base64), sent alongside `filename` — was confirmed by a live probe against the real
Responses API before this code was written, not assumed from the type sketch alone.

Audio and video have no native representation on this wire at all — the Responses input-content union
has no audio member and no video member (confirmed against the `openai` SDK's own type definitions).
This is the sharpest divergence from `openai_chat_completions`, which does support audio input: on this
battery, both audio and video always route through `unsupportedMediaPolicy`, defaulting to
`E_OPENAI_RESPONSES_UNSUPPORTED_MEDIA_MODALITY`. Configure `unsupportedMediaPolicy` to
`'fallback-stash'` or `'synthetic-description'` to degrade instead of throwing.

## Exceptions

| Exception | args | status | fatal |
|---|---|---|---|
| `E_INVALID_OPENAI_RESPONSES_OPTIONS` | `[string]` | 529 | yes |
| `E_OPENAI_RESPONSES_CONTEXT_OVERFLOW` | `[number, number, string, string]` | 529 | yes |
| `E_OPENAI_RESPONSES_HTTP_ERROR` | `[number, string]` | 502 | no |
| `E_OPENAI_RESPONSES_STREAM_ERROR` | `[string]` | 502 | no |
| `E_OPENAI_RESPONSES_STREAM_STALLED` | `[number]` | 504 | no |
| `E_OPENAI_RESPONSES_REQUEST_TIMEOUT` | `[number]` | 504 | no |
| `E_OPENAI_RESPONSES_INVALID_TOOL_CALL_ARGS` | `[string, string]` | 422 | no |
| `E_OPENAI_RESPONSES_UNSUPPORTED_MEDIA_MODALITY` | `[string, string, string]` | 422 | yes |
| `E_OPENAI_RESPONSES_REASONING_REPLAY_REJECTED` | `[string, string]` | 422 | no |

`E_INVALID_OPENAI_RESPONSES_OPTIONS` is thrown when resolved adapter options (constructor, executor
overrides, or per-dispatch `stash.openaiResponses`) fail validation. `E_OPENAI_RESPONSES_CONTEXT_OVERFLOW`
is raised only when `tokenEncoding` is non-null and the resolved request's token weight exceeds
`contextWindow`; its message carries the per-bucket breakdown. `E_OPENAI_RESPONSES_HTTP_ERROR`,
`_STREAM_ERROR`, `_STREAM_STALLED`, and `_REQUEST_TIMEOUT` are all non-fatal and surfaced via
`ctx.nack(...)`.

Reaching EOF without ever observing a `response.completed`/`.incomplete`/`.failed` terminal event is
**not** an error: the Responses SSE stream has no `[DONE]` sentinel, so a truncated-looking stream is
indistinguishable from a short one. The adapter warn-logs
(`kind: 'sse-eof-without-terminal-event'`) and drains whatever it accumulated — persisting the text,
thoughts, and tool calls received so far — rather than discarding a usable partial turn.
`E_OPENAI_RESPONSES_STREAM_ERROR` is reserved for an actual transport/parse failure or an explicit
upstream `response.failed`/`error` event.

`E_OPENAI_RESPONSES_INVALID_TOOL_CALL_ARGS` is never thrown by the adapter directly — it's instantiated
inside `executeAndPersistToolCall`, formatted into a `Tokenizable`, and persisted as an error-flagged
`ToolCall` result so the model sees it on the next iteration and can self-correct.
`E_OPENAI_RESPONSES_UNSUPPORTED_MEDIA_MODALITY` is fatal and only reached under
`unsupportedMediaPolicy: 'throw'` (see Media above). `E_OPENAI_RESPONSES_REASONING_REPLAY_REJECTED` is
the self-explaining translation of a pairing-violation 400 — raised only when the one-shot
reasoning-free retry described above has already been tried and the 400 persisted, since a rejection
that survives dropping every reasoning item is not attributable to reasoning replay.

Full option details are in [Assembly → LLM batteries](/assembly/batteries-llm).
