chat-openai: isolate per-call request params so call kinds stop bleeding state¶
- Authors
- Matt Cockayne, Claude Fable 5 (AI drafting assistant)
- Date
- 2026-07-23
- Status
- DRAFT — pending review
- Related
- architectural review (chat-family section, HIGH finding),
chat-gemini error chain preservation (sibling provider-conformance fix from the same review),
the chat extraction (
pkg/chat→go/chat+ provider modules) whose drop-in-provider promise this defect undermines
1. Problem¶
chat-openai/openai.go holds a single mutable a.params
(openai.ChatCompletionNewParams) shared by every call kind. Conversation
history legitimately lives there (params.Messages), but call-shape settings
are mutated in place and leak between calls:
-
StreamChatpoisons subsequent non-streaming calls.StreamChatsetsa.params.StreamOptions = openai.ChatCompletionStreamOptionsParam{IncludeUsage: openai.Bool(true)}(openai.go:394-396) and never clears it. A laterChatorAskon the same client sendsstream_optionson a non-streaming request — which the OpenAI API rejects with a 400. SequenceStreamChat(); Chat()on one client: the second call fails outright. -
Chat/StreamChatpermanently disableAsk's schema enforcement.ResponseFormat(the JSON-schema constraint) is set once at construction whencfg.ResponseSchema != nil(openai.go:135-146). BothChatandStreamChatclear it in place (openai.go:597,openai.go:389— "Clear structured output mode if it was set") andAsk(openai.go:228-263) never re-applies it — it sendsa.paramsas-is (openai.go:244). SequenceChat(); Ask(): theAskrequest goes out with noresponse_format, the model may answer free-text, and thejson.Unmarshalatopenai.go:257fails — or worse, mis-parses JSON-looking prose into the target. The constructor-configured schema is silently a one-shot.
Both failures are invisible at the call site: each call is individually correct,
and only the sequence breaks. That defeats the ChatClient drop-in-provider
contract — the same sequences work on Gemini, which clones its generation config
per call (chat-gemini/gemini.go:578-586 cloneConfig; Ask applies
ResponseMIMEType to the clone only, gemini.go:223-224) and therefore has no
cross-call bleed.
Concrete failure scenario. A GTB tool wires one OpenAI client with a response
schema for structured extraction, streams a chat turn to the terminal, then calls
Ask for the structured result. The Ask request carries a stale
stream_options (400 from the API) — and even with that fixed, no
response_format, so schema enforcement is gone exactly when it is needed.
2. Proposed change¶
Stop mutating call-shape fields on the shared struct. Keep a.params as the
base (model, max tokens, seed, tools, and the append-only Messages
history), and have each call kind build its request from a shallow per-call copy
— the OpenAI analogue of Gemini's cloneConfig:
// requestParams returns a per-call copy of the base params. Callers set
// call-shape fields (ResponseFormat, StreamOptions) on the copy only.
func (a *OpenAI) requestParams() openai.ChatCompletionNewParams {
p := a.params // shallow copy; Messages slice shared intentionally
return p
}
Chat: use the copy withResponseFormatzeroed (its current intent) — the in-place clears atopenai.go:597andopenai.go:389are deleted.StreamChat: copy withResponseFormatzeroed andStreamOptionsset.Ask: copy with the constructor'sResponseFormatapplied (restoring schema enforcement on everyAsk, regardless of call order) and noStreamOptions.- History appends (
a.params.Messages = append(...)) continue to target the shared base so all call kinds see the same conversation, matching today's behaviour andSave/Restore(openai.go:643-664).
Note the ReAct loops re-send params across steps (openai.go:611, 448); the
per-call copy must be taken (or refreshed) so appended tool/assistant turns are
included each step — the simplest correct shape is to rebuild the copy from the
base at each step, or to append to the base and re-copy.
Follow-on, not in scope here: the review recommends a shared
provider-conformance test suite in the go/chat core. A call-sequence state test
("for every ordered pair of call kinds, the second call's request shape is
unaffected by the first") belongs there so all providers — including Gemini and
Anthropic — are held to the same isolation contract. This spec adds the
OpenAI-local regression tests only; the suite is tracked as its own piece of work
(see also the sibling
chat-gemini error chain preservation
spec, which feeds the same suite).
3. Scope & release plan¶
- Module:
gitlab.com/phpboyscout/go/chat-openaionly. No exported API change — the fix is internal request construction plus tests. No core (go/chat) change. Theopenai-compatibleprovider shares this client, so it is fixed by the same change. - Release:
fix(openai):patch via releaser-pleaser in thechat-openairepo. The chat family's matching-minors compatibility contract is unaffected. - GTB: picks the fix up via a routine
go.modbump ofchat-openai; no GTB code change.
4. Acceptance criteria¶
- Call-sequence state test (table-driven over ordered pairs of
Chat/StreamChat/Askagainst anhttptestfake backend): the request body of the second call never containsstream_optionsunless the second call isStreamChat, and contains the constructor'sresponse_formatwhenever the second call isAskon a schema-configured client. - Specifically:
StreamChat(); Chat()succeeds (no stalestream_options400), andChat(); Ask()sendsresponse_formatwith the configured JSON schema. - A schema-less client's
Askstill sends noresponse_format(current behaviour preserved). - Multi-step ReAct requests include the turns appended by earlier steps (history sharing across the per-call copy verified).
Save/Restoreround-trip behaviour unchanged.- Module CI green (tests, race, lint); coverage on
openai.godoes not regress.
5. Open questions¶
- Should
Chatcontinue to force-clear a constructor-configuredResponseFormat? Today's in-place clear reads as a workaround, but "Chat is free-form, Ask is structured" may be the intended contract — the review's Ask-semantics finding (MEDIUM) suggests the core should define this. Proposed default: keepChat/StreamChatschema-free (preserves behaviour), and let the conformance-suite work settle the cross-provider contract.