1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "runtime"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/patriceckhart/zot/packages/provider"
18)
19
20func TestRefreshModelsBedrockWithDefaultCLIProfile(t *testing.T) {
21 if runtime.GOOS == "windows" {
22 t.Skip("test uses a shell script to simulate the AWS CLI")
23 }
24 t.Setenv("ZOT_HOME", t.TempDir())
25 t.Setenv("AWS_PROFILE", "")
26 t.Setenv("AWS_ACCESS_KEY_ID", "")
27 t.Setenv("AWS_SECRET_ACCESS_KEY", "")
28 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
29 for _, name := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "KIMI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "GONDOLA_API_KEY", "YOLO_AUTO_API_KEY"} {
30 t.Setenv(name, "")
31 }
32 bin := t.TempDir()
33 script := "#!/bin/sh\nprintf '%s\\n' '{\"AccessKeyId\":\"AKID\",\"SecretAccessKey\":\"SECRET\"}'\n"
34 if err := os.WriteFile(filepath.Join(bin, "aws"), []byte(script), 0o755); err != nil {
35 t.Fatal(err)
36 }
37 t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
38 orig := http.DefaultTransport
39 t.Cleanup(func() {
40 http.DefaultTransport = orig
41 provider.SetLiveModels(nil)
42 })
43 http.DefaultTransport = yoloAutoTestTransport(func(r *http.Request) (*http.Response, error) {
44 var body string
45 switch r.URL.Path {
46 case "/foundation-models":
47 body = `{"modelSummaries":[{"modelId":"example.cli-only-model","outputModalities":["TEXT"]}]}`
48 case "/inference-profiles":
49 body = `{"inferenceProfileSummaries":[]}`
50 default:
51 return nil, fmt.Errorf("unexpected discovery request: %s", r.URL)
52 }
53 return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
54 })
55
56 refreshModels()
57 if _, err := provider.FindModel("amazon-bedrock", "example.cli-only-model"); err != nil {
58 t.Fatal("default AWS CLI profile was not discovered:", err)
59 }
60 resolved, err := Resolve(Args{Provider: "amazon-bedrock", Model: "example.cli-only-model"}, true)
61 if err != nil {
62 t.Fatal("default AWS CLI profile could not resolve for inference:", err)
63 }
64 if resolved.Credential != "<aws>" {
65 t.Fatalf("inference credential = %q, want AWS sentinel", resolved.Credential)
66 }
67}
68
69func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
70 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71 if r.URL.Path != "/models" {
72 http.NotFound(w, r)
73 return
74 }
75 fmt.Fprint(w, `{"data":[{"id":"loaded","status":{"value":"loaded"},"meta":{"n_ctx":32768}},{"id":"offline","status":{"value":"unloaded"}}]}`)
76 }))
77 defer server.Close()
78
79 t.Setenv("ZOT_HOME", t.TempDir())
80 t.Setenv("LLAMA_BASE_URL", "")
81 if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil {
82 t.Fatal(err)
83 }
84 provider.SetManagedModels(nil)
85 t.Cleanup(func() { provider.SetManagedModels(nil) })
86
87 if err := RefreshLlamaCPPModels(context.Background()); err != nil {
88 t.Fatal(err)
89 }
90 loaded, err := provider.FindModel(provider.LlamaCPPProviderID, "loaded")
91 if err != nil {
92 t.Fatal(err)
93 }
94 if loaded.ContextWindow != 32768 || loaded.BaseURL != server.URL+"/v1" {
95 t.Fatalf("loaded model = %+v", loaded)
96 }
97 if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil {
98 t.Fatal("unloaded model must not be selectable")
99 }
100}
101
102// TestValidateAndRepairConfig_MismatchedPair simulates the bug from a
103// stale /model switch: provider=anthropic but model=kimi-for-coding
104// (which belongs to provider=kimi). The validator should rewrite the
105// model to anthropic's default and persist.
106func TestValidateAndRepairConfig_MismatchedPair(t *testing.T) {
107 home := t.TempDir()
108 t.Setenv("ZOT_HOME", home)
109
110 must := func(c Config) {
111 t.Helper()
112 b, _ := json.Marshal(c)
113 if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
114 t.Fatal(err)
115 }
116 }
117 must(Config{Provider: "anthropic", Model: "kimi-for-coding"})
118
119 ValidateAndRepairConfig()
120
121 out, err := LoadConfig()
122 if err != nil {
123 t.Fatal(err)
124 }
125 if out.Provider != "anthropic" {
126 t.Errorf("provider not preserved: %q", out.Provider)
127 }
128 if out.Model == "kimi-for-coding" {
129 t.Errorf("model not repaired; still %q", out.Model)
130 }
131 if out.Model == "" {
132 t.Errorf("model not set; expected anthropic default")
133 }
134}
135
136// TestValidateAndRepairConfig_UnknownProvider resets to anthropic and
137// clears the model when the saved provider id isn't recognised
138// (e.g. user removed it from a previous build).
139func TestValidateAndRepairConfig_UnknownProvider(t *testing.T) {
140 home := t.TempDir()
141 t.Setenv("ZOT_HOME", home)
142
143 b, _ := json.Marshal(Config{Provider: "made-up-provider", Model: "some-model"})
144 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
145
146 ValidateAndRepairConfig()
147
148 out, _ := LoadConfig()
149 if out.Provider != "anthropic" {
150 t.Errorf("provider not reset: %q", out.Provider)
151 }
152 if out.Model != "" {
153 t.Errorf("model not cleared: %q", out.Model)
154 }
155}
156
157// TestValidateAndRepairConfig_UnknownModel keeps the provider but
158// snaps the model to that provider's default when the saved id is no
159// longer in the catalog.
160func TestValidateAndRepairConfig_UnknownModel(t *testing.T) {
161 home := t.TempDir()
162 t.Setenv("ZOT_HOME", home)
163
164 b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-deleted-model"})
165 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
166
167 ValidateAndRepairConfig()
168
169 out, _ := LoadConfig()
170 if out.Provider != "anthropic" {
171 t.Errorf("provider changed: %q", out.Provider)
172 }
173 if out.Model == "" || out.Model == "claude-deleted-model" {
174 t.Errorf("model not repaired: %q", out.Model)
175 }
176}
177
178func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *testing.T) {
179 home := t.TempDir()
180 t.Setenv("ZOT_HOME", home)
181
182 b, _ := json.Marshal(Config{Provider: "openai-codex", Model: "gpt-5.5"})
183 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
184
185 ValidateAndRepairConfig()
186
187 out, _ := LoadConfig()
188 if out.Provider != "openai-codex" {
189 t.Errorf("provider mutated: %q", out.Provider)
190 }
191 if out.Model != "gpt-5.5" {
192 t.Errorf("model mutated: %q", out.Model)
193 }
194}
195
196func TestModelCacheFetchedAtPreservesCatalogFreshness(t *testing.T) {
197 cachedAt := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC)
198 now := cachedAt.Add(time.Hour)
199 cached := provider.ModelCache{FetchedAt: cachedAt}
200
201 if got := modelCacheFetchedAt(cached, false, now); !got.Equal(cachedAt) {
202 t.Fatalf("fresh cache timestamp = %v; want %v", got, cachedAt)
203 }
204 if got := modelCacheFetchedAt(cached, true, now); !got.Equal(now) {
205 t.Fatalf("refreshed cache timestamp = %v; want %v", got, now)
206 }
207}
208
209func TestMergeOpenRouterPresetsReplacesCachedPresets(t *testing.T) {
210 existing := []provider.Model{
211 {Provider: "openrouter", ID: "deepseek/deepseek-v4-flash"},
212 {Provider: "openrouter", ID: "@preset/old"},
213 {Provider: "anthropic", ID: "claude-sonnet-4-5"},
214 }
215 presets := []provider.Model{
216 {Provider: "openrouter", ID: "@preset/flash", DisplayName: "Flash (preset)", ContextWindow: 1000000},
217 }
218 out := mergeOpenRouterPresets(existing, presets)
219 if len(out) != 3 {
220 t.Fatalf("got %d models; want 3", len(out))
221 }
222 var ids []string
223 for _, m := range out {
224 ids = append(ids, m.Provider+"/"+m.ID)
225 }
226 want := []string{"openrouter/deepseek/deepseek-v4-flash", "anthropic/claude-sonnet-4-5", "openrouter/@preset/flash"}
227 for i, id := range want {
228 if ids[i] != id {
229 t.Errorf("ids[%d] = %q; want %q", i, ids[i], id)
230 }
231 }
232}
233
234func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {
235 home := t.TempDir()
236 t.Setenv("ZOT_HOME", home)
237
238 want := "deepseek/deepseek-v4-flash"
239 b, _ := json.Marshal(Config{Provider: "openrouter", Model: want})
240 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
241
242 ValidateAndRepairConfig()
243
244 out, _ := LoadConfig()
245 if out.Provider != "openrouter" {
246 t.Errorf("provider mutated: %q", out.Provider)
247 }
248 if out.Model != want {
249 t.Errorf("routed model id mutated: got %q, want %q", out.Model, want)
250 }
251}
252
253func TestValidateAndRepairConfig_GatewayPlainUnknownModelStillRepairs(t *testing.T) {
254 home := t.TempDir()
255 t.Setenv("ZOT_HOME", home)
256
257 b, _ := json.Marshal(Config{Provider: "openrouter", Model: "not-a-routed-model"})
258 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
259
260 ValidateAndRepairConfig()
261
262 out, _ := LoadConfig()
263 if out.Provider != "openrouter" {
264 t.Errorf("provider mutated: %q", out.Provider)
265 }
266 if out.Model == "not-a-routed-model" || out.Model == "" {
267 t.Errorf("plain unknown gateway model was not repaired: %q", out.Model)
268 }
269}
270
271// TestValidateAndRepairConfig_HappyPath leaves a valid config alone.
272func TestValidateAndRepairConfig_HappyPath(t *testing.T) {
273 home := t.TempDir()
274 t.Setenv("ZOT_HOME", home)
275
276 b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-sonnet-4-5"})
277 _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
278
279 ValidateAndRepairConfig()
280
281 out, _ := LoadConfig()
282 if out.Provider != "anthropic" {
283 t.Errorf("provider mutated: %q", out.Provider)
284 }
285 if out.Model != "claude-sonnet-4-5" {
286 t.Errorf("model mutated: %q", out.Model)
287 }
288}