v0.50.0 c4ec31f

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

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