v0.42.0 82578de

modelsync_test.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"net/http"
9	"net/http/httptest"
10	"os"
11	"path/filepath"
12	"sync"
13	"testing"
14	"time"
15
16	"github.com/bnema/zut/packages/provider"
17	"github.com/bnema/zut/packages/provider/auth"
18)
19
20func TestFilterCacheByProviderScopesRemovesMismatchedProvider(t *testing.T) {
21	cache := provider.ModelCache{
22		Models: []provider.Model{
23			{Provider: "openai-codex", ID: "account-a-only"},
24			{Provider: "openai", ID: "shared"},
25		},
26		AuthoritativeProviders: []string{"openai-codex"},
27		ProviderScopes:         map[string]string{"openai-codex": "account-a"},
28	}
29
30	filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-b"})
31	if len(filtered.Models) != 1 || filtered.Models[0].Provider != "openai" {
32		t.Fatalf("models = %+v, want only non-scoped model", filtered.Models)
33	}
34	if len(filtered.AuthoritativeProviders) != 0 {
35		t.Fatalf("authoritative providers = %v, want none", filtered.AuthoritativeProviders)
36	}
37	if len(filtered.ProviderScopes) != 0 {
38		t.Fatalf("provider scopes = %v, want none", filtered.ProviderScopes)
39	}
40}
41
42func TestFilterCacheByProviderScopesRetainsMatchingScopedProvider(t *testing.T) {
43	cache := provider.ModelCache{
44		Models:                 []provider.Model{{Provider: "openai-codex", ID: "account-a-only"}},
45		AuthoritativeProviders: []string{"openai-codex"},
46		ProviderScopes:         map[string]string{"openai-codex": "account-a"},
47	}
48
49	filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
50	if len(filtered.Models) != 1 || len(filtered.AuthoritativeProviders) != 1 || filtered.ProviderScopes["openai-codex"] != "account-a" {
51		t.Fatalf("matching scoped cache was not preserved: %+v", filtered)
52	}
53}
54
55func TestFilterCacheByProviderScopesRemovesRotatedOpenCodeGoCredential(t *testing.T) {
56	cache := provider.ModelCache{
57		Models:                 []provider.Model{{Provider: "opencode-go", ID: "old-key-model"}},
58		AuthoritativeProviders: []string{"opencode-go"},
59		ProviderScopes:         map[string]string{"opencode-go": credentialScopeForEndpoint("old-key", "")},
60	}
61
62	filtered := filterCacheByProviderScopes(cache, map[string]string{"opencode-go": credentialScopeForEndpoint("new-key", "")})
63	if len(filtered.Models) != 0 || len(filtered.AuthoritativeProviders) != 0 || len(filtered.ProviderScopes) != 0 {
64		t.Fatalf("rotated OpenCode Go cache was retained: %+v", filtered)
65	}
66}
67
68func TestLoadCachedModelsFiltersExplicitOpenCodeGoCredentialScope(t *testing.T) {
69	home := t.TempDir()
70	t.Setenv("ZUT_HOME", home)
71	t.Setenv("OPENCODE_API_KEY", "")
72
73	if err := provider.SaveCache(filepath.Join(home, "models-cache.json"), provider.ModelCache{
74		Version:                provider.ModelCacheVersion,
75		FetchedAt:              time.Now(),
76		Models:                 []provider.Model{{Provider: "opencode-go", ID: "old-key-model"}},
77		AuthoritativeProviders: []string{"opencode-go"},
78		ProviderScopes:         map[string]string{"opencode-go": credentialScopeForEndpoint("old-key", "")},
79	}); err != nil {
80		t.Fatal(err)
81	}
82	provider.SetLiveModels(nil)
83	t.Cleanup(func() { provider.SetLiveModels(nil) })
84
85	loadCachedModels(map[string]string{"opencode-go": credentialScopeForEndpoint("new-key", "")})
86	if _, err := provider.FindModel("opencode-go", "old-key-model"); err == nil {
87		t.Fatal("cached model from another OpenCode Go credential remained active")
88	}
89}
90
91func TestLoadCachedModelsClearsMismatchedActiveOpenCodeGoOverlay(t *testing.T) {
92	home := t.TempDir()
93	t.Setenv("ZUT_HOME", home)
94	provider.SetLiveModels([]provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "stale-model", BaseURL: "https://old.example/v1"}})
95	t.Cleanup(func() { provider.SetLiveModels(nil) })
96
97	loadCachedModels(map[string]string{provider.ProviderOpenCodeGo: credentialScopeForEndpoint("new-key", "https://new.example/v1")})
98	if _, err := provider.FindModel(provider.ProviderOpenCodeGo, "stale-model"); err == nil {
99		t.Fatal("mismatched OpenCode Go metadata remained active after cache filtering")
100	}
101}
102
103func TestLoadCachedModelsRejectsOldRoutingMetadata(t *testing.T) {
104	home := t.TempDir()
105	t.Setenv("ZUT_HOME", home)
106	if err := provider.SaveCache(filepath.Join(home, "models-cache.json"), provider.ModelCache{
107		Version:   provider.ModelCacheVersion - 1,
108		FetchedAt: time.Now(),
109		Models: []provider.Model{{
110			Provider: provider.ProviderOpenCodeGo,
111			ID:       "old-routing-model",
112			API:      provider.APIResponses,
113		}},
114		AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
115		ProviderScopes: map[string]string{
116			provider.ProviderOpenCodeGo: credentialScopeForEndpoint("key", ""),
117		},
118	}); err != nil {
119		t.Fatal(err)
120	}
121	provider.SetLiveModels(nil)
122	t.Cleanup(func() { provider.SetLiveModels(nil) })
123
124	loadCachedModels(map[string]string{provider.ProviderOpenCodeGo: credentialScopeForEndpoint("key", "")})
125	if _, err := provider.FindModel(provider.ProviderOpenCodeGo, "old-routing-model"); err == nil {
126		t.Fatal("old routing metadata remained active after cache load")
127	}
128}
129
130func TestFilterCacheByProviderScopesDropsLegacyAuthoritativeCodex(t *testing.T) {
131	cache := provider.ModelCache{
132		Models:                 []provider.Model{{Provider: "openai-codex", ID: "legacy"}},
133		AuthoritativeProviders: []string{"openai-codex"},
134	}
135
136	filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
137	if len(filtered.Models) != 0 || len(filtered.AuthoritativeProviders) != 0 {
138		t.Fatalf("legacy Codex cache was retained: %+v", filtered)
139	}
140}
141
142func TestRefreshModelsDiscoversOllamaWithExplicitKeyAndEndpointScope(t *testing.T) {
143	home := t.TempDir()
144	t.Setenv("ZUT_HOME", home)
145	t.Setenv("OLLAMA_API_KEY", "")
146	provider.SetLiveModels(nil)
147	t.Cleanup(func() { provider.SetLiveModels(nil) })
148
149	previous := discoverOllamaFn
150	t.Cleanup(func() { discoverOllamaFn = previous })
151	var gotKey, gotBaseURL string
152	discoverOllamaFn = func(_ context.Context, key, baseURL string) ([]provider.Model, error) {
153		gotKey, gotBaseURL = key, baseURL
154		return []provider.Model{{Provider: provider.ProviderOllama, ID: "cloud-model", Source: "live"}}, nil
155	}
156
157	baseURL := "https://private.example/v4"
158	refreshModels(provider.ProviderOllama, "explicit-key", baseURL, "")
159	if gotKey != "explicit-key" || gotBaseURL != baseURL {
160		t.Fatalf("discovery key/baseURL = %q, %q", gotKey, gotBaseURL)
161	}
162	model, err := provider.FindModel(provider.ProviderOllama, "cloud-model")
163	if err != nil {
164		t.Fatal(err)
165	}
166	if model.Provider != provider.ProviderOllama {
167		t.Fatalf("model = %+v", model)
168	}
169	cache, err := provider.LoadCache(filepath.Join(home, "models-cache.json"))
170	if err != nil {
171		t.Fatal(err)
172	}
173	wantScope := credentialScopeForEndpoint("explicit-key", baseURL)
174	if cache.ProviderScopes[provider.ProviderOllama] != wantScope {
175		t.Fatalf("ollama scope = %q, want %q", cache.ProviderScopes[provider.ProviderOllama], wantScope)
176	}
177}
178
179func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
180	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181		if r.URL.Path != "/models" {
182			http.NotFound(w, r)
183			return
184		}
185		fmt.Fprint(w, `{"data":[{"id":"loaded","status":{"value":"loaded"},"meta":{"n_ctx":32768}},{"id":"offline","status":{"value":"unloaded"}}]}`)
186	}))
187	defer server.Close()
188
189	t.Setenv("ZUT_HOME", t.TempDir())
190	t.Setenv("LLAMA_BASE_URL", "")
191	if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil {
192		t.Fatal(err)
193	}
194	provider.SetManagedModels(nil)
195	t.Cleanup(func() { provider.SetManagedModels(nil) })
196
197	if err := RefreshLlamaCPPModels(context.Background()); err != nil {
198		t.Fatal(err)
199	}
200	loaded, err := provider.FindModel(provider.LlamaCPPProviderID, "loaded")
201	if err != nil {
202		t.Fatal(err)
203	}
204	if loaded.ContextWindow != 32768 || loaded.BaseURL != server.URL+"/v1" {
205		t.Fatalf("loaded model = %+v", loaded)
206	}
207	if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil {
208		t.Fatal("unloaded model must not be selectable")
209	}
210}
211
212// TestValidateAndRepairConfig_MismatchedPair simulates the bug from a
213// stale /model switch: provider=anthropic but model=kimi-for-coding
214// (which belongs to provider=kimi). The validator should rewrite the
215// model to anthropic's default and persist.
216func TestValidateAndRepairConfig_MismatchedPair(t *testing.T) {
217	home := t.TempDir()
218	t.Setenv("ZUT_HOME", home)
219
220	must := func(c Config) {
221		t.Helper()
222		b, _ := json.Marshal(c)
223		if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
224			t.Fatal(err)
225		}
226	}
227	must(Config{Provider: "anthropic", Model: "kimi-for-coding"})
228
229	ValidateAndRepairConfig()
230
231	out, err := LoadConfig()
232	if err != nil {
233		t.Fatal(err)
234	}
235	if out.Provider != "anthropic" {
236		t.Errorf("provider not preserved: %q", out.Provider)
237	}
238	if out.Model == "kimi-for-coding" {
239		t.Errorf("model not repaired; still %q", out.Model)
240	}
241	if out.Model == "" {
242		t.Errorf("model not set; expected anthropic default")
243	}
244}
245
246// TestValidateAndRepairConfig_UnknownProvider resets to anthropic and
247// clears the model when the saved provider id isn't recognised
248// (e.g. user removed it from a previous build).
249func TestValidateAndRepairConfig_UnknownProvider(t *testing.T) {
250	home := t.TempDir()
251	t.Setenv("ZUT_HOME", home)
252
253	b, _ := json.Marshal(Config{Provider: "made-up-provider", Model: "some-model"})
254	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
255
256	ValidateAndRepairConfig()
257
258	out, _ := LoadConfig()
259	if out.Provider != "anthropic" {
260		t.Errorf("provider not reset: %q", out.Provider)
261	}
262	if out.Model != "" {
263		t.Errorf("model not cleared: %q", out.Model)
264	}
265}
266
267// TestValidateAndRepairConfig_UnknownModel keeps the provider but
268// snaps the model to that provider's default when the saved id is no
269// longer in the catalog.
270func TestValidateAndRepairConfig_UnknownModel(t *testing.T) {
271	home := t.TempDir()
272	t.Setenv("ZUT_HOME", home)
273
274	b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-deleted-model"})
275	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
276
277	ValidateAndRepairConfig()
278
279	out, _ := LoadConfig()
280	if out.Provider != "anthropic" {
281		t.Errorf("provider changed: %q", out.Provider)
282	}
283	if out.Model == "" || out.Model == "claude-deleted-model" {
284		t.Errorf("model not repaired: %q", out.Model)
285	}
286}
287
288func TestValidateAndRepairConfig_OpenCodeGoPreservesDynamicModel(t *testing.T) {
289	home := t.TempDir()
290	t.Setenv("ZUT_HOME", home)
291
292	const model = "muse-spark-1.2-contributor"
293	b, _ := json.Marshal(Config{Provider: "opencode-go", Model: model})
294	if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
295		t.Fatal(err)
296	}
297
298	ValidateAndRepairConfig()
299
300	out, err := LoadConfig()
301	if err != nil {
302		t.Fatal(err)
303	}
304	if out.Provider != "opencode-go" || out.Model != model {
305		t.Fatalf("dynamic OpenCode Go config changed: provider=%q model=%q", out.Provider, out.Model)
306	}
307}
308
309func TestResolveAllowsUnknownOpenCodeGoModelBeforeDiscovery(t *testing.T) {
310	t.Setenv("ZUT_HOME", t.TempDir())
311	t.Setenv("OPENCODE_API_KEY", "")
312
313	const model = "muse-spark-1.2-contributor"
314	resolved, err := Resolve(Args{Provider: "opencode-go", Model: model}, false)
315	if err != nil {
316		t.Fatal(err)
317	}
318	if resolved.Provider != "opencode-go" || resolved.Model != model {
319		t.Fatalf("resolved dynamic model = provider=%q model=%q", resolved.Provider, resolved.Model)
320	}
321	if resolved.ContextWindow != 128000 || resolved.MaxOutput != 16384 {
322		t.Fatalf("bootstrap model limits = context %d output %d", resolved.ContextWindow, resolved.MaxOutput)
323	}
324	if resolved.ModelCatalogSnapshot() != nil {
325		t.Fatal("normal Resolve captured a private OpenCode Go catalog snapshot")
326	}
327}
328
329func TestResolveRejectsUnknownOpenCodeGoModelAfterDiscovery(t *testing.T) {
330	preserveProviderCatalog(t)
331	t.Setenv("ZUT_HOME", t.TempDir())
332	t.Setenv("OPENCODE_API_KEY", "")
333	provider.SetLiveModelsForProviders([]provider.Model{{
334		Provider:      provider.ProviderOpenCodeGo,
335		ID:            "served-model",
336		ContextWindow: 128000,
337		MaxOutput:     16384,
338	}}, []string{provider.ProviderOpenCodeGo})
339	provider.SetUserModels(nil)
340
341	resolved, err := Resolve(Args{Provider: provider.ProviderOpenCodeGo, Model: "removed-model"}, false)
342	if err != nil {
343		t.Fatal(err)
344	}
345	if resolved.Model != "served-model" {
346		t.Fatalf("unknown model resolved to %q, want served-model", resolved.Model)
347	}
348}
349
350func TestResolveFallbackUsesCapturedOpenCodeGoCatalog(t *testing.T) {
351	preserveProviderCatalog(t)
352	t.Setenv("ZUT_HOME", t.TempDir())
353	t.Setenv("OPENCODE_API_KEY", "")
354
355	const modelID = "shared-scoped-model"
356	provider.SetLiveModelsForProviders([]provider.Model{{
357		Provider:      provider.ProviderOpenCodeGo,
358		ID:            modelID,
359		ContextWindow: 111000,
360		MaxOutput:     11000,
361		BaseURL:       "https://account-a.example/v1",
362	}}, []string{provider.ProviderOpenCodeGo})
363	args := Args{
364		Provider:                  provider.ProviderOpenCodeGo,
365		Model:                     "removed-from-account-a",
366		APIKey:                    "account-a-key",
367		modelCatalog:              provider.ModelsForProvider(provider.ProviderOpenCodeGo),
368		modelCatalogAuthoritative: true,
369		NoTools:                   true,
370	}
371
372	// A later runtime replaces the process-global catalog with another
373	// credential's endpoint. The first runtime must resolve its fallback from
374	// the snapshot it captured before that replacement.
375	provider.SetLiveModelsForProviders([]provider.Model{{
376		Provider:      provider.ProviderOpenCodeGo,
377		ID:            modelID,
378		ContextWindow: 222000,
379		MaxOutput:     22000,
380		BaseURL:       "https://account-b.example/v1",
381	}}, []string{provider.ProviderOpenCodeGo})
382
383	resolved, err := Resolve(args, false)
384	if err != nil {
385		t.Fatal(err)
386	}
387	if resolved.Model != modelID || resolved.ContextWindow != 111000 || resolved.MaxOutput != 11000 || resolved.BaseURL != "https://account-a.example/v1" {
388		t.Fatalf("fallback resolved from the wrong catalog: provider=%q model=%q context=%d output=%d base=%q", resolved.Provider, resolved.Model, resolved.ContextWindow, resolved.MaxOutput, resolved.BaseURL)
389	}
390}
391
392func TestValidateAndRepairConfigPreservesPaddedDynamicModel(t *testing.T) {
393	home := t.TempDir()
394	t.Setenv("ZUT_HOME", home)
395	const model = "muse-spark-1.2-contributor"
396	if err := os.WriteFile(filepath.Join(home, "config.json"), []byte(`{"provider":"opencode-go","model":" `+model+` "}`), 0o644); err != nil {
397		t.Fatal(err)
398	}
399
400	ValidateAndRepairConfig()
401
402	cfg, err := LoadConfig()
403	if err != nil {
404		t.Fatal(err)
405	}
406	if cfg.Provider != "opencode-go" || cfg.Model != " "+model+" " {
407		t.Fatalf("padded dynamic model was repaired: provider=%q model=%q", cfg.Provider, cfg.Model)
408	}
409	resolved, err := Resolve(Args{}, false)
410	if err != nil {
411		t.Fatal(err)
412	}
413	if resolved.Model != model {
414		t.Fatalf("resolved model = %q, want trimmed dynamic id", resolved.Model)
415	}
416}
417
418func TestResolveTrimsModelID(t *testing.T) {
419	t.Setenv("ZUT_HOME", t.TempDir())
420
421	resolved, err := Resolve(Args{Provider: "openai", Model: " gpt-5.6-sol "}, false)
422	if err != nil {
423		t.Fatal(err)
424	}
425	if resolved.Model != "gpt-5.6-sol" {
426		t.Fatalf("resolved model = %q, want trimmed id", resolved.Model)
427	}
428}
429
430func TestResolveTreatsWhitespaceConfigModelAsMissing(t *testing.T) {
431	home := t.TempDir()
432	t.Setenv("ZUT_HOME", home)
433	if err := os.WriteFile(filepath.Join(home, "config.json"), []byte(`{"provider":"openai","model":" \t "}`), 0o644); err != nil {
434		t.Fatal(err)
435	}
436
437	resolved, err := Resolve(Args{}, false)
438	if err != nil {
439		t.Fatal(err)
440	}
441	if resolved.Provider != "openai" || resolved.Model != defaultModelForProvider("openai") {
442		t.Fatalf("whitespace config model resolved to provider=%q model=%q", resolved.Provider, resolved.Model)
443	}
444}
445
446func TestCurrentModelProviderScopesHashesOpenCodeGoCredential(t *testing.T) {
447	t.Setenv("ZUT_HOME", t.TempDir())
448	const key = "synthetic-opencode-key"
449	t.Setenv("OPENCODE_API_KEY", key)
450
451	scopes := currentModelProviderScopes()
452	if got, want := scopes["opencode-go"], credentialScopeForEndpoint(key, ""); got != want {
453		t.Fatalf("OpenCode Go scope = %q, want %q", got, want)
454	}
455	if got, other := scopes["opencode-go"], credentialScopeForEndpoint(key, "https://proxy.example/v1"); got == other {
456		t.Fatal("OpenCode Go cache scope did not change with the endpoint")
457	}
458	if scopes["opencode-go"] == key {
459		t.Fatal("OpenCode Go credential was stored directly in its cache scope")
460	}
461}
462
463func TestSynchronousOpenCodeGoAPIKeyHonorsCancellation(t *testing.T) {
464	preserveProviderCatalog(t)
465	home := t.TempDir()
466	t.Setenv("ZUT_HOME", home)
467	t.Setenv("OPENCODE_API_KEY", "")
468	t.Setenv("ZUT_AGENT_API_KEY_COMMAND_HELPER", "1")
469	marker := filepath.Join(home, "canceled-command-ran")
470	credentials := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
471		provider.ProviderOpenCodeGo: {
472			APIKeyCommand: &auth.APIKeyCommand{
473				Program: os.Args[0],
474				Args:    []string{"-test.run=^TestAgentAPIKeyCommandHelperProcess$", "--", marker},
475			},
476		},
477	}}
478	encoded, err := json.Marshal(credentials)
479	if err != nil {
480		t.Fatal(err)
481	}
482	if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
483		t.Fatal(err)
484	}
485
486	previous := discoverOpenCodeGoFn
487	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
488	discoverOpenCodeGoFn = func(context.Context, string, string) ([]provider.Model, error) {
489		t.Fatal("canceled catalog preparation reached discovery")
490		return nil, nil
491	}
492	ctx, cancel := context.WithCancel(context.Background())
493	cancel()
494	if _, err := ResolveSDK(ctx, Args{Provider: provider.ProviderOpenCodeGo}); !errors.Is(err, context.Canceled) {
495		t.Fatalf("ResolveSDK error = %v, want context.Canceled", err)
496	}
497
498	ctx, cancel = context.WithCancel(context.Background())
499	cancel()
500	PrepareRuntimeCatalog(ctx, true, provider.ProviderOpenCodeGo, "", "", nil)
501	if _, err := os.Stat(marker); !os.IsNotExist(err) {
502		t.Fatalf("canceled credential command marker = %v", err)
503	}
504}
505
506func TestPrepareRuntimeCatalogRefreshHonorsCancellation(t *testing.T) {
507	preserveProviderCatalog(t)
508	t.Setenv("ZUT_HOME", t.TempDir())
509	previous := discoverOpenCodeGoFn
510	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
511	called := make(chan struct{})
512	release := make(chan struct{})
513	discoverOpenCodeGoFn = func(ctx context.Context, _, _ string) ([]provider.Model, error) {
514		close(called)
515		select {
516		case <-ctx.Done():
517			return nil, ctx.Err()
518		case <-release:
519			return nil, fmt.Errorf("released discovery")
520		}
521	}
522
523	ctx, cancel := context.WithCancel(context.Background())
524	done := make(chan struct{})
525	go func() {
526		PrepareRuntimeCatalog(ctx, true, provider.ProviderOpenCodeGo, "synthetic-key", "", nil)
527		close(done)
528	}()
529
530	select {
531	case <-called:
532		cancel()
533	case <-time.After(2 * time.Second):
534		cancel()
535		close(release)
536		<-done
537		t.Fatal("catalog discovery did not start")
538	}
539	select {
540	case <-done:
541	case <-time.After(1 * time.Second):
542		close(release)
543		<-done
544		t.Fatal("canceled catalog refresh did not exit promptly")
545	}
546}
547
548func TestSynchronousRefreshExecutesOpenCodeGoAPIKeyCommand(t *testing.T) {
549	preserveProviderCatalog(t)
550	home := t.TempDir()
551	t.Setenv("ZUT_HOME", home)
552	t.Setenv("OPENCODE_API_KEY", "")
553	t.Setenv("ZUT_AGENT_API_KEY_COMMAND_HELPER", "1")
554	marker := filepath.Join(home, "command-ran")
555	credentials := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
556		provider.ProviderOpenCodeGo: {
557			APIKeyCommand: &auth.APIKeyCommand{
558				Program: os.Args[0],
559				Args:    []string{"-test.run=^TestAgentAPIKeyCommandHelperProcess$", "--", marker},
560			},
561		},
562	}}
563	encoded, err := json.Marshal(credentials)
564	if err != nil {
565		t.Fatal(err)
566	}
567	if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
568		t.Fatal(err)
569	}
570	previous := discoverOpenCodeGoFn
571	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
572	var gotKey string
573	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
574		gotKey = apiKey
575		if endpoint != "" {
576			t.Fatalf("discovery endpoint = %q, want default", endpoint)
577		}
578		return []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "command-model"}}, nil
579	}
580
581	refreshModelsWithMode(provider.ProviderOpenCodeGo, "", "", provider.ProviderOpenCodeGo, apiKeyCommandExecute)
582	if gotKey != "resolved-secret" {
583		t.Fatalf("discovery key = %q, want command result", gotKey)
584	}
585	if _, err := os.Stat(marker); err != nil {
586		t.Fatalf("credential command marker: %v", err)
587	}
588	cache, err := provider.LoadCache(ModelCachePath())
589	if err != nil {
590		t.Fatal(err)
591	}
592	if cache.ProviderScopes[provider.ProviderOpenCodeGo] != credentialScopeForEndpoint(gotKey, "") {
593		t.Fatalf("cache scope = %q, want command credential scope", cache.ProviderScopes[provider.ProviderOpenCodeGo])
594	}
595}
596
597func TestResolveSDKRefreshReusesResolvedCommandCredential(t *testing.T) {
598	preserveProviderCatalog(t)
599	home := t.TempDir()
600	t.Setenv("ZUT_HOME", home)
601	t.Setenv("OPENCODE_API_KEY", "")
602	t.Setenv("ZUT_AGENT_API_KEY_COMMAND_HELPER", "1")
603	marker := filepath.Join(home, "sdk-command-ran")
604	credentials := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
605		provider.ProviderOpenCodeGo: {
606			APIKeyCommand: &auth.APIKeyCommand{
607				Program: os.Args[0],
608				Args:    []string{"-test.run=^TestAgentAPIKeyCommandHelperProcess$", "--", marker},
609			},
610		},
611	}}
612	encoded, err := json.Marshal(credentials)
613	if err != nil {
614		t.Fatal(err)
615	}
616	if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
617		t.Fatal(err)
618	}
619	previous := discoverOpenCodeGoFn
620	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
621	called := make(chan string, 1)
622	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
623		called <- apiKey + "\x00" + endpoint
624		return nil, fmt.Errorf("synthetic discovery stop")
625	}
626
627	if _, err := ResolveSDK(context.Background(), Args{Provider: provider.ProviderOpenCodeGo, BaseURL: "https://proxy.example/v1"}); err != nil {
628		t.Fatal(err)
629	}
630	select {
631	case got := <-called:
632		if got != "resolved-secret\x00https://proxy.example/v1" {
633			t.Fatalf("discovery inputs = %q, want resolved credential and endpoint", got)
634		}
635	case <-time.After(2 * time.Second):
636		t.Fatal("SDK OpenCode Go discovery was not invoked")
637	}
638}
639
640func TestRefreshModelsUsesExplicitOpenCodeGoEndpoint(t *testing.T) {
641	home := t.TempDir()
642	t.Setenv("ZUT_HOME", home)
643	provider.SetLiveModels(nil)
644	t.Cleanup(func() { provider.SetLiveModels(nil) })
645
646	const (
647		key     = "synthetic-proxy-key"
648		baseURL = "https://proxy.example/v1"
649		modelID = "proxy-model"
650	)
651	previous := discoverOpenCodeGoFn
652	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
653	var gotKey, gotBaseURL string
654	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
655		gotKey, gotBaseURL = apiKey, endpoint
656		return []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: modelID, BaseURL: endpoint, Source: "live"}}, nil
657	}
658
659	refreshModels(provider.ProviderOpenCodeGo, key, baseURL, provider.ProviderOpenCodeGo)
660	if gotKey != key || gotBaseURL != baseURL {
661		t.Fatalf("discovery received key=%q endpoint=%q, want key=%q endpoint=%q", gotKey, gotBaseURL, key, baseURL)
662	}
663	cache, err := provider.LoadCache(ModelCachePath())
664	if err != nil {
665		t.Fatal(err)
666	}
667	if cache.ProviderScopes[provider.ProviderOpenCodeGo] != credentialScopeForEndpoint(key, baseURL) {
668		t.Fatalf("cache scope = %q, want endpoint-scoped credential", cache.ProviderScopes[provider.ProviderOpenCodeGo])
669	}
670}
671
672func TestRefreshModelsReplacesUnchangedOpenCodeGoScope(t *testing.T) {
673	home := t.TempDir()
674	t.Setenv("ZUT_HOME", home)
675	provider.SetLiveModels(nil)
676	t.Cleanup(func() { provider.SetLiveModels(nil) })
677
678	const (
679		oldKey  = "synthetic-old-key"
680		oldBase = "https://old.example/v1"
681		newKey  = "synthetic-new-key"
682		newBase = "https://new.example/v1"
683	)
684	if err := provider.SaveCache(ModelCachePath(), provider.ModelCache{
685		Version:                2,
686		FetchedAt:              time.Now().Add(-48 * time.Hour),
687		Models:                 []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "old-model", BaseURL: oldBase}},
688		AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
689		ProviderScopes:         map[string]string{provider.ProviderOpenCodeGo: credentialScope(oldKey)},
690	}); err != nil {
691		t.Fatal(err)
692	}
693	previous := discoverOpenCodeGoFn
694	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
695	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
696		if apiKey != newKey || endpoint != newBase {
697			t.Fatalf("discovery inputs = key %q endpoint %q", apiKey, endpoint)
698		}
699		return []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "new-model", BaseURL: endpoint, Source: "live"}}, nil
700	}
701
702	refreshModels(provider.ProviderOpenCodeGo, newKey, newBase, provider.ProviderOpenCodeGo)
703	cache, err := provider.LoadCache(ModelCachePath())
704	if err != nil {
705		t.Fatal(err)
706	}
707	if cache.ProviderScopes[provider.ProviderOpenCodeGo] != credentialScopeForEndpoint(newKey, newBase) {
708		t.Fatalf("scope = %q, want new endpoint-scoped scope", cache.ProviderScopes[provider.ProviderOpenCodeGo])
709	}
710	if _, ok := findCachedModel(cache.Models, provider.ProviderOpenCodeGo, "new-model"); !ok {
711		t.Fatalf("new OpenCode Go model missing: %+v", cache.Models)
712	}
713	if cache.IsFresh() {
714		t.Fatal("partial upgrade of a legacy cache must still require a full refresh")
715	}
716}
717
718func TestEffectiveCatalogBaseURLUsesProviderLevelOpenCodeGoEndpoint(t *testing.T) {
719	home := t.TempDir()
720	t.Setenv("ZUT_HOME", home)
721	path := filepath.Join(home, "models.json")
722	const baseURL = "https://provider-proxy.example/v1"
723	if err := os.WriteFile(path, []byte(`{"providers":{"opencode-go":{"baseUrl":"`+baseURL+`","models":[]}}}`), 0o600); err != nil {
724		t.Fatal(err)
725	}
726	models := LoadUserModels()
727	t.Cleanup(func() {
728		_ = os.WriteFile(path, []byte(`{"providers":{}}`), 0o600)
729		LoadUserModels()
730		provider.SetUserModels(nil)
731	})
732
733	if got := effectiveCatalogBaseURL(provider.ProviderOpenCodeGo, "new-live-model", "", models); got != baseURL {
734		t.Fatalf("provider-level endpoint = %q, want %q", got, baseURL)
735	}
736	manifest := ZutfileManifest{}
737	manifest.Model.Preferred = []string{"new-live-model"}
738	gotProvider, gotModel := zutfileCatalogSelection(Args{}, manifest, models)
739	if gotProvider != provider.ProviderOpenCodeGo || gotModel != "new-live-model" {
740		t.Fatalf("catalog selection = provider=%q model=%q, want %q/new-live-model", gotProvider, gotModel, provider.ProviderOpenCodeGo)
741	}
742}
743
744func TestZutfilePreferredDynamicModelRefreshesBeforeRequirements(t *testing.T) {
745	t.Setenv("ZUT_HOME", t.TempDir())
746	provider.SetLiveModels(nil)
747	provider.SetUserModels(nil)
748	t.Cleanup(func() {
749		provider.SetLiveModels(nil)
750		provider.SetUserModels(nil)
751	})
752	previous := discoverOpenCodeGoFn
753	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
754	const (
755		key     = "synthetic-zutfile-key"
756		modelID = "new-preferred-model"
757	)
758	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
759		if apiKey != key || endpoint != "" {
760			t.Fatalf("discovery inputs = key %q endpoint %q", apiKey, endpoint)
761		}
762		return []provider.Model{{
763			Provider:      provider.ProviderOpenCodeGo,
764			ID:            modelID,
765			ContextWindow: 256000,
766			MaxOutput:     32000,
767		}}, nil
768	}
769	manifest := ZutfileManifest{}
770	manifest.Model.MinContext = 200000
771	manifest.Model.Preferred = []string{modelID}
772	catalogProvider, catalogModel := zutfileCatalogSelection(Args{Provider: provider.ProviderOpenCodeGo}, manifest, nil)
773	PrepareRuntimeCatalog(context.Background(), true, catalogProvider, key, "", nil, catalogModel)
774
775	args := Args{}
776	if err := applyZutfileModelRequirements(&args, manifest); err != nil {
777		t.Fatal(err)
778	}
779	if args.Provider != provider.ProviderOpenCodeGo || args.Model != modelID {
780		t.Fatalf("selected model = provider=%q model=%q, want %q/%q", args.Provider, args.Model, provider.ProviderOpenCodeGo, modelID)
781	}
782}
783
784func TestResolveSDKUsesUserModelEndpointForDiscovery(t *testing.T) {
785	home := t.TempDir()
786	t.Setenv("ZUT_HOME", home)
787	t.Setenv("OPENCODE_API_KEY", "")
788	provider.SetLiveModels(nil)
789	provider.SetUserModels(nil)
790	t.Cleanup(func() {
791		provider.SetLiveModels(nil)
792		provider.SetUserModels(nil)
793	})
794
795	const (
796		key     = "synthetic-proxy-key"
797		baseURL = "https://model-proxy.example/v1"
798		modelID = "proxy-model"
799	)
800	if err := os.WriteFile(UserModelsPath(), []byte(`{"providers":{"opencode-go":{"models":[{"id":"`+modelID+`","baseUrl":"`+baseURL+`"}]}}}`), 0o600); err != nil {
801		t.Fatal(err)
802	}
803	previous := discoverOpenCodeGoFn
804	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
805	called := make(chan struct{}, 1)
806	var gotKey, gotBaseURL string
807	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
808		gotKey, gotBaseURL = apiKey, endpoint
809		called <- struct{}{}
810		return nil, fmt.Errorf("synthetic discovery stop")
811	}
812
813	resolved, err := ResolveSDK(context.Background(), Args{Provider: provider.ProviderOpenCodeGo, Model: modelID, APIKey: key})
814	if err != nil {
815		t.Fatal(err)
816	}
817	if resolved.BaseURL != baseURL {
818		t.Fatalf("resolved base URL = %q, want user model endpoint %q", resolved.BaseURL, baseURL)
819	}
820	select {
821	case <-called:
822		if gotKey != key || gotBaseURL != baseURL {
823			t.Fatalf("discovery received key=%q endpoint=%q, want key=%q endpoint=%q", gotKey, gotBaseURL, key, baseURL)
824		}
825	case <-time.After(2 * time.Second):
826		t.Fatal("OpenCode Go discovery was not invoked")
827	}
828}
829
830func TestResolveSDKDoesNotInheritLiveEndpointForMetadataOnlyUserModel(t *testing.T) {
831	home := t.TempDir()
832	t.Setenv("ZUT_HOME", home)
833	t.Setenv("OPENCODE_API_KEY", "")
834	provider.SetLiveModels([]provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "metadata-only", BaseURL: "https://old.example/v1", Source: "live"}})
835	provider.SetUserModels(nil)
836	t.Cleanup(func() {
837		provider.SetLiveModels(nil)
838		provider.SetUserModels(nil)
839	})
840	if err := os.WriteFile(UserModelsPath(), []byte(`{"providers":{"opencode-go":{"models":[{"id":"metadata-only","contextWindow":90000}]}}}`), 0o600); err != nil {
841		t.Fatal(err)
842	}
843
844	const key = "synthetic-metadata-key"
845	previous := discoverOpenCodeGoFn
846	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
847	called := make(chan string, 1)
848	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
849		if apiKey != key {
850			t.Errorf("discovery key = %q, want %q", apiKey, key)
851		}
852		called <- endpoint
853		return nil, fmt.Errorf("synthetic discovery stop")
854	}
855
856	resolved, err := ResolveSDK(context.Background(), Args{Provider: provider.ProviderOpenCodeGo, Model: "metadata-only", APIKey: key})
857	if err != nil {
858		t.Fatal(err)
859	}
860	if resolved.BaseURL != "" {
861		t.Fatalf("metadata-only user model inherited base URL %q", resolved.BaseURL)
862	}
863	select {
864	case endpoint := <-called:
865		if endpoint != "" {
866			t.Fatalf("discovery endpoint = %q, want default endpoint", endpoint)
867		}
868	case <-time.After(2 * time.Second):
869		t.Fatal("OpenCode Go discovery was not invoked")
870	}
871}
872
873func TestResolveSDKFollowsActiveModelProfile(t *testing.T) {
874	const (
875		key     = "synthetic-profile-key"
876		baseURL = "https://profile-proxy.example/v1"
877	)
878	for _, tc := range []struct {
879		name            string
880		topProvider     string
881		topModel        string
882		profileProvider string
883		profileModel    string
884		wantContext     int
885		wantCachedModel bool
886	}{
887		{
888			name:            "profile switches to OpenCode Go",
889			topProvider:     provider.ProviderAnthropic,
890			topModel:        "claude-sonnet-4-5",
891			profileProvider: provider.ProviderOpenCodeGo,
892			profileModel:    "profile-opencode-model",
893			wantContext:     654321,
894			wantCachedModel: true,
895		},
896		{
897			name:            "profile switches away from OpenCode Go",
898			topProvider:     provider.ProviderOpenCodeGo,
899			topModel:        "profile-opencode-model",
900			profileProvider: provider.ProviderAnthropic,
901			profileModel:    "claude-sonnet-4-5",
902			wantCachedModel: false,
903		},
904	} {
905		t.Run(tc.name, func(t *testing.T) {
906			home := t.TempDir()
907			t.Setenv("ZUT_HOME", home)
908			provider.SetLiveModels(nil)
909			provider.SetUserModels(nil)
910			t.Cleanup(func() {
911				provider.SetLiveModels(nil)
912				provider.SetUserModels(nil)
913			})
914
915			if err := SaveConfig(Config{
916				Provider:           tc.topProvider,
917				Model:              tc.topModel,
918				ActiveModelProfile: 1,
919				QuickModelShortcuts: []QuickModelShortcut{{
920					Provider: tc.profileProvider,
921					Model:    tc.profileModel,
922				}},
923			}); err != nil {
924				t.Fatal(err)
925			}
926			if tc.wantCachedModel {
927				if err := provider.SaveCache(filepath.Join(home, "models-cache.json"), provider.ModelCache{
928					Version:                provider.ModelCacheVersion,
929					FetchedAt:              time.Now().UTC(),
930					Models:                 []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: tc.profileModel, ContextWindow: tc.wantContext, MaxOutput: 8192, BaseURL: baseURL}},
931					AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
932					ProviderScopes:         map[string]string{provider.ProviderOpenCodeGo: credentialScopeForEndpoint(key, baseURL)},
933				}); err != nil {
934					t.Fatal(err)
935				}
936			}
937
938			modelCatalogMu.Lock()
939			_, _ = prepareRuntimeCatalog("", key, baseURL, nil, "")
940			resolved, err := Resolve(Args{APIKey: key, BaseURL: baseURL}, false)
941			modelCatalogMu.Unlock()
942			if err != nil {
943				t.Fatal(err)
944			}
945			if resolved.Provider != tc.profileProvider || resolved.Model != tc.profileModel {
946				t.Fatalf("resolved profile = provider=%q model=%q, want provider=%q model=%q", resolved.Provider, resolved.Model, tc.profileProvider, tc.profileModel)
947			}
948			if tc.wantCachedModel && resolved.ContextWindow != tc.wantContext {
949				t.Fatalf("cached OpenCode Go context = %d, want %d", resolved.ContextWindow, tc.wantContext)
950			}
951		})
952	}
953}
954
955func TestEligibleProviderDiscoveryIncompleteIncludesUnscopedProviders(t *testing.T) {
956	eligible := map[string]struct{}{
957		provider.ProviderOpenAI:     {},
958		provider.ProviderOpenCodeGo: {},
959	}
960	discovered := map[string][]provider.Model{
961		provider.ProviderOpenCodeGo: {{Provider: provider.ProviderOpenCodeGo, ID: "served"}},
962	}
963	if !eligibleProviderDiscoveryIncomplete(eligible, discovered) {
964		t.Fatal("missing unscoped provider discovery was not detected")
965	}
966	discovered[provider.ProviderOpenAI] = nil
967	if eligibleProviderDiscoveryIncomplete(eligible, discovered) {
968		t.Fatal("providers present in discovery were treated as missing")
969	}
970}
971
972func TestPartialOpenCodeGoRefreshPreservesFullCatalogFreshness(t *testing.T) {
973	home := t.TempDir()
974	t.Setenv("ZUT_HOME", home)
975	provider.SetLiveModels(nil)
976	t.Cleanup(func() { provider.SetLiveModels(nil) })
977
978	const (
979		key     = "synthetic-partial-key"
980		baseURL = "https://partial-proxy.example/v1"
981	)
982	staleAt := time.Now().Add(-48 * time.Hour)
983	if err := provider.SaveCache(ModelCachePath(), provider.ModelCache{
984		Version:   provider.ModelCacheVersion,
985		FetchedAt: staleAt,
986		Models: []provider.Model{
987			{Provider: provider.ProviderOpenAI, ID: "cached-openai"},
988			{Provider: provider.ProviderOpenCodeGo, ID: "old-opencode"},
989		},
990		AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
991		ProviderScopes:         map[string]string{provider.ProviderOpenCodeGo: credentialScopeForEndpoint(key, baseURL)},
992	}); err != nil {
993		t.Fatal(err)
994	}
995	previous := discoverOpenCodeGoFn
996	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
997	calls := 0
998	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
999		calls++
1000		if apiKey != key || endpoint != baseURL {
1001			t.Fatalf("discovery inputs = key %q endpoint %q", apiKey, endpoint)
1002		}
1003		return []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "new-opencode", Source: "live"}}, nil
1004	}
1005
1006	refreshModels(provider.ProviderOpenCodeGo, key, baseURL, provider.ProviderOpenCodeGo)
1007	partial, err := provider.LoadCache(ModelCachePath())
1008	if err != nil {
1009		t.Fatal(err)
1010	}
1011	if partial.IsFresh() {
1012		t.Fatal("partial OpenCode Go refresh made a stale full catalog fresh")
1013	}
1014	if _, ok := findCachedModel(partial.Models, provider.ProviderOpenAI, "cached-openai"); !ok {
1015		t.Fatalf("partial refresh dropped unrelated cached model: %+v", partial.Models)
1016	}
1017
1018	refreshModels(provider.ProviderOpenCodeGo, key, baseURL, "")
1019	if calls != 2 {
1020		t.Fatalf("full refresh calls = %d, want a refresh after partial stale cache", calls)
1021	}
1022}
1023
1024func findCachedModel(models []provider.Model, providerName, id string) (provider.Model, bool) {
1025	for _, model := range models {
1026		if model.Provider == providerName && model.ID == id {
1027			return model, true
1028		}
1029	}
1030	return provider.Model{}, false
1031}
1032
1033func TestNeedsOpenCodeGoRefreshWhenCredentialAppearsAfterCache(t *testing.T) {
1034	t.Setenv("ZUT_HOME", t.TempDir())
1035	t.Setenv("OPENCODE_API_KEY", "synthetic-key")
1036
1037	freshWithoutOpenCode := provider.ModelCache{
1038		Version:   provider.ModelCacheVersion,
1039		FetchedAt: time.Now(),
1040		Models:    []provider.Model{{Provider: "openai", ID: "gpt-5"}},
1041	}
1042	if !needsOpenCodeGoRefresh(freshWithoutOpenCode) {
1043		t.Fatal("fresh cache without OpenCode Go should refresh after credential appears")
1044	}
1045
1046	freshWithOpenCode := freshWithoutOpenCode
1047	freshWithOpenCode.Models = append(freshWithOpenCode.Models, provider.Model{Provider: "opencode-go", ID: "served"})
1048	if needsOpenCodeGoRefresh(freshWithOpenCode) {
1049		t.Fatal("cache with OpenCode Go models should remain fresh")
1050	}
1051
1052	t.Setenv("OPENCODE_API_KEY", "")
1053	if needsOpenCodeGoRefresh(freshWithoutOpenCode) {
1054		t.Fatal("cache without an OpenCode Go credential should not refresh")
1055	}
1056}
1057
1058func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *testing.T) {
1059	home := t.TempDir()
1060	t.Setenv("ZUT_HOME", home)
1061
1062	b, _ := json.Marshal(Config{Provider: "openai-codex", Model: "gpt-5.5"})
1063	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
1064
1065	ValidateAndRepairConfig()
1066
1067	out, _ := LoadConfig()
1068	if out.Provider != "openai-codex" {
1069		t.Errorf("provider mutated: %q", out.Provider)
1070	}
1071	if out.Model != "gpt-5.5" {
1072		t.Errorf("model mutated: %q", out.Model)
1073	}
1074}
1075
1076func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {
1077	home := t.TempDir()
1078	t.Setenv("ZUT_HOME", home)
1079
1080	want := "deepseek/deepseek-v4-flash"
1081	b, _ := json.Marshal(Config{Provider: "openrouter", Model: want})
1082	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
1083
1084	ValidateAndRepairConfig()
1085
1086	out, _ := LoadConfig()
1087	if out.Provider != "openrouter" {
1088		t.Errorf("provider mutated: %q", out.Provider)
1089	}
1090	if out.Model != want {
1091		t.Errorf("routed model id mutated: got %q, want %q", out.Model, want)
1092	}
1093}
1094
1095func TestValidateAndRepairConfig_GatewayPlainUnknownModelStillRepairs(t *testing.T) {
1096	home := t.TempDir()
1097	t.Setenv("ZUT_HOME", home)
1098
1099	b, _ := json.Marshal(Config{Provider: "openrouter", Model: "not-a-routed-model"})
1100	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
1101
1102	ValidateAndRepairConfig()
1103
1104	out, _ := LoadConfig()
1105	if out.Provider != "openrouter" {
1106		t.Errorf("provider mutated: %q", out.Provider)
1107	}
1108	if out.Model == "not-a-routed-model" || out.Model == "" {
1109		t.Errorf("plain unknown gateway model was not repaired: %q", out.Model)
1110	}
1111}
1112
1113// TestValidateAndRepairConfig_HappyPath leaves a valid config alone.
1114func TestValidateAndRepairConfig_HappyPath(t *testing.T) {
1115	home := t.TempDir()
1116	t.Setenv("ZUT_HOME", home)
1117
1118	b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-sonnet-4-5"})
1119	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
1120
1121	ValidateAndRepairConfig()
1122
1123	out, _ := LoadConfig()
1124	if out.Provider != "anthropic" {
1125		t.Errorf("provider mutated: %q", out.Provider)
1126	}
1127	if out.Model != "claude-sonnet-4-5" {
1128		t.Errorf("model mutated: %q", out.Model)
1129	}
1130}
1131
1132func TestScopedRefreshPreservesActiveOpenCodeGoScopeOnUnrelatedWrite(t *testing.T) {
1133	home := t.TempDir()
1134	t.Setenv("ZUT_HOME", home)
1135	t.Setenv("OPENCODE_API_KEY", "synthetic-scope-key")
1136	provider.SetLiveModels(nil)
1137	t.Cleanup(func() { provider.SetLiveModels(nil) })
1138
1139	origDiscover := discoverOpenCodeGoFn
1140	started := make(chan struct{})
1141	release := make(chan struct{})
1142	var once sync.Once
1143	discoverOpenCodeGoFn = func(ctx context.Context, apiKey, baseURL string) ([]provider.Model, error) {
1144		once.Do(func() { close(started) })
1145		select {
1146		case <-release:
1147		case <-ctx.Done():
1148		}
1149		return []provider.Model{{
1150			Provider: provider.ProviderOpenCodeGo,
1151			ID:       "go-scoped-model",
1152			Source:   "live",
1153		}}, nil
1154	}
1155	t.Cleanup(func() { discoverOpenCodeGoFn = origDiscover })
1156
1157	done := make(chan struct{})
1158	go func() {
1159		defer close(done)
1160		refreshModelsWithContext(context.Background(), "", "", "", provider.ProviderOpenCodeGo, apiKeyCommandSkip)
1161	}()
1162	<-started
1163	// A concurrent runtime with an unrelated credential scope replaces the
1164	// cache while this refresh's OpenCode Go discovery is in flight. It
1165	// carries no OpenCode Go scope of its own, so OpenCode Go is this
1166	// refresh's only discovered provider.
1167	if err := provider.SaveCache(ModelCachePath(), provider.ModelCache{
1168		Version:   provider.ModelCacheVersion,
1169		FetchedAt: time.Now().UTC(),
1170		Models: []provider.Model{{
1171			Provider: "kimi",
1172			ID:       "kimi-unrelated",
1173			Source:   "live",
1174		}},
1175	}); err != nil {
1176		t.Fatal(err)
1177	}
1178	close(release)
1179	select {
1180	case <-done:
1181	case <-time.After(30 * time.Second):
1182		t.Fatal("scoped refresh did not finish")
1183	}
1184
1185	final, err := provider.LoadCache(ModelCachePath())
1186	if err != nil {
1187		t.Fatal(err)
1188	}
1189	var sawGo, sawKimi bool
1190	for _, m := range final.Models {
1191		if m.Provider == provider.ProviderOpenCodeGo && m.ID == "go-scoped-model" {
1192			sawGo = true
1193		}
1194		if m.Provider == "kimi" && m.ID == "kimi-unrelated" {
1195			sawKimi = true
1196		}
1197	}
1198	if !sawGo {
1199		t.Fatalf("active OpenCode Go scope was discarded by an unrelated cache write: %+v", final.Models)
1200	}
1201	if !sawKimi {
1202		t.Fatalf("unrelated scope was discarded by the OpenCode Go refresh: %+v", final.Models)
1203	}
1204	if got := final.ProviderScopes[provider.ProviderOpenCodeGo]; got != credentialScopeForEndpoint("synthetic-scope-key", "") {
1205		t.Fatalf("OpenCode Go scope = %q, want active credential scope", got)
1206	}
1207}

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