1package agent
2
3import (
4 "net/http"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/patriceckhart/zot/packages/provider"
11)
12
13func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
14 root := t.TempDir()
15 zotHome := filepath.Join(root, "zot-home")
16 project := filepath.Join(root, "repo")
17 nested := filepath.Join(project, "packages", "app")
18 if err := os.MkdirAll(zotHome, 0o755); err != nil {
19 t.Fatal(err)
20 }
21 if err := os.MkdirAll(nested, 0o755); err != nil {
22 t.Fatal(err)
23 }
24 if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
25 t.Fatal(err)
26 }
27 if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
28 t.Fatal(err)
29 }
30 if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
31 t.Fatal(err)
32 }
33
34 got := readAgentsContext(nested, zotHome)
35 for _, want := range []string{"global rule", "repo rule", "app rule"} {
36 if !strings.Contains(got, want) {
37 t.Fatalf("readAgentsContext missing %q in:\n%s", want, got)
38 }
39 }
40 if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
41 t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
42 }
43}
44
45func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
46 got := readAgentsContext(t.TempDir(), t.TempDir())
47 if got != "" {
48 t.Fatalf("expected no context, got %q", got)
49 }
50}
51
52// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
53// startup failure caught by the user's screenshot: the persisted
54// config.json points at a model id that's no longer in the active
55// catalogue (because they edited models.json or zot's bundled
56// catalogue changed). Resolve must NOT error — strands the user
57// with no way to fix it from the TUI — and should repair the config
58// so the next launch is silent.
59func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
60 t.Setenv("ZOT_HOME", t.TempDir())
61 t.Setenv("OPENAI_API_KEY", "test-key")
62 // Persist a stale model id.
63 stale := "gpt-5.5-pro-not-real"
64 if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
65 t.Fatal(err)
66 }
67
68 r, err := Resolve(Args{}, false)
69 if err != nil {
70 t.Fatalf("Resolve refused to launch with stale model: %v", err)
71 }
72 if r.Model == stale {
73 t.Fatalf("Resolve kept stale model %q", r.Model)
74 }
75 if r.Provider != "openai" {
76 t.Errorf("provider drifted: got %q; want openai", r.Provider)
77 }
78
79 // Config on disk should now hold the fallback so subsequent
80 // launches don't repeat the warning.
81 cfg, _ := LoadConfig()
82 if cfg.Model == stale {
83 t.Errorf("config.json still pins the stale model %q", cfg.Model)
84 }
85 if cfg.Model == "" {
86 t.Errorf("config.json was emptied; expected the fallback model id")
87 }
88}
89
90// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
91// repair-on-disk happens ONLY when the stale id came from the
92// persisted config. If the user passed --model X explicitly and X is
93// unknown, we still fall back, but we don't touch their config.
94func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
95 t.Setenv("ZOT_HOME", t.TempDir())
96 t.Setenv("OPENAI_API_KEY", "test-key")
97 good := "gpt-5"
98 if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
99 t.Fatal(err)
100 }
101
102 r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
103 if err != nil {
104 t.Fatalf("Resolve errored on unknown --model: %v", err)
105 }
106 if r.Model == "gpt-totally-fake" {
107 t.Errorf("Resolve kept the bogus --model value")
108 }
109 cfg, _ := LoadConfig()
110 if cfg.Model != good {
111 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
112 }
113}
114
115func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
116 t.Setenv("ZOT_HOME", t.TempDir())
117 t.Setenv("OPENROUTER_API_KEY", "test-key")
118 want := "deepseek/deepseek-v4-flash"
119 if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
120 t.Fatal(err)
121 }
122
123 r, err := Resolve(Args{}, true)
124 if err != nil {
125 t.Fatalf("Resolve failed: %v", err)
126 }
127 if r.Provider != "openrouter" {
128 t.Fatalf("provider = %q, want openrouter", r.Provider)
129 }
130 if r.Model != want {
131 t.Fatalf("model = %q, want %q", r.Model, want)
132 }
133 if r.MaxOutput != 64000 {
134 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
135 }
136}
137
138func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
139 t.Setenv("ZOT_HOME", t.TempDir())
140 t.Setenv("OPENROUTER_API_KEY", "test-key")
141 stale := "not-a-routed-model"
142 if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
143 t.Fatal(err)
144 }
145
146 r, err := Resolve(Args{}, true)
147 if err != nil {
148 t.Fatalf("Resolve failed: %v", err)
149 }
150 if r.Provider != "openrouter" {
151 t.Fatalf("provider = %q, want openrouter", r.Provider)
152 }
153 if r.Model == stale || r.Model == "" {
154 t.Fatalf("model = %q, want repaired gateway default", r.Model)
155 }
156}
157
158// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
159// #15: pointing ZOT_HOME at a fresh dir drops the persisted
160// config.json (which pinned provider=amazon-bedrock). Resolve must
161// still discover bedrock from the AWS env vars instead of falling back
162// to anthropic and reporting "not logged in".
163func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
164 t.Setenv("ZOT_HOME", t.TempDir()) // fresh home: no config.json
165 // Disable the Kimi CLI token fallback so a developer machine with a
166 // real Kimi CLI login doesn't pre-empt bedrock in the scan.
167 if err := SetKimiCLIFallbackDisabled(true); err != nil {
168 t.Fatal(err)
169 }
170 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
171 t.Setenv("AWS_REGION", "us-east-1")
172 // Make sure no other provider's env credential pre-empts bedrock.
173 for _, k := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY"} {
174 t.Setenv(k, "")
175 }
176
177 r, err := Resolve(Args{}, true)
178 if err != nil {
179 t.Fatalf("Resolve errored with env-only bedrock: %v", err)
180 }
181 if r.Provider != "amazon-bedrock" {
182 t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
183 }
184 if !r.HasCredential() {
185 t.Fatalf("bedrock credential not resolved from env")
186 }
187}
188
189func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
190 t.Setenv("ZOT_HOME", t.TempDir())
191 provider.SetLiveModels(nil)
192 defer provider.SetLiveModels(nil)
193 provider.SetUserModels([]provider.Model{{
194 Provider: "ollama",
195 ID: "qwen-local",
196 DisplayName: "Qwen Local",
197 ContextWindow: 32768,
198 MaxOutput: 8192,
199 BaseURL: "http://localhost:8000/v1",
200 }})
201
202 r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
203 if err != nil {
204 t.Fatalf("Resolve failed: %v", err)
205 }
206 if r.BaseURL != "http://localhost:8000/v1" {
207 t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
208 }
209}
210
211func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
212 t.Setenv("ZOT_HOME", t.TempDir())
213 t.Setenv("MY_COMPANY_API_KEY", "test-key")
214 path := filepath.Join(t.TempDir(), "models.json")
215 if err := os.WriteFile(path, []byte(`{
216 "providers": {
217 "my-company": {
218 "baseUrl": "https://provider.example.com/v1",
219 "api": "openai",
220 "models": [
221 {"id": "fast", "baseUrl": "https://model.example.com/v1"}
222 ]
223 }
224 }
225 }`), 0o644); err != nil {
226 t.Fatal(err)
227 }
228 models, warnings := provider.LoadUserModelsWithWarnings(path)
229 if len(warnings) != 0 {
230 t.Fatalf("warnings = %v", warnings)
231 }
232 provider.SetLiveModels(nil)
233 provider.SetUserModels(models)
234 t.Cleanup(func() { provider.SetLiveModels(nil) })
235
236 r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
237 if err != nil {
238 t.Fatalf("Resolve failed: %v", err)
239 }
240 if r.BaseURL != "https://model.example.com/v1" {
241 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
242 }
243}
244
245func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
246 t.Setenv("ZOT_HOME", t.TempDir())
247 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
248 path := filepath.Join(t.TempDir(), "models.json")
249 if err := os.WriteFile(path, []byte(`{
250 "providers": {
251 "local-proxy": {
252 "baseUrl": "https://proxy.example.com/v1",
253 "api": "openai",
254 "models": [{"id": "default"}]
255 }
256 }
257 }`), 0o644); err != nil {
258 t.Fatal(err)
259 }
260 models, warnings := provider.LoadUserModelsWithWarnings(path)
261 if len(warnings) != 0 {
262 t.Fatalf("warnings = %v", warnings)
263 }
264 provider.SetLiveModels(nil)
265 provider.SetUserModels(models)
266 t.Cleanup(func() { provider.SetLiveModels(nil) })
267
268 r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
269 if err != nil {
270 t.Fatalf("Resolve failed: %v", err)
271 }
272 if !r.InsecureTLS {
273 t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
274 }
275}
276
277func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
278 t.Setenv("ZOT_HOME", t.TempDir())
279 provider.SetLiveModels(nil)
280 defer provider.SetLiveModels(nil)
281
282 r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
283 if err != nil {
284 t.Fatalf("Resolve failed: %v", err)
285 }
286 if r.BaseURL != "http://localhost:11434" {
287 t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
288 }
289}
290
291func TestCanonicalProviderResolvesAliases(t *testing.T) {
292 cases := map[string]string{
293 "bedrock": "amazon-bedrock",
294 "AWS-Bedrock": "amazon-bedrock",
295 " bedrock ": "amazon-bedrock",
296 "vertex": "google-vertex",
297 "gemini": "google",
298 "azure": "azure-openai-responses",
299 "copilot": "github-copilot",
300 "codex": "openai-codex",
301 "moonshot": "moonshotai",
302 "vercel": "vercel-ai-gateway",
303 "hf": "huggingface",
304 "anthropic": "anthropic", // canonical passes through
305 "amazon-bedrock": "amazon-bedrock", // already canonical
306 "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
307 "Totally-UNKNOWN": "totally-unknown",
308 "": "",
309 }
310 for in, want := range cases {
311 if got := canonicalProvider(in); got != want {
312 t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
313 }
314 }
315}
316
317func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
318 for alias, canon := range providerAliases {
319 if !isKnownProvider(canon) {
320 t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
321 }
322 }
323}
324
325func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
326 orig := http.DefaultTransport
327 t.Cleanup(func() { http.DefaultTransport = orig })
328
329 t.Setenv("ZOT_HOME", t.TempDir())
330 t.Setenv("OPENAI_API_KEY", "test-key")
331
332 resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
333 if err != nil {
334 t.Fatalf("Resolve: %v", err)
335 }
336 if resolved.InsecureTLS {
337 t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
338 }
339 assertDefaultTransportStillSecure(t)
340
341 resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
342 if err != nil {
343 t.Fatalf("Resolve: %v", err)
344 }
345 if !resolved.InsecureTLS {
346 t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
347 }
348 assertDefaultTransportStillSecure(t)
349}
350
351func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
352 orig := http.DefaultTransport
353 t.Cleanup(func() { http.DefaultTransport = orig })
354
355 t.Setenv("ZOT_HOME", t.TempDir())
356 t.Setenv("OPENAI_API_KEY", "test-key")
357 if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
358 t.Fatal(err)
359 }
360
361 resolved, err := Resolve(Args{Provider: "openai"}, false)
362 if err != nil {
363 t.Fatalf("Resolve: %v", err)
364 }
365 if resolved.InsecureTLS {
366 t.Fatal("InsecureTLS must not be set without a custom base URL")
367 }
368 assertDefaultTransportStillSecure(t)
369
370 resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
371 if err != nil {
372 t.Fatalf("Resolve: %v", err)
373 }
374 if !resolved.InsecureTLS {
375 t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
376 }
377 assertDefaultTransportStillSecure(t)
378}
379
380func assertDefaultTransportStillSecure(t *testing.T) {
381 t.Helper()
382 tr, ok := http.DefaultTransport.(*http.Transport)
383 if !ok {
384 return
385 }
386 if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
387 t.Fatal("http.DefaultTransport must not be made insecure")
388 }
389}