Skip to content
Open
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
4 changes: 2 additions & 2 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 21 additions & 12 deletions coderd/exp_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.",
Expand Down
4 changes: 3 additions & 1 deletion coderd/x/chatd/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand Down
101 changes: 72 additions & 29 deletions coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -1664,31 +1728,10 @@ 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,
if _, err := resolveSendMessageModelConfigID(
ctx, store, lockedChat, 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,
)
return err
}
modelOverride = uuid.NullUUID{UUID: opts.ModelConfigID, Valid: true}
}
Expand Down
Loading
Loading