diff --git a/README.md b/README.md index 332ac64..f72885a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/cmd/configcmd/configcmd.go b/internal/cmd/configcmd/configcmd.go index 2f22317..f8e01ee 100644 --- a/internal/cmd/configcmd/configcmd.go +++ b/internal/cmd/configcmd/configcmd.go @@ -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, diff --git a/internal/cmd/configcmd/configcmd_test.go b/internal/cmd/configcmd/configcmd_test.go index 9d803da..e6e00d2 100644 --- a/internal/cmd/configcmd/configcmd_test.go +++ b/internal/cmd/configcmd/configcmd_test.go @@ -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)") || + 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()) diff --git a/internal/cmd/initcmd/initcmd_test.go b/internal/cmd/initcmd/initcmd_test.go index ab87a63..0b7c233 100644 --- a/internal/cmd/initcmd/initcmd_test.go +++ b/internal/cmd/initcmd/initcmd_test.go @@ -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)) @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 68f3c82..1989bb3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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", }, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e828c07..9f4a0fb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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", }, diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 0ac0685..ea136f2 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -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"} diff --git a/internal/stagemodel/resolver.go b/internal/stagemodel/resolver.go index 20be933..6d86714 100644 --- a/internal/stagemodel/resolver.go +++ b/internal/stagemodel/resolver.go @@ -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, diff --git a/internal/stagemodel/resolver_test.go b/internal/stagemodel/resolver_test.go index 1e930fe..cad301c 100644 --- a/internal/stagemodel/resolver_test.go +++ b/internal/stagemodel/resolver_test.go @@ -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) { @@ -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{ diff --git a/internal/view/config_test.go b/internal/view/config_test.go index e5ed2eb..9eb5256 100644 --- a/internal/view/config_test.go +++ b/internal/view/config_test.go @@ -209,7 +209,7 @@ LLM: Adapter: claude_cli Credential name: adapter-managed; not stored by cr Model map: - small: (unset) + small: claude-haiku-4-5 (built_in) medium: claude-sonnet-5 (built_in) large: claude-opus-5 (built_in) Credentials: