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