v0.4.10 5ac0009

build_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close