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 TestNewAgentInjectsOpenRouterServerToolsWhenSettingOn(t *testing.T) {
223 t.Setenv("ZOT_HOME", t.TempDir())
224 t.Setenv("OPENROUTER_API_KEY", "test-key")
225 on := true
226 if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
227 t.Fatal(err)
228 }
229 r, err := Resolve(Args{NoSkill: true}, true)
230 if err != nil {
231 t.Fatal(err)
232 }
233 ag := r.NewAgent()
234 for _, name := range []string{
235 "openrouter:web_search",
236 "openrouter:web_fetch",
237 "openrouter:advisor",
238 "openrouter:subagent",
239 "openrouter:fusion",
240 "openrouter:image_generation",
241 "openrouter:datetime",
242 "openrouter:experimental__search_models",
243 } {
244 if _, err := ag.Tools.Get(name); err != nil {
245 t.Errorf("missing advertised server tool %q: %v", name, err)
246 }
247 }
248 for _, name := range []string{"openrouter:shell", "openrouter:bash", "openrouter:apply_patch", "openrouter:tool_search"} {
249 if _, err := ag.Tools.Get(name); err == nil {
250 t.Errorf("chat-completions-incompatible tool %q should not be advertised", name)
251 }
252 }
253 if ag.MaxToolCalls != tools.OpenRouterServerToolCallLimit {
254 t.Fatalf("MaxToolCalls = %d; want %d", ag.MaxToolCalls, tools.OpenRouterServerToolCallLimit)
255 }
256
257 refreshAgentToolsAndPrompt(Args{NoSkill: true}, nil, nil, ag, nil)
258 if _, err := ag.Tools.Get("openrouter:web_search"); err != nil {
259 t.Fatalf("tool refresh removed enabled OpenRouter server tools: %v", err)
260 }
261}
262
263func TestResolveOmitsOpenRouterServerToolsWithNoTools(t *testing.T) {
264 t.Setenv("ZOT_HOME", t.TempDir())
265 t.Setenv("OPENROUTER_API_KEY", "test-key")
266 on := true
267 if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
268 t.Fatal(err)
269 }
270 r, err := Resolve(Args{NoTools: true, NoSkill: true}, true)
271 if err != nil {
272 t.Fatal(err)
273 }
274 if len(r.ToolRegistry) != 0 {
275 t.Fatalf("--no-tools registry has %d tools; want none", len(r.ToolRegistry))
276 }
277 if r.NewAgent().MaxToolCalls != 0 {
278 t.Fatal("--no-tools agent retained a server-tool call limit")
279 }
280}
281
282func TestResolveDoesNotOverrideOpenRouterPresetTools(t *testing.T) {
283 t.Setenv("ZOT_HOME", t.TempDir())
284 t.Setenv("OPENROUTER_API_KEY", "test-key")
285 on := true
286 if err := SaveConfig(Config{Provider: "openrouter", Model: "@preset/flash", OpenRouterServerToolsEnabled: &on}); err != nil {
287 t.Fatal(err)
288 }
289 r, err := Resolve(Args{NoSkill: true}, true)
290 if err != nil {
291 t.Fatal(err)
292 }
293 if hasOpenRouterServerTools(r.ToolRegistry) {
294 t.Fatal("request tools would override tools configured by the preset")
295 }
296}
297
298func TestNewAgentOmitsOpenRouterServerToolsWhenSettingOff(t *testing.T) {
299 t.Setenv("ZOT_HOME", t.TempDir())
300 t.Setenv("OPENROUTER_API_KEY", "test-key")
301 off := false
302 if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &off}); err != nil {
303 t.Fatal(err)
304 }
305 r, err := Resolve(Args{NoSkill: true}, true)
306 if err != nil {
307 t.Fatal(err)
308 }
309 ag := r.NewAgent()
310 if _, err := ag.Tools.Get("openrouter:web_search"); err == nil {
311 t.Fatal("web_search advertised while setting is off")
312 }
313}
314
315func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
316 t.Setenv("ZOT_HOME", t.TempDir())
317 t.Setenv("OPENROUTER_API_KEY", "test-key")
318 want := "deepseek/deepseek-v4-flash"
319 if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
320 t.Fatal(err)
321 }
322
323 r, err := Resolve(Args{}, true)
324 if err != nil {
325 t.Fatalf("Resolve failed: %v", err)
326 }
327 if r.Provider != "openrouter" {
328 t.Fatalf("provider = %q, want openrouter", r.Provider)
329 }
330 if r.Model != want {
331 t.Fatalf("model = %q, want %q", r.Model, want)
332 }
333 if r.MaxOutput != 64000 {
334 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
335 }
336}
337
338func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
339 t.Setenv("ZOT_HOME", t.TempDir())
340 t.Setenv("OPENROUTER_API_KEY", "test-key")
341 stale := "not-a-routed-model"
342 if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
343 t.Fatal(err)
344 }
345
346 r, err := Resolve(Args{}, true)
347 if err != nil {
348 t.Fatalf("Resolve failed: %v", err)
349 }
350 if r.Provider != "openrouter" {
351 t.Fatalf("provider = %q, want openrouter", r.Provider)
352 }
353 if r.Model == stale || r.Model == "" {
354 t.Fatalf("model = %q, want repaired gateway default", r.Model)
355 }
356}
357
358// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
359// #15: pointing ZOT_HOME at a fresh dir drops the persisted
360// config.json (which pinned provider=amazon-bedrock). Resolve must
361// still discover bedrock from the AWS env vars instead of falling back
362// to anthropic and reporting "not logged in".
363func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
364 t.Setenv("ZOT_HOME", t.TempDir()) // fresh home: no config.json
365 // Disable the Kimi CLI token fallback so a developer machine with a
366 // real Kimi CLI login doesn't pre-empt bedrock in the scan.
367 if err := SetKimiCLIFallbackDisabled(true); err != nil {
368 t.Fatal(err)
369 }
370 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
371 t.Setenv("AWS_REGION", "us-east-1")
372 // Make sure no other provider's env credential pre-empts bedrock.
373 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"} {
374 t.Setenv(k, "")
375 }
376
377 r, err := Resolve(Args{}, true)
378 if err != nil {
379 t.Fatalf("Resolve errored with env-only bedrock: %v", err)
380 }
381 if r.Provider != "amazon-bedrock" {
382 t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
383 }
384 if !r.HasCredential() {
385 t.Fatalf("bedrock credential not resolved from env")
386 }
387}
388
389func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
390 t.Setenv("ZOT_HOME", t.TempDir())
391 provider.SetLiveModels(nil)
392 defer provider.SetLiveModels(nil)
393 provider.SetUserModels([]provider.Model{{
394 Provider: "ollama",
395 ID: "qwen-local",
396 DisplayName: "Qwen Local",
397 ContextWindow: 32768,
398 MaxOutput: 8192,
399 BaseURL: "http://localhost:8000/v1",
400 }})
401
402 r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
403 if err != nil {
404 t.Fatalf("Resolve failed: %v", err)
405 }
406 if r.BaseURL != "http://localhost:8000/v1" {
407 t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
408 }
409}
410
411func TestResolveUsesInheritedSwarmCredential(t *testing.T) {
412 t.Setenv("ZOT_HOME", t.TempDir())
413 t.Setenv("OPENAI_API_KEY", "")
414
415 r, err := Resolve(Args{
416 Provider: "openai",
417 Model: "gpt-5",
418 inheritedCredential: "inherited-key",
419 inheritedAuthMethod: "apikey",
420 inheritedAccountID: "account-id",
421 }, true)
422 if err != nil {
423 t.Fatal(err)
424 }
425 if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
426 t.Fatalf("inherited credential was not preserved: %+v", r)
427 }
428}
429
430func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
431 t.Setenv("ZOT_HOME", t.TempDir())
432 t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
433 t.Setenv("LLAMA_API_KEY", "")
434 provider.SetManagedModels(nil)
435 t.Cleanup(func() { provider.SetManagedModels(nil) })
436
437 r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
438 if err != nil {
439 t.Fatalf("Resolve failed: %v", err)
440 }
441 if r.BaseURL != "http://127.0.0.1:8080/v1" {
442 t.Fatalf("BaseURL = %q", r.BaseURL)
443 }
444 if r.Credential != "local" || !r.HasCredential() {
445 t.Fatalf("credential = %q", r.Credential)
446 }
447 if got := r.NewClient().Name(); got != "openai" {
448 t.Fatalf("client name = %q", got)
449 }
450}
451
452func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
453 t.Setenv("ZOT_HOME", t.TempDir())
454 t.Setenv("LLAMA_BASE_URL", "")
455 t.Setenv("LLAMA_API_KEY", "")
456 if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
457 t.Fatal(err)
458 }
459
460 r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
461 if err != nil {
462 t.Fatalf("Resolve failed: %v", err)
463 }
464 if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
465 t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
466 }
467}
468
469func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
470 t.Setenv("ZOT_HOME", t.TempDir())
471 t.Setenv("MY_COMPANY_API_KEY", "test-key")
472 path := filepath.Join(t.TempDir(), "models.json")
473 if err := os.WriteFile(path, []byte(`{
474 "providers": {
475 "my-company": {
476 "baseUrl": "https://provider.example.com/v1",
477 "api": "openai",
478 "models": [
479 {"id": "fast", "baseUrl": "https://model.example.com/v1"}
480 ]
481 }
482 }
483 }`), 0o644); err != nil {
484 t.Fatal(err)
485 }
486 models, warnings := provider.LoadUserModelsWithWarnings(path)
487 if len(warnings) != 0 {
488 t.Fatalf("warnings = %v", warnings)
489 }
490 provider.SetLiveModels(nil)
491 provider.SetUserModels(models)
492 t.Cleanup(func() { provider.SetLiveModels(nil) })
493
494 r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
495 if err != nil {
496 t.Fatalf("Resolve failed: %v", err)
497 }
498 if r.BaseURL != "https://model.example.com/v1" {
499 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
500 }
501}
502
503func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
504 requestPath := make(chan string, 1)
505 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
506 requestPath <- r.URL.Path
507 w.Header().Set("Content-Type", "text/event-stream")
508 w.WriteHeader(http.StatusOK)
509 }))
510 t.Cleanup(srv.Close)
511
512 path := filepath.Join(t.TempDir(), "models.json")
513 if err := os.WriteFile(path, []byte(`{
514 "providers": {
515 "company-responses": {
516 "baseUrl": "`+srv.URL+`/v1",
517 "api": "openai-responses",
518 "models": [{"id": "reasoning-model", "reasoning": true}]
519 }
520 }
521 }`), 0o644); err != nil {
522 t.Fatal(err)
523 }
524 models, warnings := provider.LoadUserModelsWithWarnings(path)
525 if len(warnings) != 0 {
526 t.Fatalf("warnings = %v", warnings)
527 }
528 provider.SetLiveModels(nil)
529 provider.SetUserModels(models)
530 t.Cleanup(func() { provider.SetLiveModels(nil) })
531
532 r := Resolved{
533 Provider: "company-responses",
534 Credential: "test-key",
535 BaseURL: srv.URL + "/v1",
536 }
537 events, err := r.NewClient().Stream(context.Background(), provider.Request{
538 Model: "reasoning-model",
539 Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
540 })
541 if err != nil {
542 t.Fatal(err)
543 }
544 for range events {
545 }
546 if got := <-requestPath; got != "/v1/responses" {
547 t.Fatalf("request path = %q, want /v1/responses", got)
548 }
549}
550
551func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
552 t.Setenv("ZOT_HOME", t.TempDir())
553 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
554 path := filepath.Join(t.TempDir(), "models.json")
555 if err := os.WriteFile(path, []byte(`{
556 "providers": {
557 "local-proxy": {
558 "baseUrl": "https://proxy.example.com/v1",
559 "api": "openai",
560 "models": [{"id": "default"}]
561 }
562 }
563 }`), 0o644); err != nil {
564 t.Fatal(err)
565 }
566 models, warnings := provider.LoadUserModelsWithWarnings(path)
567 if len(warnings) != 0 {
568 t.Fatalf("warnings = %v", warnings)
569 }
570 provider.SetLiveModels(nil)
571 provider.SetUserModels(models)
572 t.Cleanup(func() { provider.SetLiveModels(nil) })
573
574 r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
575 if err != nil {
576 t.Fatalf("Resolve failed: %v", err)
577 }
578 if !r.InsecureTLS {
579 t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
580 }
581}
582
583func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
584 t.Setenv("ZOT_HOME", t.TempDir())
585 provider.SetLiveModels(nil)
586 defer provider.SetLiveModels(nil)
587
588 r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
589 if err != nil {
590 t.Fatalf("Resolve failed: %v", err)
591 }
592 if r.BaseURL != "http://localhost:11434" {
593 t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
594 }
595}
596
597func TestCanonicalProviderResolvesAliases(t *testing.T) {
598 cases := map[string]string{
599 "bedrock": "amazon-bedrock",
600 "AWS-Bedrock": "amazon-bedrock",
601 " bedrock ": "amazon-bedrock",
602 "vertex": "google-vertex",
603 "gemini": "google",
604 "azure": "azure-openai-responses",
605 "copilot": "github-copilot",
606 "codex": "openai-codex",
607 "moonshot": "moonshotai",
608 "vercel": "vercel-ai-gateway",
609 "hf": "huggingface",
610 "anthropic": "anthropic", // canonical passes through
611 "amazon-bedrock": "amazon-bedrock", // already canonical
612 "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
613 "Totally-UNKNOWN": "totally-unknown",
614 "": "",
615 }
616 for in, want := range cases {
617 if got := canonicalProvider(in); got != want {
618 t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
619 }
620 }
621}
622
623func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
624 for alias, canon := range providerAliases {
625 if !isKnownProvider(canon) {
626 t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
627 }
628 }
629}
630
631func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
632 orig := http.DefaultTransport
633 t.Cleanup(func() { http.DefaultTransport = orig })
634
635 t.Setenv("ZOT_HOME", t.TempDir())
636 t.Setenv("OPENAI_API_KEY", "test-key")
637
638 resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
639 if err != nil {
640 t.Fatalf("Resolve: %v", err)
641 }
642 if resolved.InsecureTLS {
643 t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
644 }
645 assertDefaultTransportStillSecure(t)
646
647 resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
648 if err != nil {
649 t.Fatalf("Resolve: %v", err)
650 }
651 if !resolved.InsecureTLS {
652 t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
653 }
654 assertDefaultTransportStillSecure(t)
655}
656
657func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
658 orig := http.DefaultTransport
659 t.Cleanup(func() { http.DefaultTransport = orig })
660
661 t.Setenv("ZOT_HOME", t.TempDir())
662 t.Setenv("OPENAI_API_KEY", "test-key")
663 if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
664 t.Fatal(err)
665 }
666
667 resolved, err := Resolve(Args{Provider: "openai"}, false)
668 if err != nil {
669 t.Fatalf("Resolve: %v", err)
670 }
671 if resolved.InsecureTLS {
672 t.Fatal("InsecureTLS must not be set without a custom base URL")
673 }
674 assertDefaultTransportStillSecure(t)
675
676 resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
677 if err != nil {
678 t.Fatalf("Resolve: %v", err)
679 }
680 if !resolved.InsecureTLS {
681 t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
682 }
683 assertDefaultTransportStillSecure(t)
684}
685
686func TestDefaultXAIModelIsGrok45(t *testing.T) {
687 if got := defaultModelForProvider("xai"); got != "grok-4.5" {
688 t.Fatalf("default xAI model = %q, want grok-4.5", got)
689 }
690}
691
692func assertDefaultTransportStillSecure(t *testing.T) {
693 t.Helper()
694 tr, ok := http.DefaultTransport.(*http.Transport)
695 if !ok {
696 return
697 }
698 if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
699 t.Fatal("http.DefaultTransport must not be made insecure")
700 }
701}
702
703func TestBuildToolRegistryIncludesGlob(t *testing.T) {
704 cwd := t.TempDir()
705 reg := buildToolRegistry(Args{}, cwd, nil)
706 expected := []string{"read", "write", "edit", "bash", "glob"}
707 for _, name := range expected {
708 if _, ok := reg[name]; !ok {
709 t.Errorf("expected tool %q in registry, missing", name)
710 }
711 }
712
713 summaries := toolSummaries(reg, Args{})
714 var summaryNames []string
715 for _, s := range summaries {
716 summaryNames = append(summaryNames, s.Name)
717 }
718 for _, name := range expected {
719 found := false
720 for _, sn := range summaryNames {
721 if sn == name {
722 found = true
723 break
724 }
725 }
726 if !found {
727 t.Errorf("expected tool %q in toolSummaries, missing", name)
728 }
729 }
730
731 // Filtered tools
732 filtered := buildToolRegistry(Args{Tools: []string{"glob", "read"}}, cwd, nil)
733 if len(filtered) != 2 || filtered["glob"] == nil || filtered["read"] == nil {
734 t.Errorf("expected filtered registry with glob and read, got: %#v", filtered)
735 }
736}