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}