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