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