From 707fd414d2f4a797ac77fe9cd82d9f8ca22a7313 Mon Sep 17 00:00:00 2001 From: piekstra Date: Thu, 6 Aug 2026 14:50:09 -0400 Subject: [PATCH 1/2] fix(config): map the small tier for the Claude CLI runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent declaring model_tier: small was unrunnable on anthropic + claude_cli, and did not degrade: the run aborted at plan time after the selection phase had already been paid for, and every reviewer that did resolve never ran. Agents travel between runtimes through shared catalogs, so a tier the other complete runtimes serve should not be a runtime-specific hard failure. Map small to claude-haiku-4-5. The CLI serves it and internal/pricing already prices it, so "unset" was not a design so much as an override every operator had to write identically. A built-in is a floor, not a recommendation — a profile that wants a stronger model for small still says so in llm.model_map, which is where a taste judgment belongs. anthropic_api and pi_rpc keep empty built-in maps by design, so the unmapped error stays reachable and now names the entry that fixes it instead of only the tier that failed. Closes #556 --- README.md | 8 +++--- internal/cmd/configcmd/configcmd.go | 2 +- internal/cmd/configcmd/configcmd_test.go | 2 +- internal/cmd/initcmd/initcmd_test.go | 7 ++--- internal/config/config.go | 1 + internal/config/config_test.go | 1 + internal/pipeline/pipeline_test.go | 8 ++++++ internal/stagemodel/resolver.go | 2 +- internal/stagemodel/resolver_test.go | 33 ++++++++++++++++++++++-- 9 files changed, 53 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ef4cd887..66f56f63 100644 --- a/README.md +++ b/README.md @@ -678,12 +678,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 2f223170..f8e01eee 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 9d803da9..e6e00d25 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 ab87a634..0b7c2330 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 68f3c82d..1989bb37 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 e828c07c..9f4a0fb0 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 0ac0685b..ea136f20 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 20be933d..6d867146 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 1e930fe0..cad301c7 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{ From 38cfdc88de971a4b4f9533e84acc0e122334af11 Mon Sep 17 00:00:00 2001 From: piekstra Date: Thu, 6 Aug 2026 20:10:55 -0400 Subject: [PATCH 2/2] test(view): update the rendered config shape for the mapped small tier TestRenderConfigTextExactHomeShape pins cr config show's exact text, which now names the built-in small model instead of . Missed locally because `make test` runs without -count=1, so this package's pre-change result was served from Go's test cache; CI, which builds clean, caught it in three jobs. --- internal/view/config_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/view/config_test.go b/internal/view/config_test.go index e5ed2eb9..9eb52565 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: