ADR-203: Tool calling on an OpenAI reasoning model goes through Responses
- Status
-
Accepted
- Date
-
2026-09-23
- Authors
-
Netresearch DTT GmbH
Context
Open posts every tool request to
chat/. OpenAI refuses that request for a GPT-6 model at its own
default reasoning effort, which is medium for Sol and Luna. The three
models differ in what they allow, and the difference decides the design:
gpt-and6- sol gpt-accept function tools on6- luna chat/only withcompletions reasoning_set toeffort none.gpt-supports no6- astra noneeffort at all — the value returns HTTP 400 — and accepts no function calling onchat/under any setting.completions
So the parameter that would fix the first two does not exist for the third. OpenAI's own answer is the Responses API, and its reasoning guide requires that every item between the last user message and a function-call output is passed into the next request untouched, so the model can carry its reasoning across the steps of one run.
Nothing in this extension carried provider state between two calls. A
Completion returns content, tool calls and usage; the tool loop
rebuilds the next request from Chat objects alone, and a
suspended run is persisted as Chat shapes.
Decision
- The transport is chosen per request, from a model profile. A tool
request goes to
responseswhen the model's profile sayschat/cannot serve its function tools — today the three GPT-6 models. Everything else stays oncompletions chat/, the GPT-5 and o-series reasoning models included, whose tool calls work there. The profile is one documented table — which efforts a family allows, whethercompletions chat/serves tools for it, what its default effort is — and not a regular expression grown one family at a time. The regex thatcompletions isused matchedReasoning Model () o1–o9andgpt-and silently excluded GPT-6, which is how sampling parameters were still being sent to it.5 - Only tool calls move. Plain chat, streaming and vision keep posting to
chat/. A reasoning model answers those at its default effort today, and moving a working path to fix a broken one changes behaviour for every installation to no purpose.completions - An endpoint that is not OpenAI's keeps ``chat/completions`` unless the
configuration opts in. The base URL is operator-configurable and the
openaiadapter also serves compatible gateways. Such a gateway need not implement/v1/, and switching it would break an installation that works. The predicate is the configured URL's host, not the whole string, becauseresponses Providermay have rewritten it. The opt-in isEndpoint Normalization Hook "openai_in the configuration's options JSON, whichtools_ transport": "responses" Llmalready merges into every call;Configuration:: to Options Array () "chat/forces the old path. Whether an endpoint servescompletions" /v1/is a property of the endpoint, so the provider record would be the better home. It is not used because it does not hold today:responses Providerre-runsAdapter Registry:: create Adapter From Model () configureon the cached provider adapter with five keys, and that drops every other key the provider's options JSON set — measured on() organizationandId custom, which come back empty. That is a defect of its own and is fixed separately.Headers - ``store`` stays ``false`` and no ``previous_response_id`` is sent.
Continuity comes from replaying the items, which in stateless mode carry
encrypted_. Whether a conversation may be retained on OpenAI's servers is a data-protection decision for the installation, and this change does not make it on anyone's behalf.content - The opaque items ride on :php:`ChatMessage`, in a field the wire shape
does not carry. A new
?array $providerholds them.Items Chatis unchanged, because it is the OpenAI Chat Completions wire shape and the Groq, Mistral, OpenRouter and Ollama adapters put its result straight into a request payload — an unknown key there reaches four live APIs. A second, explicitly namedMessage:: to Array () tocarries the field for persistence — a suspended run and a queued one — andTranscript Array () Toolturns every stored turn that carries the key back into aLoop Service Chatbefore anything is sent, so the key reaches no request as an array: not the Responses request, and not the Chat Completions closing answer or a fallback provider that follows a resume.Message - What came back is recorded on the response, under named keys in the
existing metadata slot — the transport that was used, the items to
replay, and the effort that was applied.
Completionis frozen inResponse api-andsurface. txt Abstractargues in its own docblock against growing it;Options metadataalready exists and already survivesGuardrail's rebuild of a screened response. An absent key and an empty array stay different facts.Middleware
Consequences
✓ Tool calling works on every GPT-6 model at its own reasoning effort, and the model keeps its reasoning across the steps of a run.
✓ The model-specific knowledge sits in one profile table instead of in a regex, a capability list and a discovery filter that drifted apart.
✕ There are two serialisations of a Chat. to is the
wire shape and to is the stored shape, and they differ in
one field. The alternative was to emit the field in to and strip it
in four adapters, which leaves the fifth to be written wrong.
✕ Two request builders and two response parsers exist for OpenAI. They are tested separately and a model profile decides between them; the duplication is real and is the price of not moving the three working call paths.
An assumption that only production can settle. Sampling parameters are now
stripped for gpt- as they already were for gpt-. No documentation
page states that GPT-6 rejects a non-default temperature, and no OpenAI
credential exists on the machine this was written on, so it was not measured.
If the assumption is wrong, an operator loses a knob and sees no error; if it
had been left as it was and is wrong the other way, every GPT-6 call returns
HTTP 400 with temperature named in the message. That is the signature to
look for, and the asymmetry is why the parameter is stripped.
Verification against the live API (2026-09-24)
The sentence above about the missing credential was wrong: an OpenAI API
key was stored in the team's password manager and had not been looked for.
The decision was then checked against the live API with the provider code
of v0.37.0 — the real Open over a real HTTP client:
- A tool request to
gpt-at its default effort went to6- luna /v1/, came back as a tool call, and the response recorded the effort OpenAI applied,responses medium. The second step, with the tool result asfunction_, answered with the tool's result.call_ output - A
think = falserequest togpt-was sent at6- astra low, and OpenAI reportedlowback. - At effort
highthe model reasoned before calling the tool — 92 reasoning tokens — and the reply carried a reasoning item withencrypted_. The second request replayed that item byte for byte, OpenAI accepted it, and the answer was correct.content gpt-still called the tool over4. 1- mini chat/.completions - The same GPT-6 tool request forced onto
chat/failed with exactly the error of issue #965.completions - The temperature assumption holds.
gpt-and6- luna gpt-both answer6- astra temperature: 0.on2 chat/with HTTP 400: Only the default (1) value is supported. Stripping it is correct.completions
At its default effort, medium, on a trivial prompt the model returned no
reasoning item at all, only the function call. A replay therefore carries
reasoning only when the model produced some, which is what the code does.
Revisit when
OpenAI stops serving function tools on chat/ for the
non-reasoning models as well; the profile then has one branch instead of two
and the Chat Completions builder can go.
An installation wants OpenAI's built-in tools — web search, file search, code interpreter. They are reachable only over this transport and need a tool vocabulary this record does not define.