feat(chatd): use lightweight model candidates for title generation (#22605)

## Problem

Title generation uses the same model the user selected for chat. This
breaks when:

1. **Thinking/extended thinking models** — `ToolChoice: None` conflicts
with extended thinking on Anthropic. The bare call has no thinking
config, so provider-level defaults can conflict.
2. **Expensive models** — User picks `o3` or `claude-opus-4`, and a
trivial 8-word title generation burns through tokens/cost unnecessarily.
3. **Provider quirks** — Different providers have different constraints
around thinking mode + tool choice combinations.

## Solution

Modeled after how `coder/mux` handles this with
`NAME_GEN_PREFERRED_MODELS` + ordered candidate fallback:

### Phase 1: Candidate model list with fallback
- New `TitleModelFunc` type returns an ordered list of candidate models
- Tries `claude-haiku-4-5` → `gpt-4o-mini` → user's model
- Gracefully skips unavailable candidates (missing API key, provider not
configured)
- Falls back to the user's chat model as last resort

### Phase 2: Provider-safe call options
- Removed `ToolChoice: None` which conflicts with extended thinking on
some providers
- Added `MaxOutputTokens: 256` to cap token usage
- Improved title prompt with verb-noun format guidance (`Fix sidebar
layout`, `Add user authentication`) and explicit
no-markdown/no-code-fences instructions

### Files changed
- `coderd/chatd/title.go` — Candidate loop, improved prompt, safe call
options
- `coderd/chatd/chatd.go` — Build `TitleModelFunc` closure with
lightweight candidates
This commit is contained in:
Kyle Carberry
2026-03-04 16:03:03 +00:00
committed by GitHub
parent f4a7fa5b95
commit ec89abd6e5
2 changed files with 88 additions and 32 deletions
+7 -7
View File
@@ -1904,7 +1904,7 @@ func (p *Server) runChat(
chat database.Chat,
logger slog.Logger,
) error {
model, modelConfig, err := p.resolveChatModel(ctx, chat)
model, modelConfig, providerKeys, err := p.resolveChatModel(ctx, chat)
if err != nil {
return err
}
@@ -1926,7 +1926,7 @@ func (p *Server) runChat(
p.inflight.Add(1)
go func() {
defer p.inflight.Done()
p.maybeGenerateChatTitle(context.WithoutCancel(ctx), chat, messages, model, logger)
p.maybeGenerateChatTitle(context.WithoutCancel(ctx), chat, messages, model, providerKeys, logger)
}()
prompt, err := chatprompt.ConvertMessages(messages)
@@ -2406,17 +2406,17 @@ func (p *Server) persistChatContextSummary(
func (p *Server) resolveChatModel(
ctx context.Context,
chat database.Chat,
) (fantasy.LanguageModel, database.ChatModelConfig, error) {
) (fantasy.LanguageModel, database.ChatModelConfig, chatprovider.ProviderAPIKeys, error) {
dbConfig, err := p.resolveModelConfig(ctx, chat)
if err != nil {
return nil, database.ChatModelConfig{}, xerrors.Errorf(
return nil, database.ChatModelConfig{}, chatprovider.ProviderAPIKeys{}, xerrors.Errorf(
"resolve model config: %w", err,
)
}
providers, err := p.db.GetEnabledChatProviders(ctx)
if err != nil {
return nil, database.ChatModelConfig{}, xerrors.Errorf(
return nil, database.ChatModelConfig{}, chatprovider.ProviderAPIKeys{}, xerrors.Errorf(
"get enabled chat providers: %w", err,
)
}
@@ -2438,11 +2438,11 @@ func (p *Server) resolveChatModel(
dbConfig.Provider, dbConfig.Model, keys,
)
if err != nil {
return nil, database.ChatModelConfig{}, xerrors.Errorf(
return nil, database.ChatModelConfig{}, chatprovider.ProviderAPIKeys{}, xerrors.Errorf(
"create model: %w", err,
)
}
return model, dbConfig, nil
return model, dbConfig, keys, nil
}
// resolveModelConfig looks up the chat's model config by its