Files
coder/coderd/x/chatd/chatprovider/model.go
T
Michael Suchacz 0e16e356b0 refactor(coderd/x/chatd): read the OpenAI transport from the model (#27704)
Stacked on #27703.

Provider option conversion, reasoning effort injection, and file part
acceptance each recomputed the OpenAI wire format from `(provider,
modelID, override)`. They now read it from `chatprovider.Model`, so a
decision cannot drift from the client it was built for.

`ProviderOptionsFromChatConfig` takes a `Transport`,
`ApplyReasoningEffort` takes a `Model`, and `AcceptsFilePartMediaType`
becomes a `Model` method. `UsesResponsesAPI` and `UsesResponsesOptions`
are deleted. The override extraction is unexported and reachable only
from `ModelFromConfig`, which now takes the model's
`ChatModelOpenAIConfig` directly, removing the six scattered extractions
at call sites.

That also resolves the computer-use mismatch. The computer-use model is
a hardcoded default with no config row of its own: its client was built
without an override while request preparation applied the chat model's.
Preparation now reads the computer-use model's own transport, so the two
agree without one model's client settings following a different model.
Passing the chat model's `openai_config` into the computer-use client
would have made them agree on the wrong value.

`TestModelTransportConsumersAgree` pins the invariant in one test: the
HTTP path the client actually hits, the concrete provider option struct
type, the type created by reasoning effort, and text/image file
acceptance.

> Mux prepared this PR on Mike's behalf.
2026-08-04 08:00:19 +00:00

61 lines
2.2 KiB
Go

package chatprovider
import (
"charm.land/fantasy"
"github.com/coder/coder/v2/coderd/x/chatd/chatopenai"
"github.com/coder/coder/v2/codersdk"
)
// Model pairs a language model client with the facts resolved when it was
// built. Its fields are unexported and nothing sets transport except NewModel,
// which derives it from the client, so no caller can choose a transport that
// disagrees with the client it wraps.
type Model struct {
lm fantasy.LanguageModel
transport chatopenai.Transport
}
// NewModel pairs an already-built client with the transport resolved from that
// client's own identity. Callers must pass the config the client was built
// with, the one degree of freedom Model cannot police. ModelFromConfig is the
// only production caller.
func NewModel(lm fantasy.LanguageModel, openAIConfig *codersdk.ChatModelOpenAIConfig) Model {
if lm == nil {
// The invalid zero value lets callers report a nil client as an
// error instead of dereferencing it here.
return Model{}
}
return Model{
lm: lm,
transport: chatopenai.TransportFor(lm.Provider(), lm.Model(), openAIResponsesAPIOverride(openAIConfig)),
}
}
func (m Model) LanguageModel() fantasy.LanguageModel { return m.lm }
func (m Model) Provider() string { return m.lm.Provider() }
func (m Model) ModelID() string { return m.lm.Model() }
func (m Model) Transport() chatopenai.Transport { return m.transport }
// Valid reports whether m wraps a client rather than being the zero value.
func (m Model) Valid() bool { return m.lm != nil }
// WithLanguageModel replaces the wrapped client and keeps the resolved
// transport, because decorators such as debug recording do not change what the
// client speaks. It panics on an invalid receiver or a replacement that
// reports a different identity, because both would pair the kept transport
// with a client it was not resolved from.
func (m Model) WithLanguageModel(lm fantasy.LanguageModel) Model {
if !m.Valid() {
panic("chatprovider: WithLanguageModel on an invalid Model")
}
if lm == nil || lm.Provider() != m.lm.Provider() || lm.Model() != m.lm.Model() {
panic("chatprovider: WithLanguageModel replacement changes the model identity")
}
m.lm = lm
return m
}