From 614e134a45d8b8c3f10f8e2f548aa5c65927944d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:56:15 +0000 Subject: [PATCH 1/3] feat: allow model selection for claude code chats --- coderd/apidoc/docs.go | 4 +- coderd/apidoc/swagger.json | 4 +- coderd/exp_chats.go | 33 +- coderd/x/chatd/ARCHITECTURE.md | 4 +- coderd/x/chatd/chatd.go | 120 ++++-- coderd/x/chatd/chatd_claudecode_test.go | 389 +++++++++++++++++- coderd/x/chatd/claudecode/SPIKE.md | 30 ++ coderd/x/chatd/generation_claudecode.go | 97 ++++- codersdk/chats.go | 11 +- docs/reference/api/chats.md | 4 +- docs/reference/api/schemas.md | 4 +- site/src/api/typesGenerated.ts | 11 +- site/src/pages/AgentsPage/AgentChatPage.tsx | 73 +++- site/src/pages/AgentsPage/AgentCreatePage.tsx | 9 +- .../components/AgentChatInput.stories.tsx | 81 +++- .../AgentsPage/components/AgentChatInput.tsx | 48 ++- .../components/AgentCreateForm.stories.tsx | 50 ++- .../AgentsPage/components/AgentCreateForm.tsx | 25 +- .../components/ChatElements/ModelSelector.tsx | 44 +- .../pages/AgentsPage/utils/modelOptions.ts | 8 + 20 files changed, 924 insertions(+), 125 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 36d3d6fd449..caae9e84d18 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16870,7 +16870,7 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ChatError" }, "last_model_config_id": { - "description": "LastModelConfigID is nil for chats on external runtimes, which\nare not backed by a chat model config.", + "description": "LastModelConfigID records the most recent explicit model\nselection. On external runtimes it is nil until a model is\npicked and serves only as a client restore hint: the runtime\ndefault applies whenever a message carries no selection.", "type": "string", "format": "uuid" }, @@ -17849,7 +17849,7 @@ const docTemplate = `{ "type": "boolean" }, "model": { - "description": "Model optionally pins the model the runtime agent uses. Empty\nmeans the runtime default.", + "description": "Model optionally pins the default model the runtime agent uses.\nA per-message model selection on the chat overrides this pin;\nempty falls through to the runtime agent's own default.", "type": "string" }, "organization_id": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1d3136cc5ab..241492bd94a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15151,7 +15151,7 @@ "$ref": "#/definitions/codersdk.ChatError" }, "last_model_config_id": { - "description": "LastModelConfigID is nil for chats on external runtimes, which\nare not backed by a chat model config.", + "description": "LastModelConfigID records the most recent explicit model\nselection. On external runtimes it is nil until a model is\npicked and serves only as a client restore hint: the runtime\ndefault applies whenever a message carries no selection.", "type": "string", "format": "uuid" }, @@ -16076,7 +16076,7 @@ "type": "boolean" }, "model": { - "description": "Model optionally pins the model the runtime agent uses. Empty\nmeans the runtime default.", + "description": "Model optionally pins the default model the runtime agent uses.\nA per-message model selection on the chat overrides this pin;\nempty falls through to the runtime agent's own default.", "type": "string" }, "organization_id": { diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 0eee5c250e5..25639789c1b 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1131,11 +1131,20 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { return } if req.ModelConfigID != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "model_config_id cannot be set for runtime chats.", - Detail: "External runtimes manage their own model selection.", - }) - return + if err := api.chatDaemon.ValidateClaudeCodeModelConfigID(ctx, *req.ModelConfigID); err != nil { + if xerrors.Is(err, chatd.ErrInvalidModelConfigID) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid model config ID.", + Detail: "Claude Code chats accept enabled Anthropic model configs only.", + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to validate model config.", + Detail: err.Error(), + }) + return + } } if req.WorkspaceID != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -1178,6 +1187,11 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, modelConfigStatus, *modelConfigError) return } + } else if req.ModelConfigID != nil { + // Runtime chats take the explicit selection only, validated + // above. Absent stays zero: the deployment default may be a + // non-Anthropic model the runtime cannot honor. + modelConfigID = *req.ModelConfigID } if !validateChatPlanMode(req.PlanMode) { @@ -3179,13 +3193,8 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { } if chat.Runtime != database.ChatRuntimeCoder { - if req.ModelConfigID != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "model_config_id cannot be set on runtime chats.", - Detail: "External runtimes manage their own model selection.", - }) - return - } + // An explicit model_config_id is validated by the send path + // via resolveSendMessageModelConfigID. if req.PlanMode != nil || (req.MCPServerIDs != nil && len(*req.MCPServerIDs) > 0) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "plan_mode and mcp_server_ids are not supported on runtime chats.", diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index cfce14178c7..1fc2e62bbf4 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -848,7 +848,9 @@ The abandon chat goroutine is responsible for abandoning the chat. It is spawned Chats carry an immutable `chats.runtime` column (`coder` by default). Chats on the `claude_code` runtime delegate whole generation turns to a Claude Code agent instead of the built-in LLM pipeline. The branch lives at the top of `taskStarter.StartGeneration`: after `loadGenerationState`, a `claude_code` chat routes to `startClaudeCodeGeneration` (`generation_claudecode.go`) and skips `prepareGeneration`/`decideGenerationAction` entirely. Everything downstream of the turn is shared with the built-in pipeline: generation attempts, `messagepartbuffer` preview episodes, `CommitStep`/`FinishTurn`/`FinishError` transitions, queue promotion, interrupts, and stale-owner recovery. -The runtime speaks the Agent Client Protocol (ACP, JSON-RPC over stdio) to a `claude-agent-acp` adapter process (`@agentclientprotocol/claude-agent-acp`) running inside the workspace bound to the chat. The chat's workspace is created server-side at chat creation from an org-scoped admin config (`chat_runtime_configs`: template, enabled flag, optional model and permission mode). Because the runtime chat has no chat model config, `chats.last_model_config_id` is nullable and message sends skip model resolution. +The runtime speaks the Agent Client Protocol (ACP, JSON-RPC over stdio) to a `claude-agent-acp` adapter process (`@agentclientprotocol/claude-agent-acp`) running inside the workspace bound to the chat. The chat's workspace is created server-side at chat creation from an org-scoped admin config (`chat_runtime_configs`: template, enabled flag, optional default model pin and permission mode). + +**Model selection.** Users may pick an enabled Anthropic model config per message (`model_config_id` on create/send/edit, validated by `fetchClaudeCodeModelConfig`); an absent id means the runtime default. The engine resolves the turn model from the newest triggering user message, not from a chat column, so a pick queued behind older messages cannot retroactively apply to them. The resolution chain is: message selection, then the admin `chat_runtime_configs.model` pin, then the adapter default. The applied model reaches the adapter through the per-spawn `ANTHROPIC_MODEL` env injection, which the G1 probe (see `claudecode/SPIKE.md`) verified wins over a resumed session's stored model; ACP `session/set_config_option` is intentionally unused. When a selection applies, `ANTHROPIC_API_KEY`/`ANTHROPIC_BASE_URL` come from the selected config's provider, and the turn's assistant messages are stamped with the `model_config_id` for per-model token analytics (their cost intentionally stays zero: `modelCallConfig` is empty). A selection that became unusable by turn time falls back to the admin-pin chain, unstamped, with a warning log. Sends never fall back to `chats.last_model_config_id` on runtime chats (that would make the default unreachable after any pick); the column is only a client restore hint updated as a byproduct of message stamping. One adapter process serves one generation turn (`claudecode.RunTurn` in `coderd/x/chatd/claudecode`): diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 1166334ed8e..dcbbb62f26b 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1537,18 +1537,27 @@ func resolveSendMessageModelConfigID( chat database.Chat, requested uuid.UUID, ) (uuid.UUID, error) { - // External runtimes manage their own model; their messages carry - // no model config. The API layer rejects explicit overrides before - // this point, so a requested ID here is a caller bug. if chat.Runtime != database.ChatRuntimeCoder { - if requested != uuid.Nil { + // Absent means the runtime default chain (admin pin, then + // adapter default). Never fall back to last_model_config_id + // here: a sticky server fallback would make the default + // unreachable once any model was ever picked. + if requested == uuid.Nil { + return uuid.Nil, nil + } + if chat.Runtime != database.ChatRuntimeClaudeCode { return uuid.Nil, xerrors.Errorf( "%w: model config cannot be set on %s runtime chats", ErrInvalidModelConfigID, chat.Runtime, ) } - return uuid.Nil, nil + if _, _, err := fetchClaudeCodeModelConfig( + chatdModelConfigLookupContext(ctx), store, requested, + ); err != nil { + return uuid.Nil, err + } + return requested, nil } if requested == uuid.Nil { return resolveFallbackModelConfigID(ctx, store, chat.LastModelConfigID.UUID) @@ -1572,6 +1581,61 @@ func resolveSendMessageModelConfigID( return requested, nil } +// fetchClaudeCodeModelConfig loads an explicitly selected model config +// for a claude_code chat together with its provider. Only enabled, +// non-deleted configs on an enabled Anthropic provider are selectable: +// the runtime injects Anthropic credentials into the adapter, so other +// provider types cannot be honored. Failures wrap +// ErrInvalidModelConfigID unless the lookup itself errored. +func fetchClaudeCodeModelConfig( + ctx context.Context, + store database.Store, + id uuid.UUID, +) (database.ChatModelConfig, database.AIProvider, error) { + config, err := store.GetEnabledChatModelConfigByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return database.ChatModelConfig{}, database.AIProvider{}, xerrors.Errorf( + "%w: %s", ErrInvalidModelConfigID, id, + ) + } + return database.ChatModelConfig{}, database.AIProvider{}, xerrors.Errorf( + "get model config %s: %w", id, err, + ) + } + if !config.AIProviderID.Valid { + return database.ChatModelConfig{}, database.AIProvider{}, xerrors.Errorf( + "%w: model config %s has no provider", ErrInvalidModelConfigID, id, + ) + } + provider, err := store.GetAIProviderByID(ctx, config.AIProviderID.UUID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return database.ChatModelConfig{}, database.AIProvider{}, xerrors.Errorf( + "%w: %s", ErrInvalidModelConfigID, id, + ) + } + return database.ChatModelConfig{}, database.AIProvider{}, xerrors.Errorf( + "get ai provider for model config %s: %w", id, err, + ) + } + if provider.Type != database.AIProviderTypeAnthropic { + return database.ChatModelConfig{}, database.AIProvider{}, xerrors.Errorf( + "%w: model config %s is not an Anthropic model", ErrInvalidModelConfigID, id, + ) + } + return config, provider, nil +} + +// ValidateClaudeCodeModelConfigID checks that an explicit model +// selection for a claude_code chat references an enabled Anthropic +// model config. It returns an error wrapping ErrInvalidModelConfigID +// when the selection is not usable. +func (p *Server) ValidateClaudeCodeModelConfigID(ctx context.Context, id uuid.UUID) error { + _, _, err := fetchClaudeCodeModelConfig(chatdModelConfigLookupContext(ctx), p.db, id) + return err +} + func resolveFallbackModelConfigID( ctx context.Context, store database.Store, @@ -1664,30 +1728,38 @@ func (p *Server) EditMessage( // foreign-key error from the message-insert path. var modelOverride uuid.NullUUID if opts.ModelConfigID != uuid.Nil { - // External runtimes manage their own model; edits - // cannot override it. Mirrors the send-message path. - if lockedChat.Runtime != database.ChatRuntimeCoder { - return xerrors.Errorf( - "%w: model config cannot be set on %s runtime chats", - ErrInvalidModelConfigID, - lockedChat.Runtime, - ) - } - if _, err := store.GetChatModelConfigByID( - chatdModelConfigLookupContext(ctx), - opts.ModelConfigID, - ); err != nil { - if errors.Is(err, sql.ErrNoRows) { + switch lockedChat.Runtime { + case database.ChatRuntimeCoder: + if _, err := store.GetChatModelConfigByID( + chatdModelConfigLookupContext(ctx), + opts.ModelConfigID, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf( + "%w: %s", + ErrInvalidModelConfigID, + opts.ModelConfigID, + ) + } return xerrors.Errorf( - "%w: %s", - ErrInvalidModelConfigID, + "get requested model config %s: %w", opts.ModelConfigID, + err, ) } - return xerrors.Errorf( - "get requested model config %s: %w", + case database.ChatRuntimeClaudeCode: + if _, _, err := fetchClaudeCodeModelConfig( + chatdModelConfigLookupContext(ctx), + store, opts.ModelConfigID, - err, + ); err != nil { + return err + } + default: + return xerrors.Errorf( + "%w: model config cannot be set on %s runtime chats", + ErrInvalidModelConfigID, + lockedChat.Runtime, ) } modelOverride = uuid.NullUUID{UUID: opts.ModelConfigID, Valid: true} diff --git a/coderd/x/chatd/chatd_claudecode_test.go b/coderd/x/chatd/chatd_claudecode_test.go index 26414657b85..a59d42faae0 100644 --- a/coderd/x/chatd/chatd_claudecode_test.go +++ b/coderd/x/chatd/chatd_claudecode_test.go @@ -3,6 +3,7 @@ package chatd_test import ( "context" "database/sql" + "sync" "testing" "github.com/google/uuid" @@ -33,6 +34,9 @@ type claudeCodeTestSetup struct { org database.Organization workspace database.WorkspaceTable agent database.WorkspaceAgent + // anthropicProviderID is the enabled anthropic provider holding + // the "test-anthropic-key" deployment key. + anthropicProviderID uuid.UUID } // seedClaudeCodeChatDependencies seeds everything a claude_code chat @@ -51,7 +55,7 @@ func seedClaudeCodeChatDependencies(t *testing.T, db database.Store, transition UserID: user.ID, OrganizationID: org.ID, }) - _ = dbgen.ChatProvider(t, db, database.ChatProvider{ + anthropicProvider := dbgen.ChatProvider(t, db, database.ChatProvider{ Provider: "anthropic", DisplayName: "anthropic", Enabled: true, @@ -114,10 +118,11 @@ func seedClaudeCodeChatDependencies(t *testing.T, db database.Store, transition require.NoError(t, err) return claudeCodeTestSetup{ - user: user, - org: org, - workspace: ws, - agent: agent, + user: user, + org: org, + workspace: ws, + agent: agent, + anthropicProviderID: anthropicProvider.ID, } } @@ -128,6 +133,7 @@ func createClaudeCodeChat( ps pubsub.Pubsub, setup claudeCodeTestSetup, prompt string, + mutators ...func(*chatstate.CreateChatInput), ) chatstate.CreateChatResult { t.Helper() @@ -135,7 +141,7 @@ func createClaudeCodeChat( codersdk.ChatMessageText(prompt), }) require.NoError(t, err) - created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{ + input := chatstate.CreateChatInput{ OrganizationID: setup.org.ID, OwnerID: setup.user.ID, WorkspaceID: uuid.NullUUID{UUID: setup.workspace.ID, Valid: true}, @@ -151,11 +157,27 @@ func createClaudeCodeChat( CreatedBy: uuid.NullUUID{UUID: setup.user.ID, Valid: true}, }, }, - }) + } + for _, mutate := range mutators { + mutate(&input) + } + created, err := chatstate.CreateChat(ctx, db, ps, input) require.NoError(t, err) return created } +// withInitialModelConfig stamps the chat's initial user message (and +// the chat's last-model hint) with an explicit model selection, the +// way CreateChat does for a create request carrying model_config_id. +func withInitialModelConfig(id uuid.UUID) func(*chatstate.CreateChatInput) { + return func(input *chatstate.CreateChatInput) { + input.LastModelConfigID = uuid.NullUUID{UUID: id, Valid: true} + for i := range input.InitialMessages { + input.InitialMessages[i].ModelConfigID = uuid.NullUUID{UUID: id, Valid: true} + } + } +} + // claudeCodeConfigOverrides wires the fake ACP transport and a mocked // workspace agent connection into the chatd server config. func claudeCodeConfigOverrides(t *testing.T, agent *claudecodetest.FakeAgent) func(*chatd.Config) { @@ -176,6 +198,69 @@ func claudeCodeConfigOverrides(t *testing.T, agent *claudecodetest.FakeAgent) fu } } +// claudeCodeEnvRecorder captures the adapter env of every turn so +// tests can assert model and credential resolution. +type claudeCodeEnvRecorder struct { + mu sync.Mutex + envs []map[string]string +} + +func (r *claudeCodeEnvRecorder) record(env map[string]string) { + r.mu.Lock() + defer r.mu.Unlock() + r.envs = append(r.envs, env) +} + +func (r *claudeCodeEnvRecorder) last(t *testing.T) map[string]string { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + require.NotEmpty(t, r.envs) + return r.envs[len(r.envs)-1] +} + +// claudeCodeConfigOverridesCaptureEnv wires the fake ACP transport like +// claudeCodeConfigOverrides but records each turn's env instead of +// asserting fixed values. +func claudeCodeConfigOverridesCaptureEnv(t *testing.T, agent *claudecodetest.FakeAgent, recorder *claudeCodeEnvRecorder) func(*chatd.Config) { + t.Helper() + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes() + + return func(cfg *chatd.Config) { + cfg.AgentConn = func(_ context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { + return mockConn, func() {}, nil + } + cfg.ClaudeCodeTransport = func(_ context.Context, _ workspacesdk.AgentConn, _ database.WorkspaceAgent, env map[string]string) (claudecode.Transport, func(), error) { + recorder.record(env) + return &claudecodetest.PipeTransport{Agent: agent}, func() {}, nil + } + } +} + +// replyingFakeAgent returns a fake agent whose prompt handler streams +// one text chunk and finishes the turn. +func replyingFakeAgent(text string) *claudecodetest.FakeAgent { + agent := &claudecodetest.FakeAgent{} + agent.OnPrompt = func(ctx context.Context, conn *acp.AgentSideConnection, params acp.PromptRequest) (acp.PromptResponse, error) { + err := conn.SessionUpdate(ctx, acp.SessionNotification{ + SessionId: params.SessionId, + Update: acp.SessionUpdate{ + AgentMessageChunk: &acp.SessionUpdateAgentMessageChunk{Content: acp.TextBlock(text)}, + }, + }) + if err != nil { + return acp.PromptResponse{}, err + } + return acp.PromptResponse{ + StopReason: acp.StopReasonEndTurn, + Usage: &acp.Usage{InputTokens: 11, OutputTokens: 7, TotalTokens: 18}, + }, nil + } + return agent +} + func TestClaudeCodeChatTurn(t *testing.T) { t.Parallel() @@ -374,3 +459,293 @@ func TestClaudeCodeChatMissingRuntimeConfigFails(t *testing.T) { require.Contains(t, string(chat.LastError.RawMessage), "not configured") require.Empty(t, fakeAgent.Prompts()) } + +func TestClaudeCodeChatModelSelection(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + setup := seedClaudeCodeChatDependencies(t, db, database.WorkspaceTransitionStart) + selected := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "claude-selected-model", + AIProviderID: uuid.NullUUID{UUID: setup.anthropicProviderID, Valid: true}, + }) + + recorder := &claudeCodeEnvRecorder{} + created := createClaudeCodeChat(ctx, t, db, ps, setup, "hello", withInitialModelConfig(selected.ID)) + _ = newActiveTestServer(t, db, ps, claudeCodeConfigOverridesCaptureEnv(t, replyingFakeAgent("selected reply"), recorder)) + + chat := waitForTerminalChat(ctx, t, db, created.Chat.ID) + require.Equal(t, database.ChatStatusWaiting, chat.Status) + require.False(t, chat.LastError.Valid) + + // The selection overrode the admin pin ("claude-test-model") and + // kept the selected provider's credentials. + env := recorder.last(t) + require.Equal(t, "claude-selected-model", env["ANTHROPIC_MODEL"]) + require.Equal(t, "test-anthropic-key", env["ANTHROPIC_API_KEY"]) + + // The assistant message is stamped with the applied selection for + // per-model analytics, with intentionally no cost attached. + messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: created.Chat.ID}) + require.NoError(t, err) + require.Len(t, messages, 2) + require.Equal(t, database.ChatMessageRoleAssistant, messages[1].Role) + require.Equal(t, uuid.NullUUID{UUID: selected.ID, Valid: true}, messages[1].ModelConfigID) + require.False(t, messages[1].TotalCostMicros.Valid) + require.Equal(t, int64(11), messages[1].InputTokens.Int64) + require.Equal(t, uuid.NullUUID{UUID: selected.ID, Valid: true}, chat.LastModelConfigID) +} + +func TestClaudeCodeChatModelSelectionUnavailableFallsBack(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + setup := seedClaudeCodeChatDependencies(t, db, database.WorkspaceTransitionStart) + // The selection references a config that is disabled by turn time. + disabled := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "claude-disabled-model", + AIProviderID: uuid.NullUUID{UUID: setup.anthropicProviderID, Valid: true}, + }, func(p *database.InsertChatModelConfigParams) { + p.Enabled = false + }) + + recorder := &claudeCodeEnvRecorder{} + created := createClaudeCodeChat(ctx, t, db, ps, setup, "hello", withInitialModelConfig(disabled.ID)) + _ = newActiveTestServer(t, db, ps, claudeCodeConfigOverridesCaptureEnv(t, replyingFakeAgent("fallback reply"), recorder)) + + chat := waitForTerminalChat(ctx, t, db, created.Chat.ID) + require.Equal(t, database.ChatStatusWaiting, chat.Status) + require.False(t, chat.LastError.Valid) + + // The turn fell back to the admin pin and left the assistant + // message unstamped. + env := recorder.last(t) + require.Equal(t, "claude-test-model", env["ANTHROPIC_MODEL"]) + messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: created.Chat.ID}) + require.NoError(t, err) + require.Len(t, messages, 2) + require.Equal(t, database.ChatMessageRoleAssistant, messages[1].Role) + require.False(t, messages[1].ModelConfigID.Valid) +} + +func TestClaudeCodeChatNoPinNoSelectionOmitsModelEnv(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + setup := seedClaudeCodeChatDependencies(t, db, database.WorkspaceTransitionStart) + _, err := db.UpsertChatRuntimeConfig(ctx, database.UpsertChatRuntimeConfigParams{ + OrganizationID: setup.org.ID, + Runtime: database.ChatRuntimeClaudeCode, + TemplateID: setup.workspace.TemplateID, + Enabled: true, + Model: "", + PermissionMode: "acceptEdits", + }) + require.NoError(t, err) + + recorder := &claudeCodeEnvRecorder{} + created := createClaudeCodeChat(ctx, t, db, ps, setup, "hello") + _ = newActiveTestServer(t, db, ps, claudeCodeConfigOverridesCaptureEnv(t, replyingFakeAgent("default reply"), recorder)) + + chat := waitForTerminalChat(ctx, t, db, created.Chat.ID) + require.Equal(t, database.ChatStatusWaiting, chat.Status) + + env := recorder.last(t) + _, hasModel := env["ANTHROPIC_MODEL"] + require.False(t, hasModel) +} + +func TestClaudeCodeChatSelectionCredentials(t *testing.T) { + t.Parallel() + + t.Run("SelectedProviderKey", func(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + setup := seedClaudeCodeChatDependencies(t, db, database.WorkspaceTransitionStart) + second := dbgen.ChatProvider(t, db, database.ChatProvider{ + Provider: "anthropic", + DisplayName: "anthropic-second", + Enabled: true, + BaseUrl: "https://second.example.com", + }, func(p *database.InsertChatProviderParams) { + p.APIKey = "second-anthropic-key" + }) + selected := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "claude-second-model", + AIProviderID: uuid.NullUUID{UUID: second.ID, Valid: true}, + }) + + recorder := &claudeCodeEnvRecorder{} + created := createClaudeCodeChat(ctx, t, db, ps, setup, "hello", withInitialModelConfig(selected.ID)) + _ = newActiveTestServer(t, db, ps, claudeCodeConfigOverridesCaptureEnv(t, replyingFakeAgent("second reply"), recorder)) + + chat := waitForTerminalChat(ctx, t, db, created.Chat.ID) + require.Equal(t, database.ChatStatusWaiting, chat.Status) + + // Credentials follow the selected config's provider. + env := recorder.last(t) + require.Equal(t, "claude-second-model", env["ANTHROPIC_MODEL"]) + require.Equal(t, "second-anthropic-key", env["ANTHROPIC_API_KEY"]) + require.Equal(t, "https://second.example.com", env["ANTHROPIC_BASE_URL"]) + }) + + t.Run("KeylessProviderFallsBack", func(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + setup := seedClaudeCodeChatDependencies(t, db, database.WorkspaceTransitionStart) + keyless := dbgen.ChatProvider(t, db, database.ChatProvider{ + Provider: "anthropic", + DisplayName: "anthropic-keyless", + Enabled: true, + }, func(p *database.InsertChatProviderParams) { + p.APIKey = "" + }) + selected := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "claude-keyless-model", + AIProviderID: uuid.NullUUID{UUID: keyless.ID, Valid: true}, + }) + + recorder := &claudeCodeEnvRecorder{} + created := createClaudeCodeChat(ctx, t, db, ps, setup, "hello", withInitialModelConfig(selected.ID)) + _ = newActiveTestServer(t, db, ps, claudeCodeConfigOverridesCaptureEnv(t, replyingFakeAgent("keyless reply"), recorder)) + + chat := waitForTerminalChat(ctx, t, db, created.Chat.ID) + require.Equal(t, database.ChatStatusWaiting, chat.Status) + + // The selected model applies (and stamps) even though its + // provider has no usable key; credentials borrow the other + // anthropic provider's key. + env := recorder.last(t) + require.Equal(t, "claude-keyless-model", env["ANTHROPIC_MODEL"]) + require.Equal(t, "test-anthropic-key", env["ANTHROPIC_API_KEY"]) + messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: created.Chat.ID}) + require.NoError(t, err) + require.Len(t, messages, 2) + require.Equal(t, uuid.NullUUID{UUID: selected.ID, Valid: true}, messages[1].ModelConfigID) + }) +} + +func TestClaudeCodeModelSelectionValidation(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replica := newTestServer(t, db, ps, uuid.New()) + setupCtx := testutil.Context(t, testutil.WaitLong) + + setup := seedClaudeCodeChatDependencies(t, db, database.WorkspaceTransitionStart) + anthropicCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "claude-valid-model", + AIProviderID: uuid.NullUUID{UUID: setup.anthropicProviderID, Valid: true}, + }) + // The deployment default is a non-Anthropic config; runtime chats + // must never fall back to it. + openaiCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "gpt-test-model", + IsDefault: true, + }) + disabledCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "claude-disabled-model", + AIProviderID: uuid.NullUUID{UUID: setup.anthropicProviderID, Valid: true}, + }, func(p *database.InsertChatModelConfigParams) { + p.Enabled = false + }) + + // No worker runs, so the chat stays running and sends queue; the + // queued message carries the resolved model config id. + created := createClaudeCodeChat(setupCtx, t, db, ps, setup, "hello") + apiKeyID := testAPIKeyID(t, db, setup.user.ID) + + send := func(ctx context.Context, modelConfigID uuid.UUID) (chatd.SendMessageResult, error) { + return replica.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: created.Chat.ID, + CreatedBy: setup.user.ID, + APIKeyID: apiKeyID, + ModelConfigID: modelConfigID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("next")}, + }) + } + + t.Run("AnthropicConfigAccepted", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + result, err := send(ctx, anthropicCfg.ID) + require.NoError(t, err) + require.True(t, result.Queued) + require.Equal(t, uuid.NullUUID{UUID: anthropicCfg.ID, Valid: true}, result.QueuedMessage.ModelConfigID) + }) + + t.Run("AbsentStaysNullDespiteDefault", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + result, err := send(ctx, uuid.Nil) + require.NoError(t, err) + require.True(t, result.Queued) + require.False(t, result.QueuedMessage.ModelConfigID.Valid) + }) + + t.Run("NonAnthropicRejected", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := send(ctx, openaiCfg.ID) + require.ErrorIs(t, err, chatd.ErrInvalidModelConfigID) + }) + + t.Run("DisabledRejected", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := send(ctx, disabledCfg.ID) + require.ErrorIs(t, err, chatd.ErrInvalidModelConfigID) + }) + + t.Run("UnknownRejected", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := send(ctx, uuid.New()) + require.ErrorIs(t, err, chatd.ErrInvalidModelConfigID) + }) + + t.Run("EditRejectsNonAnthropic", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: created.Chat.ID}) + require.NoError(t, err) + require.NotEmpty(t, messages) + _, err = replica.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: created.Chat.ID, + CreatedBy: setup.user.ID, + APIKeyID: apiKeyID, + EditedMessageID: messages[0].ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("edited")}, + ModelConfigID: openaiCfg.ID, + }) + require.ErrorIs(t, err, chatd.ErrInvalidModelConfigID) + }) + + t.Run("ValidateForCreatePath", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + require.NoError(t, replica.ValidateClaudeCodeModelConfigID(ctx, anthropicCfg.ID)) + require.ErrorIs(t, replica.ValidateClaudeCodeModelConfigID(ctx, openaiCfg.ID), chatd.ErrInvalidModelConfigID) + require.ErrorIs(t, replica.ValidateClaudeCodeModelConfigID(ctx, disabledCfg.ID), chatd.ErrInvalidModelConfigID) + require.ErrorIs(t, replica.ValidateClaudeCodeModelConfigID(ctx, uuid.New()), chatd.ErrInvalidModelConfigID) + }) +} diff --git a/coderd/x/chatd/claudecode/SPIKE.md b/coderd/x/chatd/claudecode/SPIKE.md index 28f44439cc4..e8ee7d7ee00 100644 --- a/coderd/x/chatd/claudecode/SPIKE.md +++ b/coderd/x/chatd/claudecode/SPIKE.md @@ -154,3 +154,33 @@ capabilities grew (`close`, `delete`, `additionalDirectories`), `authMethods` is now empty (API-key env remains sufficient), and the same five session modes are advertised (`default`, `acceptEdits`, `plan`, `dontAsk`, `bypassPermissions`). + +## Addendum: per-turn model switching over env (gate G1, 2026-07-10) + +Per-message model selection rides the existing per-spawn `ANTHROPIC_MODEL` +injection, so the open question was whether the env pin also wins when the +turn *resumes* a prior session (every turn after the first). Probe design: +local adapter (latest `@agentclientprotocol/claude-agent-acp` via npm, +node v22.19.0, acp-go-sdk v0.13.5), a tee proxy in front of the live +gateway recording the `model` field of every request, and per-phase marker +strings in the prompt to attribute main-turn requests. + +| Phase | Env | Session | Main-turn model requested | +|---|---|---|---| +| 1 | `ANTHROPIC_MODEL=claude-haiku-4-5` | session/new | `claude-haiku-4-5` | +| 2 | `ANTHROPIC_MODEL=claude-sonnet-4-5` | session/resume of phase 1 | `claude-sonnet-4-5` | +| 3 | unset | session/resume of phase 1 | `claude-sonnet-4-5-20250929` | + +Verdict: **G1 passes**. The env pin wins over the resumed session's stored +model, so the env carrier supports per-chat and mid-chat switching with no +new protocol surface; the ACP `session/set_config_option` contingency stays +unused. + +Known limitation (phase 3): with *no* env pin, a resumed session sticks to +the model it last used (in dated form) instead of reverting to the adapter +default. Consequence for model selection: after a user clears their pick +back to "Default" on an org with an *empty* admin model pin, subsequent +turns keep using the previously picked model until a new pick or an admin +pin injects `ANTHROPIC_MODEL` again. With a non-empty admin pin the pin is +injected on every unselected turn, so "Default" behaves as expected. + diff --git a/coderd/x/chatd/generation_claudecode.go b/coderd/x/chatd/generation_claudecode.go index 27a2552f683..408c0f62baf 100644 --- a/coderd/x/chatd/generation_claudecode.go +++ b/coderd/x/chatd/generation_claudecode.go @@ -66,7 +66,7 @@ func (s *taskStarter) startClaudeCodeGeneration( }, generationAttemptNotRequired) } - cfg, err := s.server.claudeCodeTurnConfig(ctx, chat) + cfg, err := s.server.claudeCodeTurnConfig(ctx, chat, turn.modelConfigID) if err != nil { return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired) } @@ -178,6 +178,10 @@ func (s *taskStarter) startClaudeCodeGeneration( Usage: turnUsage, Runtime: elapsed, }, + // The applied selection groups per-model token analytics. + // modelCallConfig stays zero, so these turns intentionally + // carry no cost. + modelConfigID: cfg.modelConfigID, logger: s.opts.Logger, contentVersion: chatprompt.CurrentContentVersion, }) @@ -191,6 +195,10 @@ type claudeCodeTurn struct { generate bool prompt string reseed []claudecode.ReseedTurn + // modelConfigID is the explicit model selection carried by the + // newest triggering user message. Zero means the runtime default + // chain (admin pin, then adapter default). + modelConfigID uuid.UUID } // claudeCodeTurnFromHistory derives the ACP prompt for this turn from @@ -260,10 +268,18 @@ func claudeCodeTurnFromHistory(ctx context.Context, logger slog.Logger, history reseed = append(reseed, claudecode.ReseedTurn{Role: role, Text: text}) } + // The newest trailing user message carries the model selection for + // this turn; picks on earlier queued messages are superseded. + var modelConfigID uuid.UUID + if last := history[len(history)-1]; last.ModelConfigID.Valid { + modelConfigID = last.ModelConfigID.UUID + } + return claudeCodeTurn{ - generate: true, - prompt: prompt.String(), - reseed: reseed, + generate: true, + prompt: prompt.String(), + reseed: reseed, + modelConfigID: modelConfigID, }, nil } @@ -291,12 +307,19 @@ type claudeCodeTurnConfig struct { permissionMode string apiKey string baseURL string + // modelConfigID is the applied explicit selection, stamped on the + // turn's assistant messages. Zero when the runtime default chain + // (admin pin, then adapter default) applied. + modelConfigID uuid.UUID } // claudeCodeTurnConfig loads the organization's runtime config and the // deployment Anthropic key for one turn. The key is injected into the // adapter's process environment and never written to workspace disk. -func (p *Server) claudeCodeTurnConfig(ctx context.Context, chat database.Chat) (claudeCodeTurnConfig, error) { +// A non-zero selection (the triggering user message's model config) +// overrides the admin model pin and sources credentials from the +// selected config's provider. +func (p *Server) claudeCodeTurnConfig(ctx context.Context, chat database.Chat, selection uuid.UUID) (claudeCodeTurnConfig, error) { cfg, err := p.db.GetChatRuntimeConfig(ctx, database.GetChatRuntimeConfigParams{ OrganizationID: chat.OrganizationID, Runtime: chat.Runtime, @@ -323,10 +346,33 @@ func (p *Server) claudeCodeTurnConfig(ctx context.Context, chat database.Chat) ( ) } + out := claudeCodeTurnConfig{ + model: cfg.Model, + permissionMode: cfg.PermissionMode, + } + + var selectedProviderID uuid.UUID + if selection != uuid.Nil { + modelConfig, provider, err := fetchClaudeCodeModelConfig(ctx, p.db, selection) + if err != nil { + // The selection was valid at send time; losing it mid-flight + // (config deleted or disabled, provider changed) falls back + // to the runtime default chain and leaves the assistant + // messages unstamped. + p.logger.Warn(ctx, "claude code turn: selected model config unavailable, using runtime default", + slog.F("chat_id", chat.ID), slog.F("model_config_id", selection), slog.Error(err)) + } else { + out.model = modelConfig.Model + out.modelConfigID = selection + selectedProviderID = provider.ID + } + } + providers, err := p.db.GetAIProviders(ctx, database.GetAIProvidersParams{}) if err != nil { return claudeCodeTurnConfig{}, xerrors.Errorf("get ai providers: %w", err) } + var fallbackKey, fallbackBaseURL string for _, provider := range providers { if provider.Type != database.AIProviderTypeAnthropic { continue @@ -340,20 +386,35 @@ func (p *Server) claudeCodeTurnConfig(ctx context.Context, chat database.Chat) ( if configured.APIKey == "" { continue } - return claudeCodeTurnConfig{ - model: cfg.Model, - permissionMode: cfg.PermissionMode, - apiKey: configured.APIKey, - baseURL: configured.BaseURL, - }, nil + if provider.ID == selectedProviderID { + out.apiKey = configured.APIKey + out.baseURL = configured.BaseURL + return out, nil + } + if fallbackKey == "" { + fallbackKey = configured.APIKey + fallbackBaseURL = configured.BaseURL + } } - return claudeCodeTurnConfig{}, chaterror.WithClassification( - xerrors.New("no anthropic provider key configured"), - chaterror.ClassifiedError{ - Kind: codersdk.ChatErrorKindMissingKey, - Message: "Claude Code requires a deployment Anthropic API key. An administrator must configure the Anthropic AI provider.", - }, - ) + if fallbackKey == "" { + return claudeCodeTurnConfig{}, chaterror.WithClassification( + xerrors.New("no anthropic provider key configured"), + chaterror.ClassifiedError{ + Kind: codersdk.ChatErrorKindMissingKey, + Message: "Claude Code requires a deployment Anthropic API key. An administrator must configure the Anthropic AI provider.", + }, + ) + } + if selectedProviderID != uuid.Nil { + // The selected provider yielded no usable key; keep the model + // but borrow another Anthropic provider's credentials. A + // visible auth failure beats silently ignoring the selection. + p.logger.Warn(ctx, "claude code turn: selected model's provider has no usable key, using fallback anthropic credentials", + slog.F("chat_id", chat.ID), slog.F("model_config_id", selection)) + } + out.apiKey = fallbackKey + out.baseURL = fallbackBaseURL + return out, nil } // ensureClaudeCodeWorkspaceRunning makes sure the chat's bound diff --git a/codersdk/chats.go b/codersdk/chats.go index b54bf632d5c..d855477c677 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -126,8 +126,9 @@ type ChatRuntimeConfig struct { // claude-agent-acp adapter for the claude_code runtime). TemplateID uuid.UUID `json:"template_id" format:"uuid"` Enabled bool `json:"enabled"` - // Model optionally pins the model the runtime agent uses. Empty - // means the runtime default. + // Model optionally pins the default model the runtime agent uses. + // A per-message model selection on the chat overrides this pin; + // empty falls through to the runtime agent's own default. Model string `json:"model,omitempty"` // PermissionMode optionally sets the permission mode the runtime // agent runs with (e.g. acceptEdits). Empty means the runtime @@ -173,8 +174,10 @@ type Chat struct { AgentID *uuid.UUID `json:"agent_id,omitempty" format:"uuid"` ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"` RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"` - // LastModelConfigID is nil for chats on external runtimes, which - // are not backed by a chat model config. + // LastModelConfigID records the most recent explicit model + // selection. On external runtimes it is nil until a model is + // picked and serves only as a client restore hint: the runtime + // default applies whenever a message carries no selection. LastModelConfigID *uuid.UUID `json:"last_model_config_id,omitempty" format:"uuid"` Runtime ChatRuntime `json:"runtime"` Title string `json:"title"` diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 87d19c6f550..fe900c71907 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -204,7 +204,7 @@ Status Code **200** | `»» provider` | string | false | | Provider identifies the upstream model provider when known. | | `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | | `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | -| `» last_model_config_id` | string(uuid) | false | | Last model config ID is nil for chats on external runtimes, which are not backed by a chat model config. | +| `» last_model_config_id` | string(uuid) | false | | Last model config ID records the most recent explicit model selection. On external runtimes it is nil until a model is picked and serves only as a client restore hint: the runtime default applies whenever a message carries no selection. | | `» last_turn_summary` | string | false | | | | `» mcp_server_ids` | array | false | | | | `» organization_id` | string(uuid) | false | | | @@ -557,7 +557,7 @@ Status Code **200** | `[array item]` | array | false | | | | `» created_at` | string(date-time) | false | | | | `» enabled` | boolean | false | | | -| `» model` | string | false | | Model optionally pins the model the runtime agent uses. Empty means the runtime default. | +| `» model` | string | false | | Model optionally pins the default model the runtime agent uses. A per-message model selection on the chat overrides this pin; empty falls through to the runtime agent's own default. | | `» organization_id` | string(uuid) | false | | | | `» permission_mode` | string | false | | Permission mode optionally sets the permission mode the runtime agent runs with (e.g. acceptEdits). Empty means the runtime default. | | `» runtime` | [codersdk.ChatRuntime](schemas.md#codersdkchatruntime) | false | | | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index b8461d66c00..ff8c11797ea 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2234,7 +2234,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `labels` | object | false | | | | » `[any property]` | string | false | | | | `last_error` | [codersdk.ChatError](#codersdkchaterror) | false | | | -| `last_model_config_id` | string | false | | Last model config ID is nil for chats on external runtimes, which are not backed by a chat model config. | +| `last_model_config_id` | string | false | | Last model config ID records the most recent explicit model selection. On external runtimes it is nil until a model is picked and serves only as a client restore hint: the runtime default applies whenever a message carries no selection. | | `last_turn_summary` | string | false | | | | `mcp_server_ids` | array of string | false | | | | `organization_id` | string | false | | | @@ -3441,7 +3441,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in |-------------------|----------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `created_at` | string | false | | | | `enabled` | boolean | false | | | -| `model` | string | false | | Model optionally pins the model the runtime agent uses. Empty means the runtime default. | +| `model` | string | false | | Model optionally pins the default model the runtime agent uses. A per-message model selection on the chat overrides this pin; empty falls through to the runtime agent's own default. | | `organization_id` | string | false | | | | `permission_mode` | string | false | | Permission mode optionally sets the permission mode the runtime agent runs with (e.g. acceptEdits). Empty means the runtime default. | | `runtime` | [codersdk.ChatRuntime](#codersdkchatruntime) | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 68d37ab18a1..1c8898e9e10 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1596,8 +1596,10 @@ export interface Chat { readonly parent_chat_id?: string; readonly root_chat_id?: string; /** - * LastModelConfigID is nil for chats on external runtimes, which - * are not backed by a chat model config. + * LastModelConfigID records the most recent explicit model + * selection. On external runtimes it is nil until a model is + * picked and serves only as a client restore hint: the runtime + * default applies whenever a message carries no selection. */ readonly last_model_config_id?: string; readonly runtime: ChatRuntime; @@ -2950,8 +2952,9 @@ export interface ChatRuntimeConfig { readonly template_id: string; readonly enabled: boolean; /** - * Model optionally pins the model the runtime agent uses. Empty - * means the runtime default. + * Model optionally pins the default model the runtime agent uses. + * A per-message model selection on the chat overrides this pin; + * empty falls through to the runtime agent's own default. */ readonly model?: string; /** diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 90926068a0c..115e873e112 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -89,6 +89,7 @@ import { getAgentChatSendShortcut } from "./utils/agentChatSendShortcut"; import { type ParsedDraft, parseStoredDraft } from "./utils/draftStorage"; import { countConfiguredProviderConfigs, + filterAnthropicModelOptions, getModelSelectorPlaceholder, getUnsupportedProviderNames, hasUserFixableProviders, @@ -720,6 +721,13 @@ const AgentChatPage: FC = () => { const { organizations, experiments } = useDashboard(); const organizationName = getDefaultOrganizationName(organizations); const [selectedModel, setSelectedModel] = useState(""); + // Claude Code chats distinguish an untouched picker from an explicit + // clear back to Default; both leave selectedModel empty. + const [clearedModelToDefault, setClearedModelToDefault] = useState(false); + const handleModelChange = (value: string) => { + setSelectedModel(value); + setClearedModelToDefault(value === ""); + }; const scrollToBottomRef = useRef<(() => void) | null>(null); const chatInputRef = useRef(null); const inputValueRef = useRef( @@ -1118,10 +1126,34 @@ const AgentChatPage: FC = () => { ); const prNumber = chatQuery.data?.diff_status?.pr_number ?? (parsedPrNumber || undefined); + // Claude Code chats only honor Anthropic models (the runtime + // injects Anthropic credentials into the adapter). + const claudeModelOptions = filterAnthropicModelOptions(modelOptions); // Compute an effective selected model by validating the user's // explicit choice against the current model options, falling // back to the chat's last model or the first available option. const effectiveSelectedModel = (() => { + if (isClaudeCodeChat) { + // An explicit pick wins and an explicit clear stays on + // Default; otherwise restore the chat's last pick while it + // is still selectable. No first-option fallback: empty + // means the runtime default chain (admin pin, then adapter + // default). Known quirk: clearing does not null + // last_model_config_id, so a fresh window preselects the + // old pick again. + const resolvedSelectedModel = resolveModelOptionId( + selectedModel, + claudeModelOptions, + ); + if (resolvedSelectedModel) { + return resolvedSelectedModel; + } + if (clearedModelToDefault) { + return ""; + } + return resolveModelOptionId(chatLastModelConfigID, claudeModelOptions); + } + const resolvedSelectedModel = resolveModelOptionId( selectedModel, modelOptions, @@ -1456,9 +1488,12 @@ const AgentChatPage: FC = () => { ); const originalModelConfigID = originalEditedMessage?.model_config_id; const pickerModelConfigID = effectiveSelectedModel || undefined; + const selectableOptions = isClaudeCodeChat + ? claudeModelOptions + : modelOptions; const originalIsSelectable = originalModelConfigID !== undefined && - modelOptions.some((opt) => opt.id === originalModelConfigID); + selectableOptions.some((opt) => opt.id === originalModelConfigID); // Only override the original model when the user has switched to // a different selectable option. If the original is no longer // selectable, the picker is showing a fallback we should not @@ -1471,10 +1506,7 @@ const AgentChatPage: FC = () => { : undefined; const request: TypesGen.EditChatMessageRequest = { content, - // Runtime chats have no model config to override. - model_config_id: isClaudeCodeChat - ? undefined - : editSelectedModelConfigID, + model_config_id: editSelectedModelConfigID, }; const optimisticMessage = originalEditedMessage ? buildOptimisticEditedMessage({ @@ -1504,7 +1536,8 @@ const AgentChatPage: FC = () => { handleUsageLimitError(error); }, }); - if (editSelectedModelConfigID) { + // Claude picks stay out of the coder-runtime last-used hint. + if (editSelectedModelConfigID && !isClaudeCodeChat) { localStorage.setItem( lastModelConfigIDStorageKey, editSelectedModelConfigID, @@ -1514,11 +1547,11 @@ const AgentChatPage: FC = () => { } const selectedModelConfigID = effectiveSelectedModel || undefined; - // Runtime chats reject model, MCP, and plan options; they only - // carry message content. + // Runtime chats reject MCP and plan options; the model pick is + // explicit per send, omitted means the runtime default. const request: CreateChatMessageRequestWithClearablePlanMode = isClaudeCodeChat - ? { content } + ? { content, model_config_id: selectedModelConfigID } : { content, model_config_id: selectedModelConfigID, @@ -1570,10 +1603,16 @@ const AgentChatPage: FC = () => { upsertCacheMessages([response.message]); } } - if (selectedModelConfigID) { - localStorage.setItem(lastModelConfigIDStorageKey, selectedModelConfigID); - } else { - localStorage.removeItem(lastModelConfigIDStorageKey); + // Claude picks stay out of the coder-runtime last-used hint. + if (!isClaudeCodeChat) { + if (selectedModelConfigID) { + localStorage.setItem( + lastModelConfigIDStorageKey, + selectedModelConfigID, + ); + } else { + localStorage.removeItem(lastModelConfigIDStorageKey); + } } if (planModeSwitch !== undefined) { setCachedChatPlanMode( @@ -1625,8 +1664,8 @@ const AgentChatPage: FC = () => { onContentChange={editing.handleLoadingDraftChange} isInputDisabled={isInputDisabled} effectiveSelectedModel={effectiveSelectedModel} - setSelectedModel={setSelectedModel} - modelOptions={modelOptions} + setSelectedModel={handleModelChange} + modelOptions={isClaudeCodeChat ? claudeModelOptions : modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} hasModelOptions={hasModelOptions} isModelCatalogLoading={isModelCatalogLoading} @@ -1671,8 +1710,8 @@ const AgentChatPage: FC = () => { store={store} editing={editing} effectiveSelectedModel={effectiveSelectedModel} - setSelectedModel={setSelectedModel} - modelOptions={modelOptions} + setSelectedModel={handleModelChange} + modelOptions={isClaudeCodeChat ? claudeModelOptions : modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} modelSelectorHelp={modelSelectorHelp} canConfigureAgentSetup={permissions.editDeploymentConfig} diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index f103ad46eaa..14e2ea1eba6 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -115,14 +115,16 @@ const AgentCreatePage: FC = () => { content.push({ type: "file", file_id: fileID }); } } - // Runtime chats: the server rejects workspace, model, plan, and - // MCP options because the runtime manages them. + // Runtime chats: the server rejects workspace, plan, and MCP + // options because the runtime manages them. The model is an + // optional explicit pick (absent means the runtime default). const createRequest: TypesGen.CreateChatRequest = runtime ? { organization_id: organizationId, content, client_type: "ui", runtime, + ...(model ? { model_config_id: model } : {}), } : { organization_id: organizationId, @@ -136,7 +138,8 @@ const AgentCreatePage: FC = () => { }; const createdChat = await createMutation.mutateAsync(createRequest); - if (model) { + // Claude picks stay out of the coder-runtime last-used hint. + if (model && !runtime) { localStorage.setItem(lastModelConfigIDStorageKey, model); } navigate({ diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index cb9b18cbe33..ad9d0bab12d 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -1266,9 +1266,25 @@ export const LongWorkspaceNameMobile: Story = { }, }; +const claudeModelOptions = [ + { + id: "model-config-claude-sonnet", + provider: "anthropic", + model: "claude-sonnet-4-5", + displayName: "Claude Sonnet 4.5", + }, + { + id: "model-config-claude-haiku", + provider: "anthropic", + model: "claude-haiku-4-5", + displayName: "Claude Haiku 4.5", + }, +] as const; + /** - * Claude Code runtime pinned: the model selector is replaced by a - * "Claude Code" badge and sending works without model options. + * Claude Code runtime pinned with no Anthropic model options: the + * composer shows only the "Claude Code" badge and sending works + * without model options. */ export const ClaudeCodePinned: Story = { args: { @@ -1330,11 +1346,72 @@ export const ClaudeCodeMenuItem: Story = { }, }; +/** + * Claude Code chats with Anthropic model options render a picker with + * an explicit Default row next to the badge; picking a model reports + * its config id. + */ +export const ClaudeCodeModelPicker: Story = { + args: { + claudeCodeEnabled: true, + onClaudeCodeToggle: fn(), + hasModelOptions: false, + modelOptions: [...claudeModelOptions], + selectedModel: "", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + expect(await canvas.findByTestId("claude-code-badge")).toBeVisible(); + // No selection: the trigger shows the Default row's label. + const trigger = canvas.getByRole("combobox", { name: "Default" }); + await userEvent.click(trigger); + const body = within(document.body); + const defaultOption = await body.findByRole("option", { + name: /Default/, + }); + // The popover fades in; wait out the enter animation. + await waitFor(() => expect(defaultOption).toBeVisible()); + await userEvent.click( + await body.findByRole("option", { name: /Claude Haiku 4.5/ }), + ); + await waitFor(() => { + expect(args.onModelChange).toHaveBeenCalledWith( + "model-config-claude-haiku", + ); + }); + }, +}; + +/** Picking the Default row clears an active Claude Code model pick. */ +export const ClaudeCodeModelPickerClearsToDefault: Story = { + args: { + claudeCodeEnabled: true, + onClaudeCodeToggle: fn(), + hasModelOptions: false, + modelOptions: [...claudeModelOptions], + selectedModel: "model-config-claude-sonnet", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("combobox", { + name: "Claude Sonnet 4.5", + }); + await userEvent.click(trigger); + const body = within(document.body); + await userEvent.click(await body.findByRole("option", { name: /Default/ })); + await waitFor(() => { + expect(args.onModelChange).toHaveBeenCalledWith(""); + }); + }, +}; + /** Dismissing the pinned badge exits Claude Code mode. */ export const ClaudeCodeBadgeDismiss: Story = { args: { claudeCodeEnabled: true, onClaudeCodeToggle: fn(), + modelOptions: [], + selectedModel: "", }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index c3843eb89ff..2ddcf54e670 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -123,9 +123,12 @@ interface AgentChatInputProps { planModeEnabled?: boolean; onPlanModeToggle?: (enabled: boolean) => void; // Claude Code runtime. When enabled the composer is pinned to the - // runtime: the model selector is hidden and model options are not - // required to send. The toggle only renders on the landing composer; - // existing runtime chats pass claudeCodeEnabled without a toggle. + // runtime and model options are not required to send. Callers pass + // Anthropic-only modelOptions (the runtime injects Anthropic + // credentials); a non-empty list renders a picker with an explicit + // Default row next to the badge. The toggle only renders on the + // landing composer; existing runtime chats pass claudeCodeEnabled + // without a toggle. claudeCodeEnabled?: boolean; onClaudeCodeToggle?: (enabled: boolean) => void; isModelCatalogLoading?: boolean; @@ -1457,20 +1460,35 @@ export const AgentChatInput: FC = ({ {claudeCodeEnabled ? ( - - - Claude Code - {onClaudeCodeToggle && ( - + + + Claude Code + {onClaudeCodeToggle && ( + + )} + + {modelOptions.length > 0 && ( + )} - + ) : isModelCatalogLoading ? ( ) : ( diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index a0d0b72e93c..b1c302325b8 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -833,9 +833,9 @@ export const PermittedOrgsResolvesToSubset: Story = { }; /** - * Selecting "Run with Claude Code" pins the composer to the runtime - * and the submission carries runtime instead of workspace/model/plan - * options. + * Selecting "Run with Claude Code" pins the composer to the runtime. + * The submission carries runtime without workspace/plan options and, + * with the picker on its preselected Default, no model either. */ export const ClaudeCodeRuntimeSubmission: Story = { args: { @@ -851,9 +851,11 @@ export const ClaudeCodeRuntimeSubmission: Story = { }); await userEvent.click(item); - // The composer is pinned: badge visible, model selector gone. + // The composer is pinned: badge visible, and the Anthropic-only + // picker preselects Default (ignoring any last-used model). const badge = await canvas.findByTestId("claude-code-badge"); expect(badge).toBeVisible(); + expect(canvas.getByRole("combobox", { name: "Default" })).toBeVisible(); await submitMessage(canvasElement, "build me a server"); await waitFor(() => { @@ -866,6 +868,46 @@ export const ClaudeCodeRuntimeSubmission: Story = { }, }; +/** + * Picking an Anthropic model on a Claude Code create submits its + * config id; non-Anthropic options are filtered out of the picker. + */ +export const ClaudeCodeRuntimeModelPick: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + claudeCodeOrgIds: new Set([MockDefaultOrganization.id]), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "More options" })); + await userEvent.click( + await screen.findByRole("menuitemcheckbox", { + name: /Run with Claude Code/, + }), + ); + + await userEvent.click( + await canvas.findByRole("combobox", { name: "Default" }), + ); + const body = within(document.body); + expect( + body.queryByRole("option", { name: /GPT-4o/ }), + ).not.toBeInTheDocument(); + await userEvent.click( + await body.findByRole("option", { name: /Claude Sonnet 4/ }), + ); + + await submitMessage(canvasElement, "build me a server"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + const options = getCreateOptions(args.onCreateChat); + expect(options.runtime).toBe("claude_code"); + expect(options.model).toBe(claudeModelConfigID); + }, +}; + /** * Runtime chats still require the AI gateway (chatd only exists when * it is enabled), so a disabled gateway keeps the composer disabled diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index c1730f3ee92..76cbd957344 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -15,10 +15,12 @@ import { docs } from "#/utils/docs"; import { useFileAttachments } from "../hooks/useFileAttachments"; import { parseStoredDraft } from "../utils/draftStorage"; import { + filterAnthropicModelOptions, getModelSelectorPlaceholder, getProviderForModelOption, hasConfiguredModelsInCatalog, hasUserFixableProviders, + resolveModelOptionId, } from "../utils/modelOptions"; import { formatUsageLimitMessage, @@ -52,7 +54,8 @@ export type CreateChatOptions = { planMode?: TypesGen.ChatPlanMode; // runtime selects an external generation runtime. The server // creates and binds a workspace from the admin-configured template, - // so workspace, model, MCP, and plan options do not apply. + // so workspace, MCP, and plan options do not apply; model is an + // optional explicit Anthropic pick (absent means runtime default). runtime?: TypesGen.ChatRuntime; }; @@ -295,6 +298,15 @@ export const AgentCreateForm: FC = ({ ); const claudeCodeEnabled = claudeCodeAvailable && claudeCodeSelectedOrgId === organizationId; + // Claude Code chats only honor Anthropic models. The pick starts on + // Default (no localStorage restore): absent means the runtime + // default chain. + const claudeModelOptions = filterAnthropicModelOptions(modelOptions); + const [claudeSelectedModel, setClaudeSelectedModel] = useState(""); + const effectiveClaudeModel = resolveModelOptionId( + claudeSelectedModel, + claudeModelOptions, + ); const hasModelOptions = modelOptions.length > 0; const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog); const hasUserFixableModelProviders = hasUserFixableProviders(modelCatalog); @@ -388,6 +400,7 @@ export const AgentCreateForm: FC = ({ message, organizationId, runtime: "claude_code", + model: effectiveClaudeModel || undefined, } : { message, @@ -556,9 +569,13 @@ export const AgentCreateForm: FC = ({ initialValue={initialInputValue} initialEditorState={initialEditorState} onContentChange={handleContentChange} - selectedModel={selectedModel} - onModelChange={handleModelChange} - modelOptions={modelOptions} + selectedModel={ + claudeCodeEnabled ? effectiveClaudeModel : selectedModel + } + onModelChange={ + claudeCodeEnabled ? setClaudeSelectedModel : handleModelChange + } + modelOptions={claudeCodeEnabled ? claudeModelOptions : modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} isModelCatalogLoading={isModelCatalogLoading} hasModelOptions={hasModelOptions} diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index d6285f7ed87..fb41f50fd56 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -44,6 +44,11 @@ interface ModelSelectorProps { contentClassName?: string; onTriggerTouchStart?: () => void; enableMobileFullWidthDropdown?: boolean; + // defaultOptionLabel, when set, prepends an explicit row mapping to + // the empty value so an active pick can be cleared back to the + // runtime default. The trigger shows this label while no option is + // selected. + defaultOptionLabel?: string; } const formatContextLimit = (tokens: number): string => { @@ -85,6 +90,7 @@ export const ModelSelector: FC = ({ contentClassName, onTriggerTouchStart, enableMobileFullWidthDropdown = false, + defaultOptionLabel, }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -97,6 +103,9 @@ export const ModelSelector: FC = ({ const selectedModel = options.find((option) => option.id === value); const isDisabled = disabled || options.length === 0; const query = search.trim().toLowerCase(); + const showDefaultOption = + defaultOptionLabel !== undefined && + (!query || defaultOptionLabel.toLowerCase().includes(query)); const optionsByProvider = (() => { const grouped = new Map(); @@ -122,7 +131,11 @@ export const ModelSelector: FC = ({ @@ -183,6 +198,31 @@ export const ModelSelector: FC = ({ {emptyMessage} + {showDefaultOption && ( + + { + onValueChange(""); + handleOpenChange(false); + }} + className={cn( + "gap-2 px-2 py-1 font-medium text-content-secondary data-[selected=true]:bg-surface-tertiary", + !selectedModel && "bg-surface-secondary", + )} + > + + {defaultOptionLabel} + + + + + )} {optionsByProvider.map(([providerKey, providerOptions], index) => { const firstOption = providerOptions[0]; const providerLabel = getProviderLabel( diff --git a/site/src/pages/AgentsPage/utils/modelOptions.ts b/site/src/pages/AgentsPage/utils/modelOptions.ts index 6bae7d6730b..b3016ff0f5f 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.ts @@ -276,6 +276,14 @@ export const getModelOptionsFromConfigs = ( }); }; +// filterAnthropicModelOptions keeps only options served by an Anthropic +// provider. Claude Code chats inject Anthropic credentials into the +// runtime agent, so other providers cannot be honored there. +export const filterAnthropicModelOptions = ( + options: readonly ModelSelectorOption[], +): readonly ModelSelectorOption[] => + options.filter((option) => option.provider === "anthropic"); + // Read slice of a react-query result. The field types come from UseQueryResult // by indexed access, not Pick (which would distribute over v5's status union), // so they track the library rather than being hand-maintained. From 1c45a946b68b0b817bc94d7508508378148265fe Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:10:42 +0000 Subject: [PATCH 2/3] refactor(coderd/x/chatd): simplify model selection validation and dedupe frontend option plumbing - EditMessage reuses resolveSendMessageModelConfigID instead of reimplementing runtime-specific model config validation. - AgentChatPage derives selectableModelOptions once instead of repeating the isClaudeCodeChat ternary at four call sites. - ModelSelector computes the trigger label once for aria-label and the visible text. - filterAnthropicModelOptions compares against a typed AIProviderType constant. - AgentCreateForm handleSend comment updated to reflect the optional model pick on runtime chats. --- coderd/x/chatd/chatd.go | 39 +++---------------- site/src/pages/AgentsPage/AgentChatPage.tsx | 20 +++++----- .../AgentsPage/components/AgentCreateForm.tsx | 7 ++-- .../components/ChatElements/ModelSelector.tsx | 15 +++---- .../pages/AgentsPage/utils/modelOptions.ts | 3 +- 5 files changed, 28 insertions(+), 56 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index dcbbb62f26b..acdfc162576 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1728,39 +1728,12 @@ func (p *Server) EditMessage( // foreign-key error from the message-insert path. var modelOverride uuid.NullUUID if opts.ModelConfigID != uuid.Nil { - switch lockedChat.Runtime { - case database.ChatRuntimeCoder: - if _, err := store.GetChatModelConfigByID( - chatdModelConfigLookupContext(ctx), - opts.ModelConfigID, - ); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return xerrors.Errorf( - "%w: %s", - ErrInvalidModelConfigID, - opts.ModelConfigID, - ) - } - return xerrors.Errorf( - "get requested model config %s: %w", - opts.ModelConfigID, - err, - ) - } - case database.ChatRuntimeClaudeCode: - if _, _, err := fetchClaudeCodeModelConfig( - chatdModelConfigLookupContext(ctx), - store, - opts.ModelConfigID, - ); err != nil { - return err - } - default: - return xerrors.Errorf( - "%w: model config cannot be set on %s runtime chats", - ErrInvalidModelConfigID, - lockedChat.Runtime, - ) + // A non-zero requested ID goes through the same + // runtime-specific validation as the send path. + if _, err := resolveSendMessageModelConfigID( + ctx, store, lockedChat, opts.ModelConfigID, + ); err != nil { + return err } modelOverride = uuid.NullUUID{UUID: opts.ModelConfigID, Valid: true} } diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 115e873e112..5aa9add3145 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1128,7 +1128,9 @@ const AgentChatPage: FC = () => { chatQuery.data?.diff_status?.pr_number ?? (parsedPrNumber || undefined); // Claude Code chats only honor Anthropic models (the runtime // injects Anthropic credentials into the adapter). - const claudeModelOptions = filterAnthropicModelOptions(modelOptions); + const selectableModelOptions = isClaudeCodeChat + ? filterAnthropicModelOptions(modelOptions) + : modelOptions; // Compute an effective selected model by validating the user's // explicit choice against the current model options, falling // back to the chat's last model or the first available option. @@ -1143,7 +1145,7 @@ const AgentChatPage: FC = () => { // old pick again. const resolvedSelectedModel = resolveModelOptionId( selectedModel, - claudeModelOptions, + selectableModelOptions, ); if (resolvedSelectedModel) { return resolvedSelectedModel; @@ -1151,7 +1153,10 @@ const AgentChatPage: FC = () => { if (clearedModelToDefault) { return ""; } - return resolveModelOptionId(chatLastModelConfigID, claudeModelOptions); + return resolveModelOptionId( + chatLastModelConfigID, + selectableModelOptions, + ); } const resolvedSelectedModel = resolveModelOptionId( @@ -1488,12 +1493,9 @@ const AgentChatPage: FC = () => { ); const originalModelConfigID = originalEditedMessage?.model_config_id; const pickerModelConfigID = effectiveSelectedModel || undefined; - const selectableOptions = isClaudeCodeChat - ? claudeModelOptions - : modelOptions; const originalIsSelectable = originalModelConfigID !== undefined && - selectableOptions.some((opt) => opt.id === originalModelConfigID); + selectableModelOptions.some((opt) => opt.id === originalModelConfigID); // Only override the original model when the user has switched to // a different selectable option. If the original is no longer // selectable, the picker is showing a fallback we should not @@ -1665,7 +1667,7 @@ const AgentChatPage: FC = () => { isInputDisabled={isInputDisabled} effectiveSelectedModel={effectiveSelectedModel} setSelectedModel={handleModelChange} - modelOptions={isClaudeCodeChat ? claudeModelOptions : modelOptions} + modelOptions={selectableModelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} hasModelOptions={hasModelOptions} isModelCatalogLoading={isModelCatalogLoading} @@ -1711,7 +1713,7 @@ const AgentChatPage: FC = () => { editing={editing} effectiveSelectedModel={effectiveSelectedModel} setSelectedModel={handleModelChange} - modelOptions={isClaudeCodeChat ? claudeModelOptions : modelOptions} + modelOptions={selectableModelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} modelSelectorHelp={modelSelectorHelp} canConfigureAgentSetup={permissions.editDeploymentConfig} diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 76cbd957344..4910db07d41 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -392,9 +392,10 @@ export const AgentCreateForm: FC = ({ const handleSend = async (message: string, fileIDs?: string[]) => { submitDraft(); - // Runtime chats carry only the message: the server binds a - // workspace and the runtime manages its own model, so the - // picker-driven options and file attachments are not sent. + // Runtime chats: the server binds a workspace, so workspace, + // MCP, plan, and file attachments are not sent. The model is + // an optional explicit Anthropic pick (absent means the + // runtime default). const options: CreateChatOptions = claudeCodeEnabled ? { message, diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index fb41f50fd56..bd4ebd0279f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -126,16 +126,15 @@ export const ModelSelector: FC = ({ return Array.from(grouped.entries()); })(); + const triggerLabel = selectedModel + ? selectedModel.displayName + : (defaultOptionLabel ?? placeholder); return ( diff --git a/site/src/pages/AgentsPage/utils/modelOptions.ts b/site/src/pages/AgentsPage/utils/modelOptions.ts index b3016ff0f5f..fccd3b8dd4f 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.ts @@ -279,10 +279,11 @@ export const getModelOptionsFromConfigs = ( // filterAnthropicModelOptions keeps only options served by an Anthropic // provider. Claude Code chats inject Anthropic credentials into the // runtime agent, so other providers cannot be honored there. +const anthropicProviderType = "anthropic" satisfies TypesGen.AIProviderType; export const filterAnthropicModelOptions = ( options: readonly ModelSelectorOption[], ): readonly ModelSelectorOption[] => - options.filter((option) => option.provider === "anthropic"); + options.filter((option) => option.provider === anthropicProviderType); // Read slice of a react-query result. The field types come from UseQueryResult // by indexed access, not Pick (which would distribute over v5's status union), From da4f6eef62a44792ddf65c1057e801adb8e125dd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:19:57 +0000 Subject: [PATCH 3/3] refactor: trim comments that narrate control flow in model selection --- coderd/x/chatd/chatd.go | 2 -- site/src/pages/AgentsPage/AgentChatPage.tsx | 11 ++++------- .../AgentsPage/components/AgentChatInput.stories.tsx | 1 - .../pages/AgentsPage/components/AgentChatInput.tsx | 8 +++----- .../components/ChatElements/ModelSelector.tsx | 3 +-- 5 files changed, 8 insertions(+), 17 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index acdfc162576..2be300e878f 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1728,8 +1728,6 @@ func (p *Server) EditMessage( // foreign-key error from the message-insert path. var modelOverride uuid.NullUUID if opts.ModelConfigID != uuid.Nil { - // A non-zero requested ID goes through the same - // runtime-specific validation as the send path. if _, err := resolveSendMessageModelConfigID( ctx, store, lockedChat, opts.ModelConfigID, ); err != nil { diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 5aa9add3145..106475ae1ec 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1136,13 +1136,10 @@ const AgentChatPage: FC = () => { // back to the chat's last model or the first available option. const effectiveSelectedModel = (() => { if (isClaudeCodeChat) { - // An explicit pick wins and an explicit clear stays on - // Default; otherwise restore the chat's last pick while it - // is still selectable. No first-option fallback: empty - // means the runtime default chain (admin pin, then adapter - // default). Known quirk: clearing does not null - // last_model_config_id, so a fresh window preselects the - // old pick again. + // No first-option fallback: empty means the runtime + // default chain (admin pin, then adapter default). Known + // quirk: clearing does not null last_model_config_id, so a + // fresh window preselects the old pick again. const resolvedSelectedModel = resolveModelOptionId( selectedModel, selectableModelOptions, diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index ad9d0bab12d..91fdab03c04 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -1362,7 +1362,6 @@ export const ClaudeCodeModelPicker: Story = { play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); expect(await canvas.findByTestId("claude-code-badge")).toBeVisible(); - // No selection: the trigger shows the Default row's label. const trigger = canvas.getByRole("combobox", { name: "Default" }); await userEvent.click(trigger); const body = within(document.body); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 2ddcf54e670..22ba9a35a8b 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -123,12 +123,10 @@ interface AgentChatInputProps { planModeEnabled?: boolean; onPlanModeToggle?: (enabled: boolean) => void; // Claude Code runtime. When enabled the composer is pinned to the - // runtime and model options are not required to send. Callers pass + // runtime and model options are not required to send; callers pass // Anthropic-only modelOptions (the runtime injects Anthropic - // credentials); a non-empty list renders a picker with an explicit - // Default row next to the badge. The toggle only renders on the - // landing composer; existing runtime chats pass claudeCodeEnabled - // without a toggle. + // credentials). The toggle only renders on the landing composer; + // existing runtime chats pass claudeCodeEnabled without a toggle. claudeCodeEnabled?: boolean; onClaudeCodeToggle?: (enabled: boolean) => void; isModelCatalogLoading?: boolean; diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index bd4ebd0279f..94214518e0f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -46,8 +46,7 @@ interface ModelSelectorProps { enableMobileFullWidthDropdown?: boolean; // defaultOptionLabel, when set, prepends an explicit row mapping to // the empty value so an active pick can be cleared back to the - // runtime default. The trigger shows this label while no option is - // selected. + // runtime default. defaultOptionLabel?: string; }