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/agent/tools"
13 "github.com/patriceckhart/zot/packages/provider"
14)
15
16func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
17 root := t.TempDir()
18 zotHome := filepath.Join(root, "zot-home")
19 project := filepath.Join(root, "repo")
20 nested := filepath.Join(project, "packages", "app")
21 if err := os.MkdirAll(zotHome, 0o755); err != nil {
22 t.Fatal(err)
23 }
24 if err := os.MkdirAll(nested, 0o755); err != nil {
25 t.Fatal(err)
26 }
27 if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
28 t.Fatal(err)
29 }
30 if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
31 t.Fatal(err)
32 }
33 if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
34 t.Fatal(err)
35 }
36
37 files := loadAgentsContext(nested, zotHome)
38 if len(files) != 3 {
39 t.Fatalf("loaded %d context files, want 3: %#v", len(files), files)
40 }
41 wantPaths := []string{
42 filepath.Join(zotHome, "AGENTS.md"),
43 filepath.Join(project, "AGENTS.md"),
44 filepath.Join(nested, "AGENTS.md"),
45 }
46 for idx, want := range wantPaths {
47 if files[idx].Path != want {
48 t.Fatalf("context file %d path = %q, want %q", idx, files[idx].Path, want)
49 }
50 }
51
52 got := formatAgentsContext(files)
53 for _, want := range []string{"global rule", "repo rule", "app rule"} {
54 if !strings.Contains(got, want) {
55 t.Fatalf("formatAgentsContext missing %q in:\n%s", want, got)
56 }
57 }
58 if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
59 t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
60 }
61}
62
63func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
64 files := loadAgentsContext(t.TempDir(), t.TempDir())
65 if len(files) != 0 {
66 t.Fatalf("expected no context files, got %#v", files)
67 }
68 if got := formatAgentsContext(files); got != "" {
69 t.Fatalf("expected no context, got %q", got)
70 }
71}
72
73func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) {
74 zotHome := t.TempDir()
75 cwd := t.TempDir()
76 t.Setenv("ZOT_HOME", zotHome)
77 t.Setenv("OPENAI_API_KEY", "test-key")
78 if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
79 t.Fatal(err)
80 }
81 if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil {
82 t.Fatal(err)
83 }
84
85 r, err := Resolve(Args{
86 Provider: "openai",
87 Model: "gpt-5",
88 CWD: cwd,
89 NoContextFiles: true,
90 }, false)
91 if err != nil {
92 t.Fatal(err)
93 }
94 if len(r.ContextFiles) != 0 {
95 t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles)
96 }
97 if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") {
98 t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt)
99 }
100}
101
102func TestResolveExplicitEmptySystemPromptOverridesPersistentPrompt(t *testing.T) {
103 zotHome := t.TempDir()
104 cwd := t.TempDir()
105 t.Setenv("ZOT_HOME", zotHome)
106 t.Setenv("OPENAI_API_KEY", "test-key")
107 if err := os.WriteFile(filepath.Join(zotHome, "SYSTEM.md"), []byte("persistent instructions"), 0o644); err != nil {
108 t.Fatal(err)
109 }
110
111 r, err := Resolve(Args{
112 Provider: "openai",
113 Model: "gpt-5",
114 CWD: cwd,
115 SystemPromptSet: true,
116 SystemPrompt: "",
117 NoContextFiles: true,
118 NoSkill: true,
119 }, false)
120 if err != nil {
121 t.Fatal(err)
122 }
123 for _, unwanted := range []string{"persistent instructions", defaultIdentity, "Zot's own docs are installed under"} {
124 if strings.Contains(r.SystemPrompt, unwanted) {
125 t.Fatalf("explicit empty system prompt unexpectedly contains %q:\n%s", unwanted, r.SystemPrompt)
126 }
127 }
128}
129
130// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
131// startup failure caught by the user's screenshot: the persisted
132// config.json points at a model id that's no longer in the active
133// catalogue (because they edited models.json or zot's bundled
134// catalogue changed). Resolve must NOT error — strands the user
135// with no way to fix it from the TUI — and should repair the config
136// so the next launch is silent.
137func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
138 t.Setenv("ZOT_HOME", t.TempDir())
139 t.Setenv("OPENAI_API_KEY", "test-key")
140 // Persist a stale model id.
141 stale := "gpt-5.5-pro-not-real"
142 if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
143 t.Fatal(err)
144 }
145
146 r, err := Resolve(Args{}, false)
147 if err != nil {
148 t.Fatalf("Resolve refused to launch with stale model: %v", err)
149 }
150 if r.Model == stale {
151 t.Fatalf("Resolve kept stale model %q", r.Model)
152 }
153 if r.Provider != "openai" {
154 t.Errorf("provider drifted: got %q; want openai", r.Provider)
155 }
156
157 // Config on disk should now hold the fallback so subsequent
158 // launches don't repeat the warning.
159 cfg, _ := LoadConfig()
160 if cfg.Model == stale {
161 t.Errorf("config.json still pins the stale model %q", cfg.Model)
162 }
163 if cfg.Model == "" {
164 t.Errorf("config.json was emptied; expected the fallback model id")
165 }
166}
167
168func TestResolveAppliesJailByDefault(t *testing.T) {
169 for _, tc := range []struct {
170 name string
171 config Config
172 want bool
173 }{
174 {name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
175 {name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
176 } {
177 t.Run(tc.name, func(t *testing.T) {
178 t.Setenv("ZOT_HOME", t.TempDir())
179 t.Setenv("OPENAI_API_KEY", "test-key")
180 if err := SaveConfig(tc.config); err != nil {
181 t.Fatal(err)
182 }
183
184 r, err := Resolve(Args{}, false)
185 if err != nil {
186 t.Fatalf("Resolve failed: %v", err)
187 }
188 if got := r.Sandbox.Locked(); got != tc.want {
189 t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
190 }
191 })
192 }
193}
194
195func boolPtr(v bool) *bool { return &v }
196
197// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
198// repair-on-disk happens ONLY when the stale id came from the
199// persisted config. If the user passed --model X explicitly and X is
200// unknown, we still fall back, but we don't touch their config.
201func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
202 t.Setenv("ZOT_HOME", t.TempDir())
203 t.Setenv("OPENAI_API_KEY", "test-key")
204 good := "gpt-5"
205 if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
206 t.Fatal(err)
207 }
208
209 r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
210 if err != nil {
211 t.Fatalf("Resolve errored on unknown --model: %v", err)
212 }
213 if r.Model == "gpt-totally-fake" {
214 t.Errorf("Resolve kept the bogus --model value")
215 }
216 cfg, _ := LoadConfig()
217 if cfg.Model != good {
218 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
219 }
220}
221
222func TestResolveReportsExtensionSkillDiagnostics(t *testing.T) {
223 home := t.TempDir()
224 project := t.TempDir()
225 t.Setenv("ZOT_HOME", home)
226 extDir := filepath.Join(project, ".zot", "extensions", "bad-skills")
227 if err := os.MkdirAll(extDir, 0o755); err != nil {
228 t.Fatal(err)
229 }
230 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"bad-skills","skills":["../outside"]}`), 0o644); err != nil {
231 t.Fatal(err)
232 }
233
234 r, err := Resolve(Args{CWD: project, WithSkills: true}, false)
235 if err != nil {
236 t.Fatal(err)
237 }
238 if len(r.SkillDiagnostics) != 1 || !strings.Contains(r.SkillDiagnostics[0], "escapes extension directory") {
239 t.Fatalf("SkillDiagnostics = %v", r.SkillDiagnostics)
240 }
241}
242
243func TestNewAgentInjectsOpenRouterServerToolsWhenSettingOn(t *testing.T) {
244 t.Setenv("ZOT_HOME", t.TempDir())
245 t.Setenv("OPENROUTER_API_KEY", "test-key")
246 on := true
247 if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
248 t.Fatal(err)
249 }
250 r, err := Resolve(Args{NoSkill: true}, true)
251 if err != nil {
252 t.Fatal(err)
253 }
254 ag := r.NewAgent()
255 for _, name := range []string{
256 "openrouter:web_search",
257 "openrouter:web_fetch",
258 "openrouter:advisor",
259 "openrouter:subagent",
260 "openrouter:fusion",
261 "openrouter:image_generation",
262 "openrouter:datetime",
263 "openrouter:experimental__search_models",
264 } {
265 if _, err := ag.Tools.Get(name); err != nil {
266 t.Errorf("missing advertised server tool %q: %v", name, err)
267 }
268 }
269 for _, name := range []string{"openrouter:shell", "openrouter:bash", "openrouter:apply_patch", "openrouter:tool_search"} {
270 if _, err := ag.Tools.Get(name); err == nil {
271 t.Errorf("chat-completions-incompatible tool %q should not be advertised", name)
272 }
273 }
274 if ag.MaxToolCalls != tools.OpenRouterServerToolCallLimit {
275 t.Fatalf("MaxToolCalls = %d; want %d", ag.MaxToolCalls, tools.OpenRouterServerToolCallLimit)
276 }
277
278 refreshAgentToolsAndPrompt(Args{NoSkill: true}, nil, nil, ag, nil)
279 if _, err := ag.Tools.Get("openrouter:web_search"); err != nil {
280 t.Fatalf("tool refresh removed enabled OpenRouter server tools: %v", err)
281 }
282}
283
284func TestResolveOmitsOpenRouterServerToolsWithNoTools(t *testing.T) {
285 t.Setenv("ZOT_HOME", t.TempDir())
286 t.Setenv("OPENROUTER_API_KEY", "test-key")
287 on := true
288 if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
289 t.Fatal(err)
290 }
291 r, err := Resolve(Args{NoTools: true, NoSkill: true}, true)
292 if err != nil {
293 t.Fatal(err)
294 }
295 if len(r.ToolRegistry) != 0 {
296 t.Fatalf("--no-tools registry has %d tools; want none", len(r.ToolRegistry))
297 }
298 if r.NewAgent().MaxToolCalls != 0 {
299 t.Fatal("--no-tools agent retained a server-tool call limit")
300 }
301}
302
303func TestResolveDoesNotOverrideOpenRouterPresetTools(t *testing.T) {
304 t.Setenv("ZOT_HOME", t.TempDir())
305 t.Setenv("OPENROUTER_API_KEY", "test-key")
306 on := true
307 if err := SaveConfig(Config{Provider: "openrouter", Model: "@preset/flash", OpenRouterServerToolsEnabled: &on}); err != nil {
308 t.Fatal(err)
309 }
310 r, err := Resolve(Args{NoSkill: true}, true)
311 if err != nil {
312 t.Fatal(err)
313 }
314 if hasOpenRouterServerTools(r.ToolRegistry) {
315 t.Fatal("request tools would override tools configured by the preset")
316 }
317}
318
319func TestNewAgentOmitsOpenRouterServerToolsWhenSettingOff(t *testing.T) {
320 t.Setenv("ZOT_HOME", t.TempDir())
321 t.Setenv("OPENROUTER_API_KEY", "test-key")
322 off := false
323 if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &off}); err != nil {
324 t.Fatal(err)
325 }
326 r, err := Resolve(Args{NoSkill: true}, true)
327 if err != nil {
328 t.Fatal(err)
329 }
330 ag := r.NewAgent()
331 if _, err := ag.Tools.Get("openrouter:web_search"); err == nil {
332 t.Fatal("web_search advertised while setting is off")
333 }
334}
335
336func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
337 t.Setenv("ZOT_HOME", t.TempDir())
338 t.Setenv("OPENROUTER_API_KEY", "test-key")
339 want := "deepseek/deepseek-v4-flash"
340 if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
341 t.Fatal(err)
342 }
343
344 r, err := Resolve(Args{}, true)
345 if err != nil {
346 t.Fatalf("Resolve failed: %v", err)
347 }
348 if r.Provider != "openrouter" {
349 t.Fatalf("provider = %q, want openrouter", r.Provider)
350 }
351 if r.Model != want {
352 t.Fatalf("model = %q, want %q", r.Model, want)
353 }
354 if r.MaxOutput != 64000 {
355 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
356 }
357}
358
359func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
360 t.Setenv("ZOT_HOME", t.TempDir())
361 t.Setenv("OPENROUTER_API_KEY", "test-key")
362 stale := "not-a-routed-model"
363 if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
364 t.Fatal(err)
365 }
366
367 r, err := Resolve(Args{}, true)
368 if err != nil {
369 t.Fatalf("Resolve failed: %v", err)
370 }
371 if r.Provider != "openrouter" {
372 t.Fatalf("provider = %q, want openrouter", r.Provider)
373 }
374 if r.Model == stale || r.Model == "" {
375 t.Fatalf("model = %q, want repaired gateway default", r.Model)
376 }
377}
378
379// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
380// #15: pointing ZOT_HOME at a fresh dir drops the persisted
381// config.json (which pinned provider=amazon-bedrock). Resolve must
382// still discover bedrock from the AWS env vars instead of falling back
383// to anthropic and reporting "not logged in".
384func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
385 t.Setenv("ZOT_HOME", t.TempDir()) // fresh home: no config.json
386 // Disable the Kimi CLI token fallback so a developer machine with a
387 // real Kimi CLI login doesn't pre-empt bedrock in the scan.
388 if err := SetKimiCLIFallbackDisabled(true); err != nil {
389 t.Fatal(err)
390 }
391 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
392 t.Setenv("AWS_REGION", "us-east-1")
393 // Make sure no other provider's env credential pre-empts bedrock.
394 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"} {
395 t.Setenv(k, "")
396 }
397
398 r, err := Resolve(Args{}, true)
399 if err != nil {
400 t.Fatalf("Resolve errored with env-only bedrock: %v", err)
401 }
402 if r.Provider != "amazon-bedrock" {
403 t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
404 }
405 if !r.HasCredential() {
406 t.Fatalf("bedrock credential not resolved from env")
407 }
408}
409
410func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
411 t.Setenv("ZOT_HOME", t.TempDir())
412 provider.SetLiveModels(nil)
413 defer provider.SetLiveModels(nil)
414 provider.SetUserModels([]provider.Model{{
415 Provider: "ollama",
416 ID: "qwen-local",
417 DisplayName: "Qwen Local",
418 ContextWindow: 32768,
419 MaxOutput: 8192,
420 BaseURL: "http://localhost:8000/v1",
421 }})
422
423 r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
424 if err != nil {
425 t.Fatalf("Resolve failed: %v", err)
426 }
427 if r.BaseURL != "http://localhost:8000/v1" {
428 t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
429 }
430}
431
432func TestResolveUsesInheritedSwarmCredential(t *testing.T) {
433 t.Setenv("ZOT_HOME", t.TempDir())
434 t.Setenv("OPENAI_API_KEY", "")
435
436 r, err := Resolve(Args{
437 Provider: "openai",
438 Model: "gpt-5",
439 inheritedCredential: "inherited-key",
440 inheritedAuthMethod: "apikey",
441 inheritedAccountID: "account-id",
442 }, true)
443 if err != nil {
444 t.Fatal(err)
445 }
446 if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
447 t.Fatalf("inherited credential was not preserved: %+v", r)
448 }
449}
450
451func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
452 t.Setenv("ZOT_HOME", t.TempDir())
453 t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
454 t.Setenv("LLAMA_API_KEY", "")
455 provider.SetManagedModels(nil)
456 t.Cleanup(func() { provider.SetManagedModels(nil) })
457
458 r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
459 if err != nil {
460 t.Fatalf("Resolve failed: %v", err)
461 }
462 if r.BaseURL != "http://127.0.0.1:8080/v1" {
463 t.Fatalf("BaseURL = %q", r.BaseURL)
464 }
465 if r.Credential != "local" || !r.HasCredential() {
466 t.Fatalf("credential = %q", r.Credential)
467 }
468 if got := r.NewClient().Name(); got != "openai" {
469 t.Fatalf("client name = %q", got)
470 }
471}
472
473func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
474 t.Setenv("ZOT_HOME", t.TempDir())
475 t.Setenv("LLAMA_BASE_URL", "")
476 t.Setenv("LLAMA_API_KEY", "")
477 if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
478 t.Fatal(err)
479 }
480
481 r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
482 if err != nil {
483 t.Fatalf("Resolve failed: %v", err)
484 }
485 if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
486 t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
487 }
488}
489
490func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
491 t.Setenv("ZOT_HOME", t.TempDir())
492 t.Setenv("MY_COMPANY_API_KEY", "test-key")
493 path := filepath.Join(t.TempDir(), "models.json")
494 if err := os.WriteFile(path, []byte(`{
495 "providers": {
496 "my-company": {
497 "baseUrl": "https://provider.example.com/v1",
498 "api": "openai",
499 "models": [
500 {"id": "fast", "baseUrl": "https://model.example.com/v1"}
501 ]
502 }
503 }
504 }`), 0o644); err != nil {
505 t.Fatal(err)
506 }
507 models, warnings := provider.LoadUserModelsWithWarnings(path)
508 if len(warnings) != 0 {
509 t.Fatalf("warnings = %v", warnings)
510 }
511 provider.SetLiveModels(nil)
512 provider.SetUserModels(models)
513 t.Cleanup(func() { provider.SetLiveModels(nil) })
514
515 r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
516 if err != nil {
517 t.Fatalf("Resolve failed: %v", err)
518 }
519 if r.BaseURL != "https://model.example.com/v1" {
520 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
521 }
522}
523
524func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
525 requestPath := make(chan string, 1)
526 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
527 requestPath <- r.URL.Path
528 w.Header().Set("Content-Type", "text/event-stream")
529 w.WriteHeader(http.StatusOK)
530 }))
531 t.Cleanup(srv.Close)
532
533 path := filepath.Join(t.TempDir(), "models.json")
534 if err := os.WriteFile(path, []byte(`{
535 "providers": {
536 "company-responses": {
537 "baseUrl": "`+srv.URL+`/v1",
538 "api": "openai-responses",
539 "models": [{"id": "reasoning-model", "reasoning": true}]
540 }
541 }
542 }`), 0o644); err != nil {
543 t.Fatal(err)
544 }
545 models, warnings := provider.LoadUserModelsWithWarnings(path)
546 if len(warnings) != 0 {
547 t.Fatalf("warnings = %v", warnings)
548 }
549 provider.SetLiveModels(nil)
550 provider.SetUserModels(models)
551 t.Cleanup(func() { provider.SetLiveModels(nil) })
552
553 r := Resolved{
554 Provider: "company-responses",
555 Credential: "test-key",
556 BaseURL: srv.URL + "/v1",
557 }
558 events, err := r.NewClient().Stream(context.Background(), provider.Request{
559 Model: "reasoning-model",
560 Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
561 })
562 if err != nil {
563 t.Fatal(err)
564 }
565 for range events {
566 }
567 if got := <-requestPath; got != "/v1/responses" {
568 t.Fatalf("request path = %q, want /v1/responses", got)
569 }
570}
571
572func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
573 t.Setenv("ZOT_HOME", t.TempDir())
574 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
575 path := filepath.Join(t.TempDir(), "models.json")
576 if err := os.WriteFile(path, []byte(`{
577 "providers": {
578 "local-proxy": {
579 "baseUrl": "https://proxy.example.com/v1",
580 "api": "openai",
581 "models": [{"id": "default"}]
582 }
583 }
584 }`), 0o644); err != nil {
585 t.Fatal(err)
586 }
587 models, warnings := provider.LoadUserModelsWithWarnings(path)
588 if len(warnings) != 0 {
589 t.Fatalf("warnings = %v", warnings)
590 }
591 provider.SetLiveModels(nil)
592 provider.SetUserModels(models)
593 t.Cleanup(func() { provider.SetLiveModels(nil) })
594
595 r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
596 if err != nil {
597 t.Fatalf("Resolve failed: %v", err)
598 }
599 if !r.InsecureTLS {
600 t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
601 }
602}
603
604func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
605 t.Setenv("ZOT_HOME", t.TempDir())
606 provider.SetLiveModels(nil)
607 defer provider.SetLiveModels(nil)
608
609 r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
610 if err != nil {
611 t.Fatalf("Resolve failed: %v", err)
612 }
613 if r.BaseURL != "http://localhost:11434" {
614 t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
615 }
616}
617
618func TestCanonicalProviderResolvesAliases(t *testing.T) {
619 cases := map[string]string{
620 "bedrock": "amazon-bedrock",
621 "AWS-Bedrock": "amazon-bedrock",
622 " bedrock ": "amazon-bedrock",
623 "vertex": "google-vertex",
624 "gemini": "google",
625 "azure": "azure-openai-responses",
626 "copilot": "github-copilot",
627 "codex": "openai-codex",
628 "moonshot": "moonshotai",
629 "vercel": "vercel-ai-gateway",
630 "hf": "huggingface",
631 "anthropic": "anthropic", // canonical passes through
632 "amazon-bedrock": "amazon-bedrock", // already canonical
633 "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
634 "Totally-UNKNOWN": "totally-unknown",
635 "": "",
636 }
637 for in, want := range cases {
638 if got := canonicalProvider(in); got != want {
639 t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
640 }
641 }
642}
643
644func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
645 for alias, canon := range providerAliases {
646 if !isKnownProvider(canon) {
647 t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
648 }
649 }
650}
651
652func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
653 orig := http.DefaultTransport
654 t.Cleanup(func() { http.DefaultTransport = orig })
655
656 t.Setenv("ZOT_HOME", t.TempDir())
657 t.Setenv("OPENAI_API_KEY", "test-key")
658
659 resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
660 if err != nil {
661 t.Fatalf("Resolve: %v", err)
662 }
663 if resolved.InsecureTLS {
664 t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
665 }
666 assertDefaultTransportStillSecure(t)
667
668 resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
669 if err != nil {
670 t.Fatalf("Resolve: %v", err)
671 }
672 if !resolved.InsecureTLS {
673 t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
674 }
675 assertDefaultTransportStillSecure(t)
676}
677
678func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
679 orig := http.DefaultTransport
680 t.Cleanup(func() { http.DefaultTransport = orig })
681
682 t.Setenv("ZOT_HOME", t.TempDir())
683 t.Setenv("OPENAI_API_KEY", "test-key")
684 if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
685 t.Fatal(err)
686 }
687
688 resolved, err := Resolve(Args{Provider: "openai"}, false)
689 if err != nil {
690 t.Fatalf("Resolve: %v", err)
691 }
692 if resolved.InsecureTLS {
693 t.Fatal("InsecureTLS must not be set without a custom base URL")
694 }
695 assertDefaultTransportStillSecure(t)
696
697 resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
698 if err != nil {
699 t.Fatalf("Resolve: %v", err)
700 }
701 if !resolved.InsecureTLS {
702 t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
703 }
704 assertDefaultTransportStillSecure(t)
705}
706
707func TestDefaultXAIModelIsGrok45(t *testing.T) {
708 if got := defaultModelForProvider("xai"); got != "grok-4.5" {
709 t.Fatalf("default xAI model = %q, want grok-4.5", got)
710 }
711}
712
713func assertDefaultTransportStillSecure(t *testing.T) {
714 t.Helper()
715 tr, ok := http.DefaultTransport.(*http.Transport)
716 if !ok {
717 return
718 }
719 if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
720 t.Fatal("http.DefaultTransport must not be made insecure")
721 }
722}
723
724func TestBuildToolRegistryIncludesGlob(t *testing.T) {
725 cwd := t.TempDir()
726 reg := buildToolRegistry(Args{}, cwd, nil)
727 expected := []string{"read", "write", "edit", "bash", "glob"}
728 for _, name := range expected {
729 if _, ok := reg[name]; !ok {
730 t.Errorf("expected tool %q in registry, missing", name)
731 }
732 }
733
734 summaries := toolSummaries(reg, Args{})
735 var summaryNames []string
736 for _, s := range summaries {
737 summaryNames = append(summaryNames, s.Name)
738 }
739 for _, name := range expected {
740 found := false
741 for _, sn := range summaryNames {
742 if sn == name {
743 found = true
744 break
745 }
746 }
747 if !found {
748 t.Errorf("expected tool %q in toolSummaries, missing", name)
749 }
750 }
751
752 // Filtered tools
753 filtered := buildToolRegistry(Args{Tools: []string{"glob", "read"}}, cwd, nil)
754 if len(filtered) != 2 || filtered["glob"] == nil || filtered["read"] == nil {
755 t.Errorf("expected filtered registry with glob and read, got: %#v", filtered)
756 }
757}