Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions coderd/x/chatd/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,14 @@ The generation goroutine supports:
- turn limit after a user message (the LLM shouldn't be able to spin forever in loop)
- and other things

##### Model call resolution

TODO(PR author): Document the model-call resolver introduced in `modelcall.go`:

- `Server.resolveModelCall` is the single pipeline from a `modelCallSpec` to a ready client plus call metadata (`resolvedModelCall`). It owns config selection, `chat_model_configs.options` parsing, route resolution, client construction, the debug-recording wrap, and provider-option derivation.
- Every call behaves the same: provider options are always derived, `MaxOutputTokens` always defaults, and debug recording is always on when chat debug is enabled. The spec carries only flow-specific inputs: the model source (chat config, explicit config row, or fixed provider/model pair for computer use), the requested reasoning effort, chatd-scoped route resolution for deployment-selected override models, and the active API key for AI Gateway transport.
- `resolvedModelCall.newCall` and `newObjectCall` are the only production constructors of `fantasy.Call` and `fantasy.ObjectCall`; flows pass prebuilt templates through options (`chatloop.GenerateAssistantOptions.CallTemplate`, the compaction `SummaryCall`, `chatadvisor.RuntimeConfig.CallTemplate`) and downstream packages copy the template and attach the prompt and tools they own.

##### Reasoning effort

Model configs may carry a `reasoning_effort` config (`{default, max}`) inside `chat_model_configs.options`. Users select a per-turn effort when sending or editing a message; the value is stored on `chat_messages.reasoning_effort` and on `chat_queued_messages.reasoning_effort` for queued messages. Queued messages carry the value through promotion, and `chats.last_reasoning_effort` tracks the most recent message that set one, mirroring `last_model_config_id`.
Expand All @@ -876,6 +884,8 @@ Request preparation reads the transport from the model instead of recomputing it

The first two happen together in `chatprovider.ProviderOptionsForCall`, the only entry point in `chatprovider` that builds provider options for a call; it delegates transport-aware OpenAI conversion to `chatopenai.ProviderOptionsFromChatConfig`. Config conversion and effort injection cannot pick different option types because one function owns both.

TODO(PR author): Update this paragraph for the model-call resolver. All client-building paths (compaction override, quick generation, advisor runtime) now go through `resolveModelCall`, and every path derives provider options through `ProviderOptionsForCall`; the previous exceptions for turn status labels and chat summaries are gone. Computer-use turns still substitute a hardcoded default model that has no config of its own; it carries its own transport, so the chat model's `openai_config` does not follow it.

Paths that build their own clients get a `Model` from the same constructor, including the compaction override, quick generation (used by turn status labels and debug models), and the advisor runtime. Within quick generation, only title generation converts the model config through `ProviderOptionsForCall`; the turn status label and chat summary paths deliberately send no provider options, because they are short structured calls that set their own output bounds. Debug recording replaces the wrapped client and preserves the resolved transport. Computer-use turns substitute a hardcoded default model that has no config of its own; it carries its own transport, so the chat model's `openai_config` does not follow it.

Azure is deliberately exempt: its provider always enables the Responses API for known models and exposes no equivalent per-model hook, so the transport keeps following the known-model list for Azure. Ignoring the override there is what keeps the decisions above in agreement with the Azure client. The exemption is narrower than it appears, because chatd never builds an azure-typed provider as a fantasy azure client: `fantasyConfigForAIBridge` folds every provider type other than anthropic, bedrock, and openai into openai-compat, which always speaks Chat Completions.
Expand Down
52 changes: 41 additions & 11 deletions coderd/x/chatd/advisor_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,20 +113,19 @@ func (p *Server) resolveAdvisorModelOverrideOrFallback(
modelOpts modelBuildOptions,
logger slog.Logger,
) (chatprovider.Model, codersdk.ChatModelCallConfig) {
model, cfg, err := p.resolveAdvisorModelOverride(
resolved, err := p.resolveAdvisorModelOverride(
ctx,
chat,
advisorCfg,
fallbackModel,
fallbackCallConfig,
resolvedModelCall{model: fallbackModel, callConfig: fallbackCallConfig},
modelOpts,
logger,
)
if err != nil {
logger.Warn(ctx, "failed to resolve advisor model override, continuing with chat model", slog.Error(err))
return fallbackModel, fallbackCallConfig
}
return model, cfg
return resolved.model, resolved.callConfig
}

func (p *Server) newAdvisorRuntimeOrFallback(
Expand All @@ -142,8 +141,7 @@ func (p *Server) newAdvisorRuntimeOrFallback(
ctx,
chat,
advisorCfg,
fallbackModel,
fallbackCallConfig,
resolvedModelCall{model: fallbackModel, callConfig: fallbackCallConfig},
modelOpts,
logger,
)
Expand Down Expand Up @@ -268,6 +266,37 @@ func TestResolveAdvisorModelOverride(t *testing.T) {
require.Equal(t, fallbackCallConfig, gotCfg)
})

t.Run("InvalidOptionsJSONWithLinkedProviderReturnsFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Model: "gpt-5.2",
Enabled: true,
Options: []byte("not valid json"),
DisplayName: "gpt-5.2",
AIProviderID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
}, nil
},
}
p := newAdvisorTestServer(ctx, t, store)

