Skip to content
Merged
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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -683,12 +683,14 @@ Built-in model maps:
|----------|---------|-------|--------|-------|
| `openai` | `codex_cli` | `gpt-5.4-mini` | `gpt-5.4` | `gpt-5.5` |
| `openai` | `openai_api` | `gpt-5.4-mini` | `gpt-5.4` | `gpt-5.5` |
| `anthropic` | `claude_cli` | unset | `claude-sonnet-5` | `claude-opus-5` |
| `anthropic` | `claude_cli` | `claude-haiku-4-5` | `claude-sonnet-5` | `claude-opus-5` |
| `anthropic` | `anthropic_api` | unset | unset | unset |
| `pi` | `pi_rpc` | unset | unset | unset |

`anthropic_api`, `pi_rpc`, and `claude_cli` small-tier usage require explicit
`llm.model_map` entries.
`anthropic_api` and `pi_rpc` require explicit `llm.model_map` entries for every
tier an agent asks for; an unmapped tier fails the run and names the entry to
add. A built-in mapping is a floor, not a recommendation: override the tier in
`llm.model_map` when a stage deserves a stronger model than its tier implies.

For Anthropic subscription profiles, `adapter: claude_cli` runs Claude Code
background jobs, writes the full review task to `cr-prompt.txt` in an
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/configcmd/configcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,7 @@ func newLLMCommand(opts *root.Options) *cobra.Command {
}
resolved, ok := config.ResolveModelTier(profile.LLM, tier)
if !ok {
return fmt.Errorf("model_tier %q is not mapped for provider %q adapter %q", tier, profile.LLM.Provider, profile.LLM.Adapter)
return fmt.Errorf("model_tier %q is not mapped for provider %q adapter %q; add llm.model_map.%s to the profile's LLM runtime", tier, profile.LLM.Provider, profile.LLM.Adapter, tier)
}
result := modelResolveResult{
ActiveProfile: profileName,
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/configcmd/configcmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2130,7 +2130,7 @@ func TestConfigLLMModelsListAndResolve(t *testing.T) {
if err := root.Execute(cmd, []string{"--profile", "home", "config", "llm", "models", "list"}); err != nil {
t.Fatalf("Execute list: %v", err)
}
if !strings.Contains(out.String(), "small: <unset> (unset)") ||
if !strings.Contains(out.String(), "small: claude-haiku-4-5 (built_in)") ||
!strings.Contains(out.String(), "medium: claude-sonnet-5 (built_in)") ||
!strings.Contains(out.String(), "large: claude-opus-5 (built_in)") {
t.Fatalf("list stdout = %q, want effective Claude CLI defaults", out.String())
Expand Down
7 changes: 4 additions & 3 deletions internal/cmd/initcmd/initcmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10324,8 +10324,9 @@ func TestInitProfileV2ModelMapInputsDraftOverridesAndClears(t *testing.T) {
), 160, 40)

model = focusInitProfileV2Field(t, model, initProfileV2FieldModelMap(config.ModelTierSmall))
model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlU})
if !strings.Contains(model.View(), "> |") {
t.Fatalf("view missing editable cursor for empty small model field:\n%s", model.View())
t.Fatalf("view missing editable cursor for cleared small model field:\n%s", model.View())
}
model = typeInitProfileV2Text(t, model, "claude-haiku-custom")
model = focusInitProfileV2Field(t, model, initProfileV2FieldModelMap(config.ModelTierMedium))
Expand Down Expand Up @@ -10364,8 +10365,8 @@ func TestInitProfileV2LLMRuntimeSelectionRefreshesModelMapFields(t *testing.T) {
},
}
model := newInitProfileV2ReadOnlyModel(newTestInitProfileV2EditorWithRuntimeAndModelMap("monit", "github.com/SignalFT", llmRuntimes, "claude-work"), 160, 24)
if got := model.document.fieldValue(initProfileV2FieldModelMap(config.ModelTierSmall)); got != "" {
t.Fatalf("initial small model = %q, want unmapped Claude small model", got)
if got := model.document.fieldValue(initProfileV2FieldModelMap(config.ModelTierSmall)); got != "claude-haiku-4-5" {
t.Fatalf("initial small model = %q, want Claude built-in", got)
}
if got := model.document.fieldValue(initProfileV2FieldModelMap(config.ModelTierMedium)); got != "claude-sonnet-5" {
t.Fatalf("initial medium model = %q, want Claude built-in", got)
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ var llmRuntimeSpecs = []LLMRuntimeSpec{
SuggestedName: "claude-cli",
DisplayName: "Claude CLI",
BuiltInModelMap: ModelMap{
string(ModelTierSmall): "claude-haiku-4-5",
string(ModelTierMedium): "claude-sonnet-5",
string(ModelTierLarge): "claude-opus-5",
},
Expand Down
1 change: 1 addition & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ func TestBuiltInModelMapIsProviderAdapterSpecific(t *testing.T) {
provider: LLMProviderAnthropic,
adapter: LLMAdapterClaudeCLI,
want: ModelMap{
"small": "claude-haiku-4-5",
"medium": "claude-sonnet-5",
"large": "claude-opus-5",
},
Expand Down
8 changes: 8 additions & 0 deletions internal/pipeline/pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3110,6 +3110,14 @@ func TestDryRunFastPreflightResolvesEveryReviewerBeforeLLM(t *testing.T) {
store := openPipelineStore(t)
defer closeStore(t, store)
provider, req := dryRunHarness(t)
// Every built-in Claude CLI tier resolves, so the reviewer's tier has to be
// unmapped by the profile's own map for preflight to have anything to catch.
req.Profile.LLM = config.LLMConfig{
Provider: config.LLMProviderPi,
Auth: config.LLMAuthSubscription,
Adapter: config.LLMAdapterPiRPC,
ModelMap: config.ModelMap{"medium": "pi-model", "large": "pi-model"},
}
writeAgentWithModelTier(t, req.Profile.AgentSources[0], "harness", "unmapped", "small")
req.ReviewerFast = true
adapter := &llm.FakeAdapter{NameValue: "fake-llm"}
Expand Down
2 changes: 1 addition & 1 deletion internal/stagemodel/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func ResolveStageModel(req Request) (Result, error) {
resolved, ok := config.ResolveModelTier(req.Profile.LLM, tier)
if !ok {
llmConfig := req.Profile.LLM
return Result{}, fmt.Errorf("stagemodel: stage %s: model_tier %q is not mapped for provider %q adapter %q", stage, tier, llmConfig.Provider, llmConfig.Adapter)
return Result{}, fmt.Errorf("stagemodel: stage %s: model_tier %q is not mapped for provider %q adapter %q; add llm.model_map.%s to the profile's LLM runtime", stage, tier, llmConfig.Provider, llmConfig.Adapter, tier)
}
return Result{
Stage: stage,
Expand Down
33 changes: 31 additions & 2 deletions internal/stagemodel/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,32 @@ func TestResolveStageModelErrorsForUnmappedTier(t *testing.T) {
if !strings.Contains(err.Error(), "thread_analysis") || !strings.Contains(err.Error(), "model_tier") {
t.Fatalf("error = %q, want stage and model_tier context", err)
}
// The tier an agent asked for is not the operator's vocabulary; the error
// has to name the config entry that resolves it.
if !strings.Contains(err.Error(), "llm.model_map.small") {
t.Fatalf("error = %q, want the config entry that fixes it", err)
}
}

func TestResolveStageModelMapsSmallTierForClaudeCLI(t *testing.T) {
profile := config.Profile{LLM: config.LLMConfig{
Provider: config.LLMProviderAnthropic,
Auth: config.LLMAuthSubscription,
Adapter: config.LLMAdapterClaudeCLI,
}}

resolved, err := ResolveStageModel(Request{
Profile: profile,
Stage: StageReviewer,
Tier: config.ModelTierSmall,
DefaultEffort: "medium",
})
if err != nil {
t.Fatalf("ResolveStageModel: %v", err)
}
if resolved.Model != "claude-haiku-4-5" || resolved.Source != config.ModelMapSourceBuiltIn {
t.Fatalf("resolved = %#v, want the built-in Claude CLI small model", resolved)
}
}

func TestResolveStageModelErrorsForInvalidTierBeforeApplyingFloor(t *testing.T) {
Expand All @@ -179,10 +205,13 @@ func TestResolveStageModelErrorsForInvalidTierBeforeApplyingFloor(t *testing.T)
}

func TestResolveFirstAvailableUsesFirstConfiguredTier(t *testing.T) {
// anthropic_api ships no built-in map, so the profile's own entries are the
// whole map and small stays genuinely unmapped for the fallback to find.
profile := config.Profile{LLM: config.LLMConfig{
Provider: config.LLMProviderAnthropic,
Auth: config.LLMAuthSubscription,
Adapter: config.LLMAdapterClaudeCLI,
Auth: config.LLMAuthAPIKey,
Adapter: config.LLMAdapterAnthropicAPI,
ModelMap: config.ModelMap{string(config.ModelTierMedium): "claude-sonnet-5"},
}}

got, ok := ResolveFirstAvailable(Request{
Expand Down
2 changes: 1 addition & 1 deletion internal/view/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ LLM:
Adapter: claude_cli
Credential name: adapter-managed; not stored by cr
Model map:
small: <unset> (unset)
small: claude-haiku-4-5 (built_in)
medium: claude-sonnet-5 (built_in)
large: claude-opus-5 (built_in)
Credentials:
Expand Down
Loading