OpenAI Responses
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.
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
reasoningitem'sencrypted_contentlets a reasoning model's internal chain-of-thought survive across turns even understore: 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_filesupport 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:
// 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_idconversationpromptcontext_managementinstructions(ADK-rendered only — see above; not a settable option at all)store(alwaysfalseon 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:
- Reasoning/output item pairing is an undocumented hard constraint. A
reasoningitem not immediately followed by its paired output item gets rejected with a 400 — reproduced across five official SDKs (openai/openai-node#1791).buildOpenAIResponsesInputruns an adjacency-sweep pass after assemblinginput: it walks the array left to right, drops anyreasoningitem not immediately followed by its paired item, and strips theidfrom the paired item if its reasoning partner was just dropped (an id-less item isn't subject to the pairing check). This is whyreasoningReplaydefaults 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 below. max_output_tokenshas an undocumented minimum of 16. OpenAI returns400 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.- Reasoning-signature capture must happen at
output_item.done, never.added. Capturing a reasoning item's signature/encrypted_contentwhen the item is first announced (.added) reads it before the field is populated, permanently losing the reasoning chain understore: false— a documented bug in another project's Responses integration (langchain-ai/langchainjs#10844). This battery's output-slot state machine only finalizes and capturesencrypted_contentatoutput_item.done, with a cheap backfill fromresponse.outputatresponse.completed/.incompletefor any reasoning item whose.doneevent omitted it. - No
namefield 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 anameshortcut. - 64-character item-id limit. Ids over 64 characters are rejected.
normalizeOpenAIResponsesItemIdhashes and truncates an oversized id (msg_<shorthash>) rather than sending it verbatim. - 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 (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. 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 states that "our systems will smartly ignore any reasoning items that aren't relevant to your functions", and the reasoning-items cookbook 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 persistedThought'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 theretry.maxAttemptsbudget, so a genuinely broken request cannot loop. E_OPENAI_RESPONSES_REASONING_REPLAY_REJECTEDis 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 suggestingreasoningReplay: '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.