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}