package config import ( "fmt" "os" "path/filepath" "strings" "testing" "time" "textmachine/backend/internal/llm" ) // TestResolveCapabilityMerge checks the D3.1 layering: kind baseline < provider // default < per-model override, field-by-field with the model winning. func TestResolveCapabilityMerge(t *testing.T) { m := &Models{ Providers: map[string]Provider{ "openai": {Kind: "openai", Capabilities: &CapabilitiesConfig{ BudgetField: "max_completion_tokens", Temperature: &TemperatureCap{Mode: "omit"}, }}, "local": {Kind: "local"}, }, Models: map[string]Model{ "gpt-5-nano": {Provider: "openai"}, // inherits the provider default "gpt-5-mini": {Provider: "openai", Capabilities: &CapabilitiesConfig{ Temperature: &TemperatureCap{Mode: "force", Value: 0.7}, // overrides temp only }}, "local-x": {Provider: "local"}, }, } nano := m.ResolveCapability("gpt-5-nano") if nano.Budget != llm.BudgetMaxCompletionTokens || nano.Temp != llm.TempOmit { t.Fatalf("nano must inherit provider default: %+v", nano) } mini := m.ResolveCapability("gpt-5-mini") if mini.Budget != llm.BudgetMaxCompletionTokens { t.Fatalf("mini budget must still inherit the provider: %+v", mini) } if mini.Temp != llm.TempForce || mini.TempValue != 0.7 { t.Fatalf("mini temperature override must win: %+v", mini) } loc := m.ResolveCapability("local-x") if loc.Reasoning.Control != llm.ReasoningEffortField || loc.Reasoning.OffEffort != "none" { t.Fatalf("local baseline must map off->none: %+v", loc.Reasoning) } if loc.Budget != llm.BudgetMaxTokens || loc.Temp != llm.TempSend { t.Fatalf("local baseline budget/temp: %+v", loc) } } // TestLoadModelsRejectsBadCapability confirms a bogus capability enum fails // fast at load (part of the models.yaml problem list), not as a runtime 4xx. func TestLoadModelsRejectsBadCapability(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "models.yaml") body := fmt.Sprintf(` prices_checked: %q default_model: m providers: p: kind: openai base_url: http://x models: m: provider: p price: { input_per_m: 1, output_per_m: 2 } capabilities: budget_field: bogus `, time.Now().UTC().Format("2006-01-02")) if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } _, err := LoadModels(path) if err == nil || !strings.Contains(err.Error(), "budget_field") { t.Fatalf("bad budget_field must fail-fast mentioning the field, got %v", err) } } // TestLoadModelsCapabilityValidation covers the other validateCapabilities // paths the single case above misses: the PROVIDER call-site, the // temperature.mode / reasoning.control enums, and companion-field completeness // (a control whose disable field is absent must fail at load, not no-op at // runtime). func TestLoadModelsCapabilityValidation(t *testing.T) { cases := []struct { name string capsYAML string // injected under provider p or model m as noted onProvider bool wantSubstr string }{ {"provider bad reasoning.control", "capabilities: { reasoning: { control: bogus } }", true, "control"}, {"model bad temperature.mode", "capabilities: { temperature: { mode: sned } }", false, "mode"}, {"extra_body_disable without off_extra_body", "capabilities: { reasoning: { control: extra_body_disable } }", false, "off_extra_body"}, {"effort without off_effort", "capabilities: { reasoning: { control: effort } }", false, "off_effort"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { provCaps, modelCaps := "", "" if c.onProvider { provCaps = "\n " + c.capsYAML } else { modelCaps = "\n " + c.capsYAML } body := fmt.Sprintf(` prices_checked: %q default_model: m providers: p: kind: openai base_url: http://x%s models: m: provider: p price: { input_per_m: 1, output_per_m: 2 }%s `, time.Now().UTC().Format("2006-01-02"), provCaps, modelCaps) dir := t.TempDir() path := filepath.Join(dir, "models.yaml") if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } _, err := LoadModels(path) if err == nil || !strings.Contains(err.Error(), c.wantSubstr) { t.Fatalf("expected fail-fast mentioning %q, got %v", c.wantSubstr, err) } }) } }