1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "testing"
12
13 "github.com/bnema/zut/packages/provider"
14)
15
16func TestFilterCacheByProviderScopesRemovesMismatchedProvider(t *testing.T) {
17 cache := provider.ModelCache{
18 Models: []provider.Model{
19 {Provider: "openai-codex", ID: "account-a-only"},
20 {Provider: "openai", ID: "shared"},
21 },
22 AuthoritativeProviders: []string{"openai-codex"},
23 ProviderScopes: map[string]string{"openai-codex": "account-a"},
24 }
25
26 filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-b"})
27 if len(filtered.Models) != 1 || filtered.Models[0].Provider != "openai" {
28 t.Fatalf("models = %+v, want only non-scoped model", filtered.Models)
29 }
30 if len(filtered.AuthoritativeProviders) != 0 {
31 t.Fatalf("authoritative providers = %v, want none", filtered.AuthoritativeProviders)
32 }
33 if len(filtered.ProviderScopes) != 0 {
34 t.Fatalf("provider scopes = %v, want none", filtered.ProviderScopes)
35 }
36}
37
38func TestFilterCacheByProviderScopesRetainsMatchingScopedProvider(t *testing.T) {
39 cache := provider.ModelCache{
40 Models: []provider.Model{{Provider: "openai-codex", ID: "account-a-only"}},
41 AuthoritativeProviders: []string{"openai-codex"},
42 ProviderScopes: map[string]string{"openai-codex": "account-a"},
43 }
44
45 filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
46 if len(filtered.Models) != 1 || len(filtered.AuthoritativeProviders) != 1 || filtered.ProviderScopes["openai-codex"] != "account-a" {
47 t.Fatalf("matching scoped cache was not preserved: %+v", filtered)
48 }
49}
50
51func TestFilterCacheByProviderScopesDropsLegacyAuthoritativeCodex(t *testing.T) {
52 cache := provider.ModelCache{
53 Models: []provider.Model{{Provider: "openai-codex", ID: "legacy"}},
54 AuthoritativeProviders: []string{"openai-codex"},
55 }
56
57 filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
58 if len(filtered.Models) != 0 || len(filtered.AuthoritativeProviders) != 0 {
59 t.Fatalf("legacy Codex cache was retained: %+v", filtered)
60 }
61}
62
63func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
64 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65 if r.URL.Path != "/models" {
66 http.NotFound(w, r)
67 return
68 }
69 fmt.Fprint(w, `{"data":[{"id":"loaded","status":{"value":"loaded"},"meta":{"n_ctx":32768}},{"id":"offline","status":{"value":"unloaded"}}]}`)
70 }))
71 defer server.Close()
72
73 t.Setenv("ZUT_HOME", t.TempDir())
74 t.Setenv("LLAMA_BASE_URL", "")
75 if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil {
76 t.Fatal(err)
77 }
78 provider.SetManagedModels(nil)
79 t.Cleanup(func() { provider.SetManagedModels(nil) })
80
81 if err := RefreshLlamaCPPModels(context.Background()); err != nil {
82 t.Fatal(err)
83 }
84 loaded, err := provider.FindModel(provider.LlamaCPPProviderID, "loaded")
85 if err != nil {
86 t.Fatal(err)
87 }
88 if loaded.ContextWindow != 32768 || loaded.BaseURL != server.URL+"/v1" {
89 t.Fatalf("loaded model = %+v", loaded)
90 }
91 if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil {
92 t.Fatal("unloaded model must not be selectable")
93 }
94}
95
96// TestValidateAndRepairConfig_MismatchedPair simulates the bug from a
97// stale /model switch: provider=anthropic but model=kimi-for-coding
98// (which belongs to provider=kimi). The validator should rewrite the
99// model to anthropic's default and persist.
100func TestValidateAndRepairConfig_MismatchedPair(t *testing.T) {
101 home := t.TempDir()
102 t.Setenv("ZUT_HOME", home)
103
104 must := func(c Config) {
105 t.Helper()
106 b, _ := json.Marshal(c)
107 if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
108 t.Fatal(err)
109 }
110 }
111 must(Config{Provider: "anthropic", Model: "kimi-for-coding"})
112
113 ValidateAndRepairConfig()
114
115 out, err := LoadConfig()
116 if err != nil {
117 t.Fatal(err)
118 }
119 if out.Provider != "anthropic" {
120 t.Errorf("provider not preserved: %q", out.Provider)
121 }
122 if out.Model == "kimi-for-coding" {
123 t.Errorf("model not repaired; still %q", out.Model)
124 }
125 if out.Model == "" {
126 t.Errorf("model not set; expected anthropic default")
127 }
128}
129
130// TestValidateAndRepairConfig_UnknownProvider resets to anthropic and
131// clears the model when the saved provider id isn't recognised
132// (e.g. user removed it from a previous build).
133func TestValidateAndRepairConfig_UnknownProvider(t *testing.T) {
134 home := t.TempDir()
135 t.Setenv("ZUT_HOME", home)
136
137 b, _ := json.Marshal(Config{Provider: "made-up-provider", Model: "some-model"})
138 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
139
140 ValidateAndRepairConfig()
141
142 out, _ := LoadConfig()
143 if out.Provider != "anthropic" {
144 t.Errorf("provider not reset: %q", out.Provider)
145 }
146 if out.Model != "" {
147 t.Errorf("model not cleared: %q", out.Model)
148 }
149}
150
151// TestValidateAndRepairConfig_UnknownModel keeps the provider but
152// snaps the model to that provider's default when the saved id is no
153// longer in the catalog.
154func TestValidateAndRepairConfig_UnknownModel(t *testing.T) {
155 home := t.TempDir()
156 t.Setenv("ZUT_HOME", home)
157
158 b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-deleted-model"})
159 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
160
161 ValidateAndRepairConfig()
162
163 out, _ := LoadConfig()
164 if out.Provider != "anthropic" {
165 t.Errorf("provider changed: %q", out.Provider)
166 }
167 if out.Model == "" || out.Model == "claude-deleted-model" {
168 t.Errorf("model not repaired: %q", out.Model)
169 }
170}
171
172func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *testing.T) {
173 home := t.TempDir()
174 t.Setenv("ZUT_HOME", home)
175
176 b, _ := json.Marshal(Config{Provider: "openai-codex", Model: "gpt-5.5"})
177 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
178
179 ValidateAndRepairConfig()
180
181 out, _ := LoadConfig()
182 if out.Provider != "openai-codex" {
183 t.Errorf("provider mutated: %q", out.Provider)
184 }
185 if out.Model != "gpt-5.5" {
186 t.Errorf("model mutated: %q", out.Model)
187 }
188}
189
190func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {
191 home := t.TempDir()
192 t.Setenv("ZUT_HOME", home)
193
194 want := "deepseek/deepseek-v4-flash"
195 b, _ := json.Marshal(Config{Provider: "openrouter", Model: want})
196 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
197
198 ValidateAndRepairConfig()
199
200 out, _ := LoadConfig()
201 if out.Provider != "openrouter" {
202 t.Errorf("provider mutated: %q", out.Provider)
203 }
204 if out.Model != want {
205 t.Errorf("routed model id mutated: got %q, want %q", out.Model, want)
206 }
207}
208
209func TestValidateAndRepairConfig_GatewayPlainUnknownModelStillRepairs(t *testing.T) {
210 home := t.TempDir()
211 t.Setenv("ZUT_HOME", home)
212
213 b, _ := json.Marshal(Config{Provider: "openrouter", Model: "not-a-routed-model"})
214 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
215
216 ValidateAndRepairConfig()
217
218 out, _ := LoadConfig()
219 if out.Provider != "openrouter" {
220 t.Errorf("provider mutated: %q", out.Provider)
221 }
222 if out.Model == "not-a-routed-model" || out.Model == "" {
223 t.Errorf("plain unknown gateway model was not repaired: %q", out.Model)
224 }
225}
226
227// TestValidateAndRepairConfig_HappyPath leaves a valid config alone.
228func TestValidateAndRepairConfig_HappyPath(t *testing.T) {
229 home := t.TempDir()
230 t.Setenv("ZUT_HOME", home)
231
232 b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-sonnet-4-5"})
233 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
234
235 ValidateAndRepairConfig()
236
237 out, _ := LoadConfig()
238 if out.Provider != "anthropic" {
239 t.Errorf("provider mutated: %q", out.Provider)
240 }
241 if out.Model != "claude-sonnet-4-5" {
242 t.Errorf("model mutated: %q", out.Model)
243 }
244}