resolved, err := p.resolveAdvisorModelOverride(
ctx,
database.Chat{},
codersdk.AdvisorConfig{ModelConfigID: configID},
resolvedModelCall{model: fallbackModel, callConfig: fallbackCallConfig},
modelBuildOptions{},
logger,
)
require.NoError(t, err)
require.Equal(t, fallbackModel, resolved.model)
require.Equal(t, fallbackCallConfig, resolved.callConfig)
})

t.Run("MissingProviderKeyReturnsFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
Expand Down Expand Up @@ -416,7 +445,9 @@ func TestResolveAdvisorModelOverride(t *testing.T) {
require.True(t, gotModel.Valid())
require.Equal(t, "openai", gotModel.Provider())
require.Equal(t, "gpt-5.2", gotModel.ModelID())
require.Equal(t, fallbackCallConfig, gotCfg)
require.Equal(t, codersdk.ChatModelCallConfig{
MaxOutputTokens: ptr.Ref(defaultChatMaxOutputTokens),
}, gotCfg)
})
}

Expand Down Expand Up @@ -446,17 +477,16 @@ func TestResolveAdvisorModelOverridePromotesAIBridgeErrors(t *testing.T) {
p := newAdvisorTestServer(ctx, t, store)

ctx = aibridge.WithDelegatedAPIKeyID(ctx, uuid.NewString())
model, _, err := p.resolveAdvisorModelOverride(
resolved, err := p.resolveAdvisorModelOverride(
ctx,
database.Chat{ID: uuid.New(), OwnerID: uuid.New()},
codersdk.AdvisorConfig{ModelConfigID: configID},
chatprovider.NewModel(&chattest.FakeModel{ProviderName: "stub", ModelName: "stub"}, nil),
codersdk.ChatModelCallConfig{},
resolvedModelCall{model: chatprovider.NewModel(&chattest.FakeModel{ProviderName: "stub", ModelName: "stub"}, nil)},
modelBuildOptions{ActiveAPIKeyID: uuid.NewString()},
slog.Make(),
)
require.ErrorContains(t, err, "AI Gateway transport factory")
require.False(t, model.Valid())
require.False(t, resolved.model.Valid())
}

// TestStripAdvisorGuidanceBlock exercises the filter that keeps the advisor
Expand Down
12 changes: 6 additions & 6 deletions coderd/x/chatd/chatadvisor/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,14 @@ func (rt *Runtime) RunAdvisor(

// resetProviderOptionsForNestedCall mutates its argument; give it a
// clone so the Runtime's stored options stay unchanged across calls.
nestedProviderOptions := cloneProviderOptions(rt.cfg.ProviderOptions)
resetProviderOptionsForNestedCall(nestedProviderOptions)
nestedCall := rt.cfg.CallTemplate
nestedCall.ProviderOptions = cloneProviderOptions(rt.cfg.CallTemplate.ProviderOptions)
resetProviderOptionsForNestedCall(nestedCall.ProviderOptions)

assistantOpts := chatloop.GenerateAssistantOptions{
Model: rt.cfg.Model,
Messages: BuildAdvisorMessages(question, conversationSnapshot),
ModelConfig: rt.cfg.ModelConfig,
ProviderOptions: nestedProviderOptions,
Model: rt.cfg.Model,
Messages: BuildAdvisorMessages(question, conversationSnapshot),
CallTemplate: nestedCall,
}
if opts != nil && opts.OnAdviceDelta != nil {
assistantOpts.PublishMessagePart = func(role codersdk.ChatMessageRole, part codersdk.ChatMessagePart) {
Expand Down
9 changes: 4 additions & 5 deletions coderd/x/chatd/chatadvisor/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (

"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
)

func TestAdvisorRunAdvice(t *testing.T) {
Expand Down Expand Up @@ -425,12 +424,12 @@ func TestNewRuntimeValidation(t *testing.T) {
errText: "advisor max output tokens must be positive",
},
{
name: "MismatchedModelConfigMaxOutputTokens",
name: "MismatchedCallTemplateMaxOutputTokens",
cfg: chatadvisor.RuntimeConfig{
Model: model,
MaxUsesPerRun: 1,
MaxOutputTokens: matchingTokens,
ModelConfig: codersdk.ChatModelCallConfig{
CallTemplate: fantasy.Call{
MaxOutputTokens: &mismatchedTokens,
},
},
Expand Down Expand Up @@ -473,7 +472,7 @@ func TestNewRuntimeDeepClonesOpenAIResponsesProviderOptions(t *testing.T) {
}), nil
},
},
ProviderOptions: parentProviderOpts,
CallTemplate: fantasy.Call{ProviderOptions: parentProviderOpts},
MaxUsesPerRun: 1,
MaxOutputTokens: 64,
})
Expand Down Expand Up @@ -532,7 +531,7 @@ func TestAdvisorRunDisablesStoreAndIsConsistentAcrossCalls(t *testing.T) {
}), nil
},
},
ProviderOptions: parentProviderOpts,
CallTemplate: fantasy.Call{ProviderOptions: parentProviderOpts},
MaxUsesPerRun: 2,
MaxOutputTokens: 64,
})
Expand Down
22 changes: 10 additions & 12 deletions coderd/x/chatd/chatadvisor/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@ import (
"charm.land/fantasy"
fantasyopenai "charm.land/fantasy/providers/openai"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/codersdk"
)

