1package agent
2
3import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/patriceckhart/zot/packages/provider"
13)
14
15func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
16 root := t.TempDir()
17 zotHome := filepath.Join(root, "zot-home")
18 project := filepath.Join(root, "repo")
19 nested := filepath.Join(project, "packages", "app")
20 if err := os.MkdirAll(zotHome, 0o755); err != nil {
21 t.Fatal(err)
22 }
23 if err := os.MkdirAll(nested, 0o755); err != nil {
24 t.Fatal(err)
25 }
26 if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
27 t.Fatal(err)
28 }
29 if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
30 t.Fatal(err)
31 }
32 if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
33 t.Fatal(err)
34 }
35
36 files := loadAgentsContext(nested, zotHome)
37 if len(files) != 3 {
38 t.Fatalf("loaded %d context files, want 3: %#v", len(files), files)
39 }
40 wantPaths := []string{
41 filepath.Join(zotHome, "AGENTS.md"),
42 filepath.Join(project, "AGENTS.md"),
43 filepath.Join(nested, "AGENTS.md"),
44 }
45 for idx, want := range wantPaths {
46 if files[idx].Path != want {
47 t.Fatalf("context file %d path = %q, want %q", idx, files[idx].Path, want)
48 }
49 }
50
51 got := formatAgentsContext(files)
52 for _, want := range []string{"global rule", "repo rule", "app rule"} {
53 if !strings.Contains(got, want) {
54 t.Fatalf("formatAgentsContext missing %q in:\n%s", want, got)
55 }
56 }
57 if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
58 t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
59 }
60}
61
62func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
63 files := loadAgentsContext(t.TempDir(), t.TempDir())
64 if len(files) != 0 {
65 t.Fatalf("expected no context files, got %#v", files)
66 }
67 if got := formatAgentsContext(files); got != "" {
68 t.Fatalf("expected no context, got %q", got)
69 }
70}
71
72// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
73// startup failure caught by the user's screenshot: the persisted
74// config.json points at a model id that's no longer in the active
75// catalogue (because they edited models.json or zot's bundled
76// catalogue changed). Resolve must NOT error — strands the user
77// with no way to fix it from the TUI — and should repair the config
78// so the next launch is silent.
79func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
80 t.Setenv("ZOT_HOME", t.TempDir())
81 t.Setenv("OPENAI_API_KEY", "test-key")
82 // Persist a stale model id.
83 stale := "gpt-5.5-pro-not-real"
84 if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
85 t.Fatal(err)
86 }
87
88 r, err := Resolve(Args{}, false)
89 if err != nil {
90 t.Fatalf("Resolve refused to launch with stale model: %v", err)
91 }
92 if r.Model == stale {
93 t.Fatalf("Resolve kept stale model %q", r.Model)
94 }
95 if r.Provider != "openai" {
96 t.Errorf("provider drifted: got %q; want openai", r.Provider)
97 }
98
99 // Config on disk should now hold the fallback so subsequent
100 // launches don't repeat the warning.
101 cfg, _ := LoadConfig()
102 if cfg.Model == stale {
103 t.Errorf("config.json still pins the stale model %q", cfg.Model)
104 }
105 if cfg.Model == "" {
106 t.Errorf("config.json was emptied; expected the fallback model id")
107 }
108}
109
110func TestResolveAppliesJailByDefault(t *testing.T) {
111 for _, tc := range []struct {
112 name string
113 config Config
114 want bool
115 }{
116 {name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
117 {name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
118 } {
119 t.Run(tc.name, func(t *testing.T) {
120 t.Setenv("ZOT_HOME", t.TempDir())
121 t.Setenv("OPENAI_API_KEY", "test-key")
122 if err := SaveConfig(tc.config); err != nil {
123 t.Fatal(err)
124 }
125
126 r, err := Resolve(Args{}, false)
127 if err != nil {
128 t.Fatalf("Resolve failed: %v", err)
129 }
130 if got := r.Sandbox.Locked(); got != tc.want {
131 t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
132 }
133 })
134 }
135}
136
137func boolPtr(v bool) *bool { return &v }
138
139// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
140// repair-on-disk happens ONLY when the stale id came from the
141// persisted config. If the user passed --model X explicitly and X is
142// unknown, we still fall back, but we don't touch their config.
143func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
144 t.Setenv("ZOT_HOME", t.TempDir())
145 t.Setenv("OPENAI_API_KEY", "test-key")
146 good := "gpt-5"
147 if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
148 t.Fatal(err)
149 }
150
151 r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
152 if err != nil {
153 t.Fatalf("Resolve errored on unknown --model: %v", err)
154 }
155 if r.Model == "gpt-totally-fake" {
156 t.Errorf("Resolve kept the bogus --model value")
157 }
158 cfg, _ := LoadConfig()
159 if cfg.Model != good {
160 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
161 }
162}
163
164func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
165 t.Setenv("ZOT_HOME", t.TempDir())
166 t.Setenv("OPENROUTER_API_KEY", "test-key")
167 want := "deepseek/deepseek-v4-flash"
168 if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
169 t.Fatal(err)
170 }
171
172 r, err := Resolve(Args{}, true)
173 if err != nil {
174 t.Fatalf("Resolve failed: %v", err)
175 }
176 if r.Provider != "openrouter" {
177 t.Fatalf("provider = %q, want openrouter", r.Provider)
178 }
179 if r.Model != want {
180 t.Fatalf("model = %q, want %q", r.Model, want)
181 }
182 if r.MaxOutput != 64000 {
183 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
184 }
185}
186
187func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
188 t.Setenv("ZOT_HOME", t.TempDir())
189 t.Setenv("OPENROUTER_API_KEY", "test-key")
190 stale := "not-a-routed-model"
191 if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
192 t.Fatal(err)
193 }
194
195 r, err := Resolve(Args{}, true)
196 if err != nil {
197 t.Fatalf("Resolve failed: %v", err)
198 }
199 if r.Provider != "openrouter" {
200 t.Fatalf("provider = %q, want openrouter", r.Provider)
201 }
202 if r.Model == stale || r.Model == "" {
203 t.Fatalf("model = %q, want repaired gateway default", r.Model)
204 }
205}
206
207// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
208// #15: pointing ZOT_HOME at a fresh dir drops the persisted
209// config.json (which pinned provider=amazon-bedrock). Resolve must
210// still discover bedrock from the AWS env vars instead of falling back
211// to anthropic and reporting "not logged in".
212func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
213 t.Setenv("ZOT_HOME", t.TempDir()) // fresh home: no config.json
214 // Disable the Kimi CLI token fallback so a developer machine with a
215 // real Kimi CLI login doesn't pre-empt bedrock in the scan.
216 if err := SetKimiCLIFallbackDisabled(true); err != nil {
217 t.Fatal(err)
218 }
219 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
220 t.Setenv("AWS_REGION", "us-east-1")
221 // Make sure no other provider's env credential pre-empts bedrock.
222 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"} {
223 t.Setenv(k, "")
224 }
225
226 r, err := Resolve(Args{}, true)
227 if err != nil {
228 t.Fatalf("Resolve errored with env-only bedrock: %v", err)
229 }
230 if r.Provider != "amazon-bedrock" {
231 t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
232 }
233 if !r.HasCredential() {
234 t.Fatalf("bedrock credential not resolved from env")
235 }
236}
237
238func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
239 t.Setenv("ZOT_HOME", t.TempDir())
240 provider.SetLiveModels(nil)
241 defer provider.SetLiveModels(nil)
242 provider.SetUserModels([]provider.Model{{
243 Provider: "ollama",
244 ID: "qwen-local",
245 DisplayName: "Qwen Local",
246 ContextWindow: 32768,
247 MaxOutput: 8192,
248 BaseURL: "http://localhost:8000/v1",
249 }})
250
251 r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
252 if err != nil {
253 t.Fatalf("Resolve failed: %v", err)
254 }
255 if r.BaseURL != "http://localhost:8000/v1" {
256 t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
257 }
258}
259
260func TestResolveUsesInheritedSwarmCredential(t *testing.T) {
261 t.Setenv("ZOT_HOME", t.TempDir())
262 t.Setenv("OPENAI_API_KEY", "")
263
264 r, err := Resolve(Args{
265 Provider: "openai",
266 Model: "gpt-5",
267 inheritedCredential: "inherited-key",
268 inheritedAuthMethod: "apikey",
269 inheritedAccountID: "account-id",
270 }, true)
271 if err != nil {
272 t.Fatal(err)
273 }
274 if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
275 t.Fatalf("inherited credential was not preserved: %+v", r)
276 }
277}
278
279func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
280 t.Setenv("ZOT_HOME", t.TempDir())
281 t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
282 t.Setenv("LLAMA_API_KEY", "")
283 provider.SetManagedModels(nil)
284 t.Cleanup(func() { provider.SetManagedModels(nil) })
285
286 r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
287 if err != nil {
288 t.Fatalf("Resolve failed: %v", err)
289 }
290 if r.BaseURL != "http://127.0.0.1:8080/v1" {
291 t.Fatalf("BaseURL = %q", r.BaseURL)
292 }
293 if r.Credential != "local" || !r.HasCredential() {
294 t.Fatalf("credential = %q", r.Credential)
295 }
296 if got := r.NewClient().Name(); got != "openai" {
297 t.Fatalf("client name = %q", got)
298 }
299}
300
301func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
302 t.Setenv("ZOT_HOME", t.TempDir())
303 t.Setenv("LLAMA_BASE_URL", "")
304 t.Setenv("LLAMA_API_KEY", "")
305 if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
306 t.Fatal(err)
307 }
308
309 r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
310 if err != nil {
311 t.Fatalf("Resolve failed: %v", err)
312 }
313 if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
314 t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
315 }
316}
317
318func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
319 t.Setenv("ZOT_HOME", t.TempDir())
320 t.Setenv("MY_COMPANY_API_KEY", "test-key")
321 path := filepath.Join(t.TempDir(), "models.json")
322 if err := os.WriteFile(path, []byte(`{
323 "providers": {
324 "my-company": {
325 "baseUrl": "https://provider.example.com/v1",
326 "api": "openai",
327 "models": [
328 {"id": "fast", "baseUrl": "https://model.example.com/v1"}
329 ]
330 }
331 }
332 }`), 0o644); err != nil {
333 t.Fatal(err)
334 }
335 models, warnings := provider.LoadUserModelsWithWarnings(path)
336 if len(warnings) != 0 {
337 t.Fatalf("warnings = %v", warnings)
338 }
339 provider.SetLiveModels(nil)
340 provider.SetUserModels(models)
341 t.Cleanup(func() { provider.SetLiveModels(nil) })
342
343 r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
344 if err != nil {
345 t.Fatalf("Resolve failed: %v", err)
346 }
347 if r.BaseURL != "https://model.example.com/v1" {
348 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
349 }
350}
351
352func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
353 requestPath := make(chan string, 1)
354 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
355 requestPath <- r.URL.Path
356 w.Header().Set("Content-Type", "text/event-stream")
357 w.WriteHeader(http.StatusOK)
358 }))
359 t.Cleanup(srv.Close)
360
361 path := filepath.Join(t.TempDir(), "models.json")
362 if err := os.WriteFile(path, []byte(`{
363 "providers": {
364 "company-responses": {
365 "baseUrl": "`+srv.URL+`/v1",
366 "api": "openai-responses",
367 "models": [{"id": "reasoning-model", "reasoning": true}]
368 }
369 }
370 }`), 0o644); err != nil {
371 t.Fatal(err)
372 }
373 models, warnings := provider.LoadUserModelsWithWarnings(path)
374 if len(warnings) != 0 {
375 t.Fatalf("warnings = %v", warnings)
376 }
377 provider.SetLiveModels(nil)
378 provider.SetUserModels(models)
379 t.Cleanup(func() { provider.SetLiveModels(nil) })
380
381 r := Resolved{
382 Provider: "company-responses",
383 Credential: "test-key",
384 BaseURL: srv.URL + "/v1",
385 }
386 events, err := r.NewClient().Stream(context.Background(), provider.Request{
387 Model: "reasoning-model",
388 Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
389 })
390 if err != nil {
391 t.Fatal(err)
392 }
393 for range events {
394 }
395 if got := <-requestPath; got != "/v1/responses" {
396 t.Fatalf("request path = %q, want /v1/responses", got)
397 }
398}
399
400func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
401 t.Setenv("ZOT_HOME", t.TempDir())
402 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
403 path := filepath.Join(t.TempDir(), "models.json")
404 if err := os.WriteFile(path, []byte(`{
405 "providers": {
406 "local-proxy": {
407 "baseUrl": "https://proxy.example.com/v1",
408 "api": "openai",
409 "models": [{"id": "default"}]
410 }
411 }
412 }`), 0o644); err != nil {
413 t.Fatal(err)
414 }
415 models, warnings := provider.LoadUserModelsWithWarnings(path)
416 if len(warnings) != 0 {
417 t.Fatalf("warnings = %v", warnings)
418 }
419 provider.SetLiveModels(nil)
420 provider.SetUserModels(models)
421 t.Cleanup(func() { provider.SetLiveModels(nil) })
422
423 r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
424 if err != nil {
425 t.Fatalf("Resolve failed: %v", err)
426 }
427 if !r.InsecureTLS {
428 t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
429 }
430}
431
432func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
433 t.Setenv("ZOT_HOME", t.TempDir())
434 provider.SetLiveModels(nil)
435 defer provider.SetLiveModels(nil)
436
437 r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
438 if err != nil {
439 t.Fatalf("Resolve failed: %v", err)
440 }
441 if r.BaseURL != "http://localhost:11434" {
442 t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
443 }
444}
445
446func TestCanonicalProviderResolvesAliases(t *testing.T) {
447 cases := map[string]string{
448 "bedrock": "amazon-bedrock",
449 "AWS-Bedrock": "amazon-bedrock",
450 " bedrock ": "amazon-bedrock",
451 "vertex": "google-vertex",
452 "gemini": "google",
453 "azure": "azure-openai-responses",
454 "copilot": "github-copilot",
455 "codex": "openai-codex",
456 "moonshot": "moonshotai",
457 "vercel": "vercel-ai-gateway",
458 "hf": "huggingface",
459 "anthropic": "anthropic", // canonical passes through
460 "amazon-bedrock": "amazon-bedrock", // already canonical
461 "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
462 "Totally-UNKNOWN": "totally-unknown",
463 "": "",
464 }
465 for in, want := range cases {
466 if got := canonicalProvider(in); got != want {
467 t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
468 }
469 }
470}
471
472func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
473 for alias, canon := range providerAliases {
474 if !isKnownProvider(canon) {
475 t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
476 }
477 }
478}
479
480func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
481 orig := http.DefaultTransport
482 t.Cleanup(func() { http.DefaultTransport = orig })
483
484 t.Setenv("ZOT_HOME", t.TempDir())
485 t.Setenv("OPENAI_API_KEY", "test-key")
486
487 resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
488 if err != nil {
489 t.Fatalf("Resolve: %v", err)
490 }
491 if resolved.InsecureTLS {
492 t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
493 }
494 assertDefaultTransportStillSecure(t)
495
496 resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
497 if err != nil {
498 t.Fatalf("Resolve: %v", err)
499 }
500 if !resolved.InsecureTLS {
501 t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
502 }
503 assertDefaultTransportStillSecure(t)
504}
505
506func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
507 orig := http.DefaultTransport
508 t.Cleanup(func() { http.DefaultTransport = orig })
509
510 t.Setenv("ZOT_HOME", t.TempDir())
511 t.Setenv("OPENAI_API_KEY", "test-key")
512 if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
513 t.Fatal(err)
514 }
515
516 resolved, err := Resolve(Args{Provider: "openai"}, false)
517 if err != nil {
518 t.Fatalf("Resolve: %v", err)
519 }
520 if resolved.InsecureTLS {
521 t.Fatal("InsecureTLS must not be set without a custom base URL")
522 }
523 assertDefaultTransportStillSecure(t)
524
525 resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
526 if err != nil {
527 t.Fatalf("Resolve: %v", err)
528 }
529 if !resolved.InsecureTLS {
530 t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
531 }
532 assertDefaultTransportStillSecure(t)
533}
534
535func TestDefaultXAIModelIsGrok45(t *testing.T) {
536 if got := defaultModelForProvider("xai"); got != "grok-4.5" {
537 t.Fatalf("default xAI model = %q, want grok-4.5", got)
538 }
539}
540
541func assertDefaultTransportStillSecure(t *testing.T) {
542 t.Helper()
543 tr, ok := http.DefaultTransport.(*http.Transport)
544 if !ok {
545 return
546 }
547 if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
548 t.Fatal("http.DefaultTransport must not be made insecure")
549 }
550}