// RuntimeConfig configures a single advisor runtime instance.
type RuntimeConfig struct {
Model fantasy.LanguageModel
ModelConfig codersdk.ChatModelCallConfig
ProviderOptions fantasy.ProviderOptions
Model fantasy.LanguageModel
// CallTemplate's provider options are cloned for each nested call.
CallTemplate fantasy.Call
MaxUsesPerRun int
MaxOutputTokens int64
}
Expand Down Expand Up @@ -44,19 +42,19 @@ func NewRuntime(cfg RuntimeConfig) (*Runtime, error) {
if cfg.MaxOutputTokens <= 0 {
return nil, xerrors.New("advisor max output tokens must be positive")
}
if cfg.ModelConfig.MaxOutputTokens != nil &&
*cfg.ModelConfig.MaxOutputTokens != cfg.MaxOutputTokens {
if cfg.CallTemplate.MaxOutputTokens != nil &&
*cfg.CallTemplate.MaxOutputTokens != cfg.MaxOutputTokens {
return nil, xerrors.Errorf(
"advisor model_config.max_output_tokens (%d) must match runtime max output tokens (%d)",
*cfg.ModelConfig.MaxOutputTokens,
"advisor call template max output tokens (%d) must match runtime max output tokens (%d)",
*cfg.CallTemplate.MaxOutputTokens,
cfg.MaxOutputTokens,
)
}

normalized := cfg
normalized.ProviderOptions = cloneProviderOptions(cfg.ProviderOptions)
normalized.CallTemplate.ProviderOptions = cloneProviderOptions(cfg.CallTemplate.ProviderOptions)
maxOutputTokens := cfg.MaxOutputTokens
normalized.ModelConfig.MaxOutputTokens = &maxOutputTokens
normalized.CallTemplate.MaxOutputTokens = &maxOutputTokens

return &Runtime{cfg: normalized}, nil
}
Expand Down Expand Up @@ -134,7 +132,7 @@ func (rt *Runtime) ProviderOptions() fantasy.ProviderOptions {
if rt == nil {
return nil
}
return rt.cfg.ProviderOptions
return rt.cfg.CallTemplate.ProviderOptions
}

func (rt *Runtime) tryAcquire() bool {
Expand Down
Loading
Loading