detached a1aeca6

feat(provider): sync OpenCode Go models (#186)

* feat(provider): sync OpenCode Go models

* fix(provider): finish dynamic model catalog support

* fix(agent): preserve model refresh overrides

* fix(provider): harden dynamic catalog refresh

* fix(api): reject blank dynamic model IDs

* fix(provider): harden dynamic model synchronization

* fix(provider): address review findings

* fix(provider): harden scoped model catalog synchronization

* fix(agent): propagate SDK catalog cancellation

* feat(provider): route OpenCode Go models by models.dev adapter

Resolve the wire protocol from models.dev npm metadata, add the
Anthropic Messages route, and send stable session/request identity
headers on every attempt. Invalidate pre-adapter caches and fail
unsupported adapters explicitly instead of silent fallback.

Also adds a scrollable full-error view to the rescue dialog and
preserves complete provider diagnostics.

* fix(agent): preserve active OpenCode Go scope on unrelated cache write

Only suppress this refresh's OpenCode Go discovery when the newer
cache snapshot actually carries OpenCode Go scope data. A concurrent
write without that scope no longer discards the active discovery.

Also fail ResolveSDK fast on canceled contexts instead of resolving
a Runtime.
commit
a1aeca6b9263ca3970d21415fb7702a0686f7896
author
bnema · · 2026-09-07 13:01 UTC
committer
GitHub · · 2026-09-07 13:01 UTC
parent
9467429
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
49 files · +4625 -478
M README.md +4 -4
@@ -148,7 +148,7 @@ $ZUT_HOME/
148148 ├── config.json # last-used provider/model/theme and persistent settings
149149 ├── auth.json # api keys and oauth tokens (mode 0600)
150150 ├── sessions/ # jsonl transcripts, one dir per cwd
151-├── models-cache.json # live /v1/models discovery cache (6h ttl)
151+├── models-cache.json # live model discovery cache (24h ttl)
152152 ├── AGENTS.md # optional: global instructions appended to the prompt
153153 ├── SYSTEM.md # optional: replaces the built-in identity; addenda remain
154154 ├── skills/ # optional: user SKILL.md files
@@ -310,7 +310,7 @@ Single-part names resolve only to a matching local directory or `.zut` archive.
310310
311311 Two ways to drive zut from another program:
312312
313-- **Go in-process**: import `github.com/bnema/zut/packages/agent/sdk`. One `Runtime` per project; `Prompt(ctx, text, images)` returns a channel of `Event`. `sdk.Config.MaxSteps` is unlimited when zero; embedders preserving the former SDK default should set it to `50` explicitly. Small example in `examples/sdk/`.
313+- **Go in-process**: import `github.com/bnema/zut/packages/agent/sdk`. One `Runtime` per project; `Prompt(ctx, text, images)` returns a channel of `Event`. The SDK loads the credential- and endpoint-scoped model cache before resolving a runtime, including dynamic OpenCode Go metadata. `sdk.Config.MaxSteps` is unlimited when zero; embedders preserving the former SDK default should set it to `50` explicitly. Small example in `examples/sdk/`.
314314 - **Any language, out-of-process**: spawn `zut rpc` as a subprocess and exchange newline-delimited JSON over its stdin/stdout. Wire format and event schema in [docs/rpc.md](docs/rpc.md). Reference clients live under `examples/rpc/`.
315315
316316 Both interfaces share the same event schema, so transcripts captured by one can be replayed through the other.
@@ -536,7 +536,7 @@ Use `/login` to store API keys or subscription credentials. `/model` only shows
536536 `--list-models` or the `/model` picker shows the full catalog across all built-in providers. Three sources:
537537
538538 - **Catalog**: models baked into zut, covering Claude, GPT/Codex, Gemini/Gemma, Kimi/Moonshot, DeepSeek, Groq-hosted Llama/Gemma/Compound, OpenRouter-routed models, Bedrock model ids, Vertex model ids, Azure OpenAI deployments, Copilot models, and other provider-specific catalog entries.
539-- **Live**: IDs discovered from `GET /v1/models` using your stored API key (cached for 6h in `$ZUT_HOME/models-cache.json`, refreshed in the background on startup).
539+- **Live**: IDs discovered from provider model endpoints using your stored API key (cached for 24h in `$ZUT_HOME/models-cache.json`, refreshed in the background on startup). OpenCode Go joins the published `https://opencode.ai/zen/go/v1/models` list with limits, prices, names, reasoning, and wire-adapter metadata from `https://models.dev/api.json`; its cache is scoped to the credential fingerprint and endpoint, and SDK runtimes load matching cached metadata before resolving. The published list is a catalog, not proof of consent, regional eligibility, quota, or account access; models.dev is the JSON source behind the linked provider page.
540540 - **Speculative**: IDs that appear in the upstream generator but aren't live on the public API yet. They'll 404 today and start working the moment the provider ships them.
541541
542542 The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed. Tool output is still shown in full through the normal transcript rendering and retained in the session transcript. To keep long-running sessions usable, zut bounds the historical tool-result text included in provider-facing context; this projection affects what is sent to the model, not what is displayed or persisted, and it preserves the tool-call/result structure.
@@ -545,7 +545,7 @@ The context meter in the status line uses the model's advertised context window
545545
546546 When a turn fails because of a recoverable provider error — expired token (`401`), permission denied (`403`), rate limit (`429`), provider outage (`502`/`503`/`504`), or a transient network failure — zut opens a **rescue** picker over the chat instead of just painting a red banner.
547547
548-The picker is the same vertical list / fuzzy filter UI as `/model`, but it only shows models from providers you're currently logged in to (env vars, `auth.json`, Kimi CLI fallback, ollama). The failed model is excluded. Press `↑`/`↓` to choose, `enter` to retry the **same prompt** on the new model, `esc` to dismiss.
548+The picker is the same vertical list / fuzzy filter UI as `/model`, but it only shows models from providers you're currently logged in to (env vars, `auth.json`, Kimi CLI fallback, ollama). The failed model is excluded. Press `↑`/`↓` to choose, `enter` to retry the **same prompt** on the new model, `esc` to dismiss. Press `tab` to read the full provider error, wrapped to the pane width; use `↑`/`↓` or `PageUp`/`PageDown` to scroll, and `tab` or `esc` to return to the picker.
549549
550550 Before the actual provider request fires, the shared HTTP streaming clients used by OpenAI / Anthropic / Kimi / DeepSeek / Google / OpenAI-Codex also do up to four silent retries with short backoff (250ms, then 750ms, 750ms, and 750ms) on transient HTTP failures and connection-reset / EOF-before-headers errors. Once a stream is open, up to five reconnect attempts guard interrupted streams. Independently, five minutes without a provider event fails the turn instead of restarting the idle deadline on each retry. Most edge-proxy blips disappear without you ever seeing the rescue picker.
551551
M docs/providers.md +37 -4
@@ -136,6 +136,36 @@ export OPENROUTER_API_KEY=...
136136 zut --provider openrouter
137137 ```
138138
139+### OpenCode Go model catalog
140+
141+OpenCode Go is synchronized instead of using a hand-maintained model list. When
142+`OPENCODE_API_KEY` or a configured OpenCode Go credential is available, zut
143+reads the model IDs published by
144+[`https://opencode.ai/zen/go/v1/models`](https://opencode.ai/zen/go/v1/models)
145+and enriches them with names, limits, prices, reasoning options, and adapter
146+metadata from the [Models.dev JSON API](https://models.dev/api.json), which
147+powers the [OpenCode Go provider page](https://models.dev/providers/opencode-go/).
148+The published list is authoritative for the catalog and picker, but it is not
149+an entitlement check: consent, regional eligibility, quota, and account policy
150+can still reject an inference request.
151+
152+Models.dev adapter metadata selects the wire protocol for each model:
153+`@ai-sdk/openai` uses OpenAI Responses, `@ai-sdk/openai-compatible` uses Chat
154+Completions, and `@ai-sdk/anthropic` uses Anthropic Messages. If that metadata
155+is unavailable, zut keeps the published model ID and uses only its narrow
156+bootstrap fallback; protocol compatibility cannot be guaranteed until the
157+metadata is available. An explicit unsupported adapter fails with an
158+unsupported-protocol error instead of silently choosing Chat Completions.
159+Explicit and discovered base URLs remain the credential-scoped gateway
160+endpoint; public metadata does not redirect requests to another host.
161+
162+The merged catalog is cached in `$ZUT_HOME/models-cache.json` for 24 hours;
163+OpenCode Go entries are scoped to both the credential fingerprint and endpoint.
164+`--list-models` waits for a refresh when the cache is stale, while SDK runtimes
165+load matching cached metadata before resolving. Synchronous refreshes resolve
166+configured `api_key_command` credentials; unsolicited startup refreshes leave
167+those commands untouched.
168+
139169 ## Fast mode
140170
141171 Use `/fast` or `/settings` to enable **fast mode** for subsequent model calls.
@@ -175,10 +205,13 @@ correlations; adapters translate them only when their exact provider, model,
175205 and endpoint declaration supports it. Resident children share the root cache
176206 identity but retain distinct thread identities. The OpenAI/Codex Responses
177207 route uses the root identity as `prompt_cache_key`; declared Codex routes also
178-receive their documented session and thread routing headers. A generic
179-OpenAI-compatible endpoint never receives these extensions merely because it
180-accepts an OpenAI-shaped request. JSON event mode also reports sanitized cache
181-diagnostics (`eligible`, `mode`, `transport`, and `continuation`). `eligible`
208+receive their documented session and thread routing headers. OpenCode Go
209+requests identify zut with `x-opencode-client` and `User-Agent`, use the stable
210+thread (or root cache) ID as `x-opencode-session`, and use the turn ID as
211+`x-opencode-request`. A generic OpenAI-compatible endpoint never receives
212+these extensions merely because it accepts an OpenAI-shaped request. JSON
213+event mode also reports sanitized cache diagnostics (`eligible`, `mode`,
214+`transport`, and `continuation`). `eligible`
182215 is a conservative local prompt-length estimate; OpenAI performs the authoritative
183216 tokenization and cache decision. These records never include prompts, durable
184217 IDs, request bodies, or credentials.
M packages/agent/args.go +10 -5
@@ -8,6 +8,7 @@ import (
88
99 "github.com/bnema/zut/packages/agent/subagents"
1010 "github.com/bnema/zut/packages/agent/tools"
11+ "github.com/bnema/zut/packages/provider"
1112 "github.com/bnema/zut/packages/tui"
1213 "github.com/google/uuid"
1314 "golang.org/x/term"
@@ -35,11 +36,15 @@ type Args struct {
3536 Model string
3637 APIKey string
3738
38- BaseURL string // override provider base URL (for tests/self-hosted)
39- SystemPrompt string
40- AppendSystemPrompt []string
41- Reasoning string
42- Temperature *float32
39+ BaseURL string // override provider base URL (for tests/self-hosted)
40+ // modelCatalog is set by ResolveSDK so dynamic model lookup uses the
41+ // scope captured during preparation rather than the global live overlay.
42+ modelCatalog []provider.Model
43+ modelCatalogAuthoritative bool
44+ SystemPrompt string
45+ AppendSystemPrompt []string
46+ Reasoning string
47+ Temperature *float32
4348
4449 // FastMode is an internal resident-child propagation value.
4550 FastMode bool
M packages/agent/build.go +195 -92
@@ -57,6 +57,16 @@ type Resolved struct {
5757 // long writes/edits with stopReason=length).
5858 MaxOutput int
5959
60+ // modelCatalog is a runtime-owned snapshot for providers whose model
61+ // metadata is account- or endpoint-scoped. It prevents a later SDK
62+ // runtime from changing request shaping for an already-created runtime.
63+ modelCatalog []provider.Model
64+ modelCatalogAuthoritative bool
65+
66+ // customProviderConfig snapshots user-provider transport settings so
67+ // client construction does not reread a mutable global registry.
68+ customProviderConfig *provider.CustomProviderConfig
69+
6070 // SkillTool is the on-demand skill loader registered with the
6171 // agent's tool registry, or nil if no SKILL.md files were
6272 // discovered. Exposed so the tui can list / preview skills.
@@ -84,6 +94,19 @@ type Resolved struct {
8494 // HasCredential reports whether a credential was resolved.
8595 func (r Resolved) HasCredential() bool { return r.Credential != "" }
8696
97+// ModelCatalogSnapshot returns the runtime-owned model metadata captured while
98+// resolving. It is used by SDK clients whose provider catalog is scoped to a
99+// credential or endpoint.
100+func (r Resolved) ModelCatalogSnapshot() []provider.Model {
101+ return append([]provider.Model(nil), r.modelCatalog...)
102+}
103+
104+// ModelCatalogIsAuthoritative reports whether the runtime snapshot came from
105+// a successful provider catalog that is authoritative for model IDs.
106+func (r Resolved) ModelCatalogIsAuthoritative() bool {
107+ return r.modelCatalogAuthoritative
108+}
109+
87110 // MergeExtensionTools folds every tool registered by an extension
88111 // into r's ToolRegistry and re-renders the system prompt's tool
89112 // summary so the model sees both built-in and extension tools.
@@ -249,6 +272,8 @@ func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []To
249272 return out
250273 }
251274
275+const openCodeGoBootstrapModel = "kimi-k2.6"
276+
252277 // defaultModelForProvider returns the model id zut prefers when the
253278 // caller didn't pick one. Mirrors the per-provider switch used at
254279 // multiple points in Resolve; centralised so the unknown-model
@@ -259,57 +284,63 @@ func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []To
259284 // error or use whatever the user passed.
260285 func defaultModelForProvider(prov string) string {
261286 switch prov {
262- case "openai":
287+ case provider.ProviderOpenAI:
263288 return "gpt-5"
264- case "openai-codex":
289+ case provider.ProviderOpenAICodex:
265290 return "gpt-5.5"
266- case "openai-responses":
291+ case provider.ProviderOpenAIResponses:
267292 return "gpt-5"
268- case "kimi":
293+ case provider.ProviderKimi:
269294 return "kimi-for-coding"
270- case "deepseek":
295+ case provider.ProviderDeepSeek:
271296 return "deepseek-v4-pro"
272- case "google":
297+ case provider.ProviderGoogle:
273298 return "gemini-2.5-pro"
274- case "ollama", provider.LlamaCPPProviderID:
299+ case provider.ProviderOllama, provider.ProviderLlamaCPP:
275300 return ""
276- case "moonshotai", "moonshotai-cn":
301+ case provider.ProviderMoonshot, provider.ProviderMoonshotCN:
277302 return "kimi-k2.6"
278- case "cerebras":
303+ case provider.ProviderCerebras:
279304 return "qwen-3-235b-a22b-instruct-2507"
280- case "groq":
305+ case provider.ProviderGroq:
281306 return "llama-3.3-70b-versatile"
282- case "xai":
307+ case provider.ProviderXAI:
283308 return "grok-4.5"
284- case "together":
309+ case provider.ProviderTogether:
285310 return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
286- case "huggingface":
311+ case provider.ProviderHuggingFace:
287312 return "moonshotai/Kimi-K2-Instruct"
288- case "openrouter":
313+ case provider.ProviderOpenRouter:
289314 return "anthropic/claude-sonnet-4.5"
290- case "mistral":
315+ case provider.ProviderMistral:
291316 return "mistral-large-latest"
292- case "zai":
317+ case provider.ProviderZAI:
293318 return "glm-4.7"
294- case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
319+ case provider.ProviderXiaomi, provider.ProviderXiaomiTokenPlanAMS, provider.ProviderXiaomiTokenPlanCN, provider.ProviderXiaomiTokenPlanSGP:
295320 return "mimo-v2.5"
296- case "minimax", "minimax-cn":
321+ case provider.ProviderMiniMax, provider.ProviderMiniMaxCN:
297322 return "MiniMax-M2.7"
298- case "fireworks":
323+ case provider.ProviderFireworks:
299324 return "accounts/fireworks/models/kimi-k2p6"
300- case "vercel-ai-gateway":
325+ case provider.ProviderVercelAIGateway:
301326 return "anthropic/claude-sonnet-4.5"
302- case "opencode":
327+ case provider.ProviderOpenCode:
303328 return "claude-sonnet-4-5"
304- case "opencode-go":
305- return "kimi-k2.6"
306- case "amazon-bedrock":
329+ case provider.ProviderOpenCodeGo:
330+ // OpenCode Go is synchronized at runtime, so prefer the first
331+ // currently served model and keep the former default only as a
332+ // bootstrap while the first refresh is still in flight.
333+ if models := provider.ModelsForProvider(prov); len(models) > 0 {
334+ return models[0].ID
335+ }
336+ return openCodeGoBootstrapModel
337+ case provider.ProviderAmazonBedrock:
307338 return "anthropic.claude-sonnet-4-5-20250929-v1:0"
308- case "google-vertex":
339+ case provider.ProviderGoogleVertex:
309340 return "gemini-2.5-pro"
310- case "azure-openai-responses":
341+ case provider.ProviderAzureOpenAIResponses:
311342 return "gpt-5"
312- case "github-copilot":
343+ case provider.ProviderGitHubCopilot:
313344 return "claude-sonnet-4.5"
314345 default:
315346 // Custom providers: pick the first model from the catalog for
@@ -325,18 +356,7 @@ func defaultModelForProvider(prov string) string {
325356 // Resolve to validate args.Provider, by extension-callers, and by the
326357 // auto-fallback logic that picks any logged-in provider when the user's
327358 // preferred one has no credentials.
328-var knownProviders = []string{
329- "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID,
330- "moonshotai", "moonshotai-cn",
331- "cerebras", "groq", "xai", "together", "huggingface", "openrouter",
332- "mistral", "zai",
333- "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
334- "minimax", "minimax-cn",
335- "fireworks", "vercel-ai-gateway",
336- "opencode", "opencode-go",
337- "amazon-bedrock", "google-vertex", "azure-openai-responses",
338- "github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
339-}
359+var knownProviders = provider.BuiltinProviderIDs()
340360
341361 func isKnownProvider(name string) bool {
342362 for _, p := range knownProviders {
@@ -486,6 +506,35 @@ func webSearchAllowedForRegistry(args Args) bool {
486506 }
487507 }
488508
509+func findModelForResolve(providerName, modelID string, scopedCatalog []provider.Model) (provider.Model, error) {
510+ if providerName == provider.ProviderOpenCodeGo && scopedCatalog != nil {
511+ for _, model := range scopedCatalog {
512+ if model.ID == modelID {
513+ return model, nil
514+ }
515+ }
516+ return provider.Model{}, fmt.Errorf("unknown model %q (provider=%q)", modelID, providerName)
517+ }
518+ return provider.FindModel(providerName, modelID)
519+}
520+
521+func acceptsUnlistedModel(providerName string, args Args) bool {
522+ if providerName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
523+ return !args.modelCatalogAuthoritative
524+ }
525+ return provider.AcceptsUnlistedModels(providerName)
526+}
527+
528+func defaultModelForResolve(providerName string, args Args) string {
529+ if providerName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
530+ if len(args.modelCatalog) > 0 {
531+ return args.modelCatalog[0].ID
532+ }
533+ return openCodeGoBootstrapModel
534+ }
535+ return defaultModelForProvider(providerName)
536+}
537+
489538 // Resolve merges args, config, and env into a Resolved set.
490539 //
491540 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -606,12 +655,29 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
606655 }
607656 }
608657
609- model := firstNonEmpty(args.Model, cfg.Model)
658+ var customProviderConfig *provider.CustomProviderConfig
659+ if custom, ok := provider.CustomProviders()[provName]; ok {
660+ customProviderConfig = &custom
661+ }
662+
663+ // Normalize model ids at the resolver boundary without mutating the
664+ // caller's Args value. Treat whitespace-only values as missing so a
665+ // configured fallback can still be selected.
666+ model := strings.TrimSpace(args.Model)
667+ if model == "" {
668+ model = strings.TrimSpace(cfg.Model)
669+ }
610670 if model == "" {
611671 if provName == "ollama" || provName == provider.LlamaCPPProviderID {
612672 return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
613673 }
614- model = defaultModelForProvider(provName)
674+ if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
675+ // Keep model selection inside the runtime snapshot instead of
676+ // consulting another runtime's mutable catalog.
677+ model = defaultModelForResolve(provName, args)
678+ } else {
679+ model = defaultModelForProvider(provName)
680+ }
615681 }
616682 // If the resolved model belongs to a different provider (e.g. config
617683 // says gpt-5 but we auto-fell back to anthropic), pick that provider's
@@ -620,11 +686,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
620686 // router providers (openrouter, vercel-ai-gateway, etc.) can serve
621687 // models from any provider in the catalog.
622688 if autoFellBack && provName != "ollama" {
623- if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
689+ if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
690+ if _, err := findModelForResolve(provName, model, args.modelCatalog); err != nil {
691+ model = defaultModelForResolve(provName, args)
692+ }
693+ } else if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
624694 model = defaultModelForProvider(provName)
625695 }
626696 }
627- resolvedModel, err := provider.FindModel(provName, model)
697+ resolvedModel, err := findModelForResolve(provName, model, args.modelCatalog)
628698 if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
629699 // Local providers are intentionally open-catalogue: any model id the
630700 // configured server understands is valid, even if it is not cached.
@@ -639,26 +709,32 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
639709 }
640710 err = nil
641711 }
712+ // OpenCode Go publishes its authoritative model list at runtime. Keep
713+ // explicit model ids usable during the first refresh or while offline;
714+ // the provider request will be the final authority if no catalog data is
715+ // available yet.
716+ if err != nil && acceptsUnlistedModel(provName, args) {
717+ resolvedModel = provider.DynamicOpenCodeGoModel(provName, model, args.BaseURL)
718+ err = nil
719+ }
642720 // Custom providers are open-catalogue like ollama: any model id the
643721 // endpoint understands is valid. Use the provider-level base URL.
644- if err != nil {
645- if cfg, ok := provider.CustomProviders()[provName]; ok {
646- resolvedModel = provider.Model{
647- Provider: provName,
648- ID: model,
649- DisplayName: model,
650- ContextWindow: 128000,
651- MaxOutput: 16384,
652- BaseURL: cfg.BaseURL,
653- Source: "user",
654- }
655- err = nil
722+ if err != nil && customProviderConfig != nil {
723+ resolvedModel = provider.Model{
724+ Provider: provName,
725+ ID: model,
726+ DisplayName: model,
727+ ContextWindow: 128000,
728+ MaxOutput: 16384,
729+ BaseURL: customProviderConfig.BaseURL,
730+ Source: "user",
656731 }
732+ err = nil
657733 }
658- if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
734+ if customProviderConfig != nil && err == nil && resolvedModel.BaseURL == "" && customProviderConfig.BaseURL != "" {
659735 // Fall back to the provider-level base URL when the model does
660736 // not define its own endpoint.
661- resolvedModel.BaseURL = cfg.BaseURL
737+ resolvedModel.BaseURL = customProviderConfig.BaseURL
662738 }
663739 if err != nil {
664740 // The model the user (or persisted config) asked for is no
@@ -687,13 +763,18 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
687763 }
688764 // Don't repair config — the routed model id may be valid upstream.
689765 } else {
690- fallback := defaultModelForProvider(provName)
691- fm, ferr := provider.FindModel(provName, fallback)
766+ fallback := defaultModelForResolve(provName, args)
767+ fm, ferr := findModelForResolve(provName, fallback, args.modelCatalog)
692768 if ferr != nil {
693769 // Even the provider default is gone (catastrophic
694770 // catalogue trim). Last resort: any model on this
695- // provider, then the global DefaultModel.
696- if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
771+ // provider, then the global DefaultModel. A scoped
772+ // OpenCode Go runtime must stay inside its snapshot.
773+ candidates := provider.ModelsForProvider(provName)
774+ if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
775+ candidates = args.modelCatalog
776+ }
777+ if len(candidates) > 0 {
697778 fm = candidates[0]
698779 } else {
699780 fm = provider.DefaultModel
@@ -702,7 +783,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
702783 fmt.Fprintf(os.Stderr,
703784 "zut: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
704785 model, fm.ID)
705- if args.Model == "" && cfg.Model == model {
786+ if strings.TrimSpace(args.Model) == "" && strings.TrimSpace(cfg.Model) == model {
706787 cfg.Model = fm.ID
707788 repairConfig = true
708789 }
@@ -880,33 +961,51 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
880961 }
881962 max := args.MaxSteps // 0 = unlimited
882963
964+ var modelCatalog []provider.Model
965+ if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
966+ modelCatalog = append([]provider.Model{}, args.modelCatalog...)
967+ found := false
968+ for _, catalogModel := range modelCatalog {
969+ if catalogModel.ID == model {
970+ found = true
971+ break
972+ }
973+ }
974+ if !found {
975+ modelCatalog = append(modelCatalog, resolvedModel)
976+ }
977+ }
978+
883979 return Resolved{
884- Provider: provName,
885- Model: model,
886- Credential: cred,
887- AuthMethod: method,
888- AccountID: accountID,
889- BaseURL: args.BaseURL,
890- InsecureTLS: insecureTLS,
891- CWD: args.CWD,
892- Reasoning: reasoning,
893- Temperature: temperature,
894- FastMode: fastMode,
895- WebSearchPolicy: webSearchPolicy,
896- ToolRegistry: reg,
897- ToolSummary: summaries,
898- SystemPrompt: sys,
899- MaxSteps: max,
900- ContextWindow: resolvedModel.ContextWindow,
901- MaxOutput: resolvedModel.MaxOutput,
902- Sandbox: sandbox,
903- SkillTool: skillTool,
904- skillsEnabled: skillsEnabled,
905- ContextFiles: contextFiles,
906- systemAppend: append_,
907- systemCustom: custom,
908- skillAddendum: skillAddendum,
909- toolDescriptions: descMapFromSummaries(summaries),
980+ Provider: provName,
981+ Model: model,
982+ Credential: cred,
983+ AuthMethod: method,
984+ AccountID: accountID,
985+ BaseURL: args.BaseURL,
986+ InsecureTLS: insecureTLS,
987+ CWD: args.CWD,
988+ Reasoning: reasoning,
989+ Temperature: temperature,
990+ FastMode: fastMode,
991+ WebSearchPolicy: webSearchPolicy,
992+ ToolRegistry: reg,
993+ ToolSummary: summaries,
994+ SystemPrompt: sys,
995+ MaxSteps: max,
996+ ContextWindow: resolvedModel.ContextWindow,
997+ MaxOutput: resolvedModel.MaxOutput,
998+ modelCatalog: modelCatalog,
999+ modelCatalogAuthoritative: args.modelCatalogAuthoritative,
1000+ customProviderConfig: customProviderConfig,
1001+ Sandbox: sandbox,
1002+ SkillTool: skillTool,
1003+ skillsEnabled: skillsEnabled,
1004+ ContextFiles: contextFiles,
1005+ systemAppend: append_,
1006+ systemCustom: custom,
1007+ skillAddendum: skillAddendum,
1008+ toolDescriptions: descMapFromSummaries(summaries),
9101009 }, nil
9111010 }
9121011
@@ -1089,8 +1188,8 @@ func (r Resolved) NewClient() provider.Client {
10891188 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
10901189 case "opencode":
10911190 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1092- case "opencode-go":
1093- return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
1191+ case provider.ProviderOpenCodeGo:
1192+ return wrap(provider.NewOpenCodeGoWithModels(r.Credential, r.BaseURL, r.modelCatalog))
10941193 case "minimax":
10951194 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
10961195 case "minimax-cn":
@@ -1115,8 +1214,12 @@ func (r Resolved) NewClient() provider.Client {
11151214 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
11161215 default:
11171216 // Custom providers: choose wire format from the models.json api field.
1118- if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
1119- switch cfg.API {
1217+ customConfig, ok := provider.CustomProviders()[r.Provider]
1218+ if r.customProviderConfig != nil {
1219+ customConfig, ok = *r.customProviderConfig, true
1220+ }
1221+ if ok {
1222+ switch customConfig.API {
11201223 case provider.APIResponses:
11211224 return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
11221225 case "anthropic":
A packages/agent/catalog_test_helpers_test.go +15 -0
@@ -0,0 +1,15 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/provider"
7+)
8+
9+func preserveProviderCatalog(t *testing.T) {
10+ t.Helper()
11+ snapshot := provider.SnapshotCatalog()
12+ t.Cleanup(func() {
13+ provider.RestoreCatalog(snapshot)
14+ })
15+}
M packages/agent/cli.go +2 -1
@@ -904,7 +904,8 @@ func runWithArgsRaw(rawArgs []string, version string) error {
904904 }
905905 // Model catalog: load any cached discovery data before we inspect
906906 // the model list (list-models, print/json, interactive).
907- prepareRuntimeCatalog()
907+ userModels := LoadUserModels()
908+ PrepareRuntimeCatalog(context.Background(), args.ListModels, args.Provider, args.APIKey, args.BaseURL, userModels, args.Model)
908909
909910 if args.ListModels {
910911 printModels()
M packages/agent/modelsync.go +373 -65
@@ -2,15 +2,23 @@ package agent
22
33 import (
44 "context"
5+ "crypto/sha256"
56 "fmt"
67 "os"
78 "path/filepath"
9+ "reflect"
810 "strings"
11+ "sync"
912 "time"
1013
1114 "github.com/bnema/zut/packages/provider"
1215 )
1316
17+var (
18+ discoverOpenCodeGoFn = provider.DiscoverOpenCodeGo
19+ modelCatalogMu sync.Mutex
20+)
21+
1422 // ModelCachePath returns the on-disk location of the merged model cache.
1523 func ModelCachePath() string {
1624 return filepath.Join(ZutHome(), "models-cache.json")
@@ -25,11 +33,68 @@ func UserModelsPath() string {
2533 // package so FindModel / ModelsForProvider see live ids immediately.
2634 // Safe to call before any credentials are known.
2735 func LoadCachedModels() {
36+ loadCachedModels(currentModelProviderScopes())
37+}
38+
39+func synchronousOpenCodeGoAPIKey(ctx context.Context, explicitProvider, explicitAPIKey string) string {
40+ providerName := effectiveCatalogProvider(explicitProvider)
41+ if explicitAPIKey != "" || (providerName != "" && providerName != provider.ProviderOpenCodeGo) {
42+ return explicitAPIKey
43+ }
44+ key, method, _, err := resolveCredentialFull(ctx, provider.ProviderOpenCodeGo, "", apiKeyCommandExecute)
45+ if err != nil || method != "apikey" {
46+ return ""
47+ }
48+ return key
49+}
50+
51+// ResolveSDK prepares the credential-scoped catalog and resolves an SDK
52+// runtime against a private model snapshot. Preparation and snapshot capture
53+// are coordinated with background publication, while credential resolution
54+// itself remains outside the process-wide catalog lock.
55+func ResolveSDK(ctx context.Context, args Args) (Resolved, error) {
56+ if ctx == nil {
57+ ctx = context.Background()
58+ }
59+ if err := ctx.Err(); err != nil {
60+ return Resolved{}, err
61+ }
62+ if args.APIKey == "" && effectiveCatalogProvider(args.Provider) == provider.ProviderOpenCodeGo {
63+ args.APIKey = synchronousOpenCodeGoAPIKey(ctx, args.Provider, args.APIKey)
64+ if err := ctx.Err(); err != nil {
65+ return Resolved{}, err
66+ }
67+ }
68+ modelCatalogMu.Lock()
69+ userModels := LoadUserModels()
70+ _, _ = prepareRuntimeCatalog(args.Provider, args.APIKey, args.BaseURL, userModels, args.Model)
71+ // Keep an explicit empty snapshot as well: if Resolve later falls back
72+ // to OpenCode Go because only that provider has credentials, it must not
73+ // reread a concurrently changing global overlay.
74+ args.modelCatalog = append([]provider.Model{}, provider.ModelsForProvider(provider.ProviderOpenCodeGo)...)
75+ args.modelCatalogAuthoritative = provider.IsProviderCatalogAuthoritative(provider.ProviderOpenCodeGo)
76+ modelCatalogMu.Unlock()
77+ if err := ctx.Err(); err != nil {
78+ return Resolved{}, err
79+ }
80+
81+ resolved, err := Resolve(args, true)
82+ if err == nil && resolved.Provider == provider.ProviderOpenCodeGo {
83+ refreshModelsAsyncForProvider(ctx, resolved.Provider, resolved.Credential, resolved.BaseURL, provider.ProviderOpenCodeGo)
84+ }
85+ return resolved, err
86+}
87+
88+func loadCachedModels(scopes map[string]string) {
89+ // A process can construct SDK runtimes for different credentials in
90+ // sequence. Remove the previous OpenCode Go live overlay before applying
91+ // the newly scoped cache, including cache-miss and read-error paths.
92+ provider.ClearLiveModelsForProvider(provider.ProviderOpenCodeGo)
2893 c, err := provider.LoadCache(ModelCachePath())
29- if err != nil {
94+ if err != nil || c.Version != provider.ModelCacheVersion {
3095 return
3196 }
32- c = filterCacheByProviderScopes(c, currentModelProviderScopes())
97+ c = filterCacheByProviderScopes(c, scopes)
3398 if len(c.Models) > 0 || len(c.AuthoritativeProviders) > 0 {
3499 provider.SetLiveModelsForProviders(c.Models, c.AuthoritativeProviders)
35100 }
@@ -39,10 +104,44 @@ func LoadCachedModels() {
39104 // discovered catalog account-specific. The cache remains provider-neutral:
40105 // each provider supplies an opaque scope value when it needs one.
41106 func currentModelProviderScopes() map[string]string {
107+ return currentModelProviderScopesForBaseURL("")
108+}
109+
110+func currentModelProviderScopesForBaseURL(openCodeGoBaseURL string) map[string]string {
42111 scopes := make(map[string]string)
43112 if token := loadOAuthToken("openai"); token != nil && token.AccountID != "" {
44113 scopes["openai-codex"] = token.AccountID
45114 }
115+ if key, method, _, err := resolveCredentialFull(context.Background(), provider.ProviderOpenCodeGo, "", apiKeyCommandSkip); err == nil && method == "apikey" && key != "" {
116+ scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(key, openCodeGoBaseURL)
117+ }
118+ return scopes
119+}
120+
121+// credentialScope lets a cache follow a credential without storing the
122+// credential itself. API keys are high-entropy values, and the cache is also
123+// protected with restrictive file permissions.
124+func credentialScope(credential string) string {
125+ return fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(credential)))
126+}
127+
128+func credentialScopeForEndpoint(credential, baseURL string) string {
129+ endpoint := strings.TrimRight(strings.TrimSpace(baseURL), "/")
130+ if endpoint == "" {
131+ endpoint = provider.OpenCodeGoDefaultBaseURL
132+ }
133+ return credentialScope(credential + "\x00" + endpoint)
134+}
135+
136+func modelProviderScopes(explicitProvider, explicitAPIKey, explicitBaseURL string) map[string]string {
137+ openCodeGoBaseURL := ""
138+ if explicitProvider == provider.ProviderOpenCodeGo {
139+ openCodeGoBaseURL = explicitBaseURL
140+ }
141+ scopes := currentModelProviderScopesForBaseURL(openCodeGoBaseURL)
142+ if explicitProvider == provider.ProviderOpenCodeGo && explicitAPIKey != "" {
143+ scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(explicitAPIKey, explicitBaseURL)
144+ }
46145 return scopes
47146 }
48147
@@ -51,7 +150,12 @@ func currentModelProviderScopes() map[string]string {
51150 // marker so that a failed refresh falls back to the baked-in catalog instead
52151 // of treating another account's empty catalog as authoritative.
53152 func filterCacheByProviderScopes(c provider.ModelCache, scopes map[string]string) provider.ModelCache {
153+ return filterCacheByProviderScopesExcept(c, scopes, "")
154+}
155+
156+func filterCacheByProviderScopesExcept(c provider.ModelCache, scopes map[string]string, keepProvider string) provider.ModelCache {
54157 out := provider.ModelCache{
158+ Version: c.Version,
55159 FetchedAt: c.FetchedAt,
56160 Models: append([]provider.Model(nil), c.Models...),
57161 AuthoritativeProviders: append([]string(nil), c.AuthoritativeProviders...),
@@ -61,7 +165,10 @@ func filterCacheByProviderScopes(c provider.ModelCache, scopes map[string]string
61165 out.ProviderScopes[name] = scope
62166 }
63167
64- scopedProviders := map[string]struct{}{"openai-codex": {}}
168+ scopedProviders := map[string]struct{}{
169+ provider.ProviderOpenAICodex: {},
170+ provider.ProviderOpenCodeGo: {},
171+ }
65172 for name := range c.ProviderScopes {
66173 scopedProviders[name] = struct{}{}
67174 }
@@ -69,6 +176,9 @@ func filterCacheByProviderScopes(c provider.ModelCache, scopes map[string]string
69176 scopedProviders[name] = struct{}{}
70177 }
71178 for name := range scopedProviders {
179+ if name == keepProvider {
180+ continue
181+ }
72182 if cachedScope := c.ProviderScopes[name]; cachedScope != "" && cachedScope == scopes[name] {
73183 continue
74184 }
@@ -116,12 +226,13 @@ func providerScopesEqual(a, b map[string]string) bool {
116226 // Any validation issues (bad provider id, empty model id, malformed
117227 // JSON, negative widths) are surfaced as one warning per line on stderr;
118228 // the well-formed entries from the rest of the file are still loaded.
119-func LoadUserModels() {
229+func LoadUserModels() []provider.Model {
120230 models, warnings := provider.LoadUserModelsWithWarnings(UserModelsPath())
121231 for _, w := range warnings {
122232 fmt.Fprintln(os.Stderr, "zut:", w)
123233 }
124234 provider.SetUserModels(models)
235+ return models
125236 }
126237
127238 // isGatewayProvider returns true for providers whose OpenAI-compatible
@@ -159,6 +270,8 @@ func isGatewayRoutedModelID(model string) bool {
159270 //
160271 // Gateway providers are exempt from the cross-provider model check for routed
161272 // model IDs because those IDs can be valid even when absent from zut's catalog.
273+// OpenCode Go is also exempt from the unknown-model repair because its
274+// provider catalog is authoritative but live-only.
162275 //
163276 // Silent on success; one stderr line per repair. Errors loading or
164277 // saving the file are non-fatal — the caller continues with defaults.
@@ -177,26 +290,28 @@ func ValidateAndRepairConfig() {
177290 changed = true
178291 }
179292
180- if cfg.Provider != "" && cfg.Model != "" {
181- if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil {
182- // Gateway providers can serve routed model ids like
183- // "deepseek/deepseek-v4-flash" even when the local catalog does not
184- // know them. Preserve only routed ids; plain typos are still repaired.
185- if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model) {
293+ modelID := strings.TrimSpace(cfg.Model)
294+ if cfg.Provider != "" && modelID != "" {
295+ if _, err := provider.FindModel(cfg.Provider, modelID); err != nil {
296+ // OpenCode Go is authoritative at runtime and intentionally has no
297+ // baked-in entries, so an uncached id must survive startup repair.
298+ if provider.AcceptsUnlistedModels(cfg.Provider) {
299+ // The live refresh will validate the id when it is available.
300+ } else if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(modelID) {
186301 // Provider is a router and the id is route-qualified; preserve it.
187- } else if m, err := provider.FindModel("", cfg.Model); err == nil {
302+ } else if m, err := provider.FindModel("", modelID); err == nil {
188303 fix := defaultModelForProvider(cfg.Provider)
189304 fmt.Fprintf(os.Stderr,
190305 "zut: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n",
191- cfg.Model, m.Provider, cfg.Provider, fix)
306+ modelID, m.Provider, cfg.Provider, fix)
192307 cfg.Model = fix
193308 changed = true
194- } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID {
309+ } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID && !provider.AcceptsUnlistedModels(cfg.Provider) {
195310 // Model id not in any catalog. Reset to provider's default.
196311 fix := defaultModelForProvider(cfg.Provider)
197312 fmt.Fprintf(os.Stderr,
198313 "zut: config.json: model %q not found in the active catalog; switched to %q\n",
199- cfg.Model, fix)
314+ modelID, fix)
200315 cfg.Model = fix
201316 changed = true
202317 }
@@ -216,8 +331,12 @@ func ValidateAndRepairConfig() {
216331 //
217332 // Silent on error: discovery is a nice-to-have. Callers can still use
218333 // the baked-in catalog if this fails.
219-func RefreshModelsAsync() {
220- go refreshModels()
334+func RefreshModelsAsync(explicitProvider, explicitAPIKey string, explicitBaseURL ...string) {
335+ baseURL := ""
336+ if len(explicitBaseURL) > 0 {
337+ baseURL = explicitBaseURL[0]
338+ }
339+ go refreshModels(explicitProvider, explicitAPIKey, baseURL, "")
221340 go func() {
222341 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
223342 defer cancel()
@@ -225,6 +344,16 @@ func RefreshModelsAsync() {
225344 }()
226345 }
227346
347+func refreshModelsAsyncForProvider(ctx context.Context, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string) {
348+ if ctx == nil {
349+ ctx = context.Background()
350+ }
351+ if ctx.Err() != nil {
352+ return
353+ }
354+ go refreshModelsWithContext(ctx, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider, apiKeyCommandSkip)
355+}
356+
228357 // RefreshLlamaCPPModels adds the router's currently loaded models to the
229358 // active catalog. Unloaded models remain in the management UI and cannot be
230359 // selected for inference until they are loaded.
@@ -249,81 +378,260 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e
249378 return nil
250379 }
251380
252-func refreshModels() {
253- cached, _ := provider.LoadCache(ModelCachePath())
254- currentScopes := currentModelProviderScopes()
255- if cached.IsFresh() && providerScopesEqual(cached.ProviderScopes, currentScopes) {
256- return
381+// needsOpenCodeGoRefresh handles a credential added after a fresh cache was
382+// written. Without this check, a cache created before the first OpenCode Go
383+// login would hide the provider until the 24-hour TTL expired.
384+func needsOpenCodeGoRefresh(c provider.ModelCache) bool {
385+ if !CredentialAvailable(provider.ProviderOpenCodeGo) {
386+ return false
387+ }
388+ for _, model := range c.Models {
389+ if model.Provider == provider.ProviderOpenCodeGo {
390+ return false
391+ }
392+ }
393+ for _, name := range c.AuthoritativeProviders {
394+ if name == provider.ProviderOpenCodeGo {
395+ return false
396+ }
257397 }
398+ return true
399+}
258400
259- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
260- defer cancel()
401+func cacheSnapshotsEqual(a provider.ModelCache, aErr error, b provider.ModelCache, bErr error) bool {
402+ if (aErr != nil) != (bErr != nil) {
403+ return false
404+ }
405+ if aErr != nil {
406+ return true
407+ }
408+ return reflect.DeepEqual(a, b)
409+}
261410
262- var all []provider.Model
263- var authoritativeProviders []string
264- providerScopes := make(map[string]string)
265- if cached.IsFresh() {
266- cached = filterCacheByProviderScopes(cached, currentScopes)
267- all = append(all, cached.Models...)
268- authoritativeProviders = append(authoritativeProviders, cached.AuthoritativeProviders...)
269- for name, scope := range cached.ProviderScopes {
270- providerScopes[name] = scope
411+func eligibleProviderDiscoveryIncomplete(eligible map[string]struct{}, discovered map[string][]provider.Model) bool {
412+ for name := range eligible {
413+ if _, ok := discovered[name]; !ok {
414+ return true
271415 }
272416 }
417+ return false
418+}
419+
420+func refreshModels(explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string) {
421+ refreshModelsWithMode(explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider, apiKeyCommandSkip)
422+}
423+
424+func refreshModelsWithMode(explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string, commandMode apiKeyCommandMode) {
425+ refreshModelsWithContext(context.Background(), explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider, commandMode)
426+}
427+
428+func resolveCredentialForCatalog(ctx context.Context, providerName string, mode apiKeyCommandMode) (cred, method string, err error) {
429+ cred, method, _, err = resolveCredentialFull(ctx, providerName, "", mode)
430+ return cred, method, err
431+}
432+
433+func refreshModelsWithContext(parent context.Context, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string, commandMode apiKeyCommandMode) {
434+ if parent == nil {
435+ parent = context.Background()
436+ }
437+ explicitProvider = effectiveCatalogProvider(explicitProvider)
438+ cached, cachedErr := provider.LoadCache(ModelCachePath())
439+ ctx, cancel := context.WithTimeout(parent, 20*time.Second)
440+ defer cancel()
441+
442+ openCodeGoCred, openCodeGoMethod := "", ""
443+ if explicitProvider == provider.ProviderOpenCodeGo && explicitAPIKey != "" {
444+ openCodeGoCred, openCodeGoMethod = explicitAPIKey, "apikey"
445+ } else {
446+ openCodeGoCred, openCodeGoMethod, _, _ = resolveCredentialFull(ctx, provider.ProviderOpenCodeGo, "", commandMode)
447+ }
448+ currentScopes := modelProviderScopes(explicitProvider, explicitAPIKey, explicitBaseURL)
449+ openCodeGoBaseURL := ""
450+ if explicitProvider == provider.ProviderOpenCodeGo {
451+ openCodeGoBaseURL = explicitBaseURL
452+ }
453+ if openCodeGoMethod == "apikey" && openCodeGoCred != "" {
454+ currentScopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(openCodeGoCred, openCodeGoBaseURL)
455+ }
456+ if cached.IsFresh() &&
457+ cached.Version == provider.ModelCacheVersion &&
458+ providerScopesEqual(cached.ProviderScopes, currentScopes) &&
459+ ((onlyProvider != "" && onlyProvider != provider.ProviderOpenCodeGo) || !needsOpenCodeGoRefresh(cached)) {
460+ return
461+ }
273462
274- if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {
275- // /v1/models on Anthropic is API-key only; OAuth tokens can
276- // also list models via the bearer header, but we skip OAuth
277- // here to avoid surprise rate-limit hits on subscription keys.
278- if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil {
279- all = append(all, live...)
463+ discovered := make(map[string][]provider.Model)
464+ authoritativeDiscovered := make(map[string]bool)
465+ discoveredScopes := make(map[string]string)
466+ eligibleProviders := make(map[string]struct{}, len(currentScopes))
467+ for name := range currentScopes {
468+ eligibleProviders[name] = struct{}{}
469+ }
470+ markEligible := func(name string) {
471+ eligibleProviders[name] = struct{}{}
472+ }
473+ recordDiscovery := func(name string, models []provider.Model, authoritative bool, scope string) {
474+ discovered[name] = append([]provider.Model(nil), models...)
475+ if authoritative {
476+ authoritativeDiscovered[name] = true
477+ }
478+ if scope != "" {
479+ discoveredScopes[name] = scope
480+ }
481+ }
482+ if onlyProvider == "" || onlyProvider == provider.ProviderAnthropic {
483+ if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderAnthropic, commandMode); err == nil && method == "apikey" {
484+ markEligible(provider.ProviderAnthropic)
485+ // /v1/models on Anthropic is API-key only; OAuth tokens can
486+ // also list models via the bearer header, but we skip OAuth
487+ // here to avoid surprise rate-limit hits on subscription keys.
488+ if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil {
489+ recordDiscovery(provider.ProviderAnthropic, live, false, "")
490+ }
491+ }
492+ }
493+ if onlyProvider == "" || onlyProvider == provider.ProviderOpenAI {
494+ if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderOpenAI, commandMode); err == nil && method == "apikey" {
495+ markEligible(provider.ProviderOpenAI)
496+ if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
497+ recordDiscovery(provider.ProviderOpenAI, live, false, "")
498+ }
280499 }
281500 }
282- if cred, method, err := resolveCredentialForBackground(ctx, "openai"); err == nil && method == "apikey" {
283- if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
284- all = append(all, live...)
501+ if onlyProvider == "" || onlyProvider == provider.ProviderOpenAICodex {
502+ if cred, method, accountID, err := resolveCredentialFull(ctx, provider.ProviderOpenAICodex, "", commandMode); err == nil && method == "oauth" {
503+ markEligible(provider.ProviderOpenAICodex)
504+ if live, err := provider.DiscoverOpenAICodex(ctx, cred, accountID, ""); err == nil {
505+ recordDiscovery(provider.ProviderOpenAICodex, live, accountID != "", accountID)
506+ }
285507 }
286508 }
287- if cred, method, accountID, err := resolveCredentialFull(ctx, "openai-codex", "", apiKeyCommandSkip); err == nil && method == "oauth" {
288- if live, err := provider.DiscoverOpenAICodex(ctx, cred, accountID, ""); err == nil {
289- all = filterModelsByProvider(all, "openai-codex")
290- authoritativeProviders = filterProviderNames(authoritativeProviders, "openai-codex")
291- delete(providerScopes, "openai-codex")
292- all = append(all, live...)
293- if accountID != "" {
294- authoritativeProviders = append(authoritativeProviders, "openai-codex")
295- providerScopes["openai-codex"] = accountID
509+ if onlyProvider == "" || onlyProvider == provider.ProviderOpenCodeGo {
510+ if openCodeGoMethod == "apikey" {
511+ markEligible(provider.ProviderOpenCodeGo)
512+ if live, err := discoverOpenCodeGoFn(ctx, openCodeGoCred, openCodeGoBaseURL); err == nil {
513+ recordDiscovery(provider.ProviderOpenCodeGo, live, true, credentialScopeForEndpoint(openCodeGoCred, openCodeGoBaseURL))
296514 }
297515 }
298516 }
299- if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {
300- if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
301- for i := range live {
302- live[i].Provider = "kimi"
303- live[i].Source = "live"
517+ if onlyProvider == "" || onlyProvider == provider.ProviderKimi {
518+ if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderKimi, commandMode); err == nil && method == "apikey" {
519+ markEligible(provider.ProviderKimi)
520+ if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
521+ for i := range live {
522+ live[i].Provider = provider.ProviderKimi
523+ live[i].Source = "live"
524+ }
525+ recordDiscovery(provider.ProviderKimi, live, false, "")
304526 }
305- all = append(all, live...)
306527 }
307528 }
308- if cred, method, err := resolveCredentialForBackground(ctx, "google"); err == nil && method == "apikey" {
309- if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
310- all = append(all, live...)
529+ if onlyProvider == "" || onlyProvider == provider.ProviderGoogle {
530+ if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderGoogle, commandMode); err == nil && method == "apikey" {
531+ markEligible(provider.ProviderGoogle)
532+ if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
533+ recordDiscovery(provider.ProviderGoogle, live, false, "")
534+ }
311535 }
312536 }
313- if _, _, err := resolveCredentialForBackground(ctx, "openrouter"); err == nil {
314- // /models is public; gate on a credential so the picker only
315- // fills with OpenRouter's hundreds of routes for users who use it.
316- if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {
317- all = append(all, live...)
537+ if onlyProvider == "" || onlyProvider == provider.ProviderOpenRouter {
538+ if _, _, err := resolveCredentialForCatalog(ctx, provider.ProviderOpenRouter, commandMode); err == nil {
539+ markEligible(provider.ProviderOpenRouter)
540+ // /models is public; gate on a credential so the picker only
541+ // fills with OpenRouter's hundreds of routes for users who use it.
542+ if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {
543+ recordDiscovery(provider.ProviderOpenRouter, live, false, "")
544+ }
318545 }
319546 }
320547
548+ if len(discovered) == 0 || ctx.Err() != nil {
549+ return
550+ }
551+
552+ modelCatalogMu.Lock()
553+ defer modelCatalogMu.Unlock()
554+ if ctx.Err() != nil {
555+ return
556+ }
557+ latestSnapshot, latestErr := provider.LoadCache(ModelCachePath())
558+ latest := latestSnapshot
559+ if latest.Version != provider.ModelCacheVersion {
560+ // Routing metadata from an older cache format is not safe to merge
561+ // into a newly discovered catalog. Treat it as empty, but compare the
562+ // raw snapshot below so a concurrent refresh is still detected.
563+ latest = provider.ModelCache{}
564+ }
565+ preserveFullRefreshFreshness := onlyProvider == "" && eligibleProviderDiscoveryIncomplete(eligibleProviders, discovered)
566+ var all []provider.Model
567+ var authoritativeProviders []string
568+ var providerScopes map[string]string
569+ _, latestHasOpenCodeGo := latestSnapshot.ProviderScopes[provider.ProviderOpenCodeGo]
570+ keepOpenCodeGo := !cacheSnapshotsEqual(cached, cachedErr, latestSnapshot, latestErr) && latestHasOpenCodeGo
571+ if keepOpenCodeGo {
572+ // Another runtime replaced the cache while this discovery was in
573+ // flight and owns newer OpenCode Go scope data. Keep its scope and
574+ // still publish any unrelated providers that this refresh
575+ // discovered. When the newer snapshot carries no OpenCode Go scope,
576+ // this refresh's own discovery is the only such data and is
577+ // published below instead of being discarded.
578+ delete(discovered, provider.ProviderOpenCodeGo)
579+ delete(authoritativeDiscovered, provider.ProviderOpenCodeGo)
580+ delete(discoveredScopes, provider.ProviderOpenCodeGo)
581+ }
582+ if len(discovered) == 0 {
583+ return
584+ }
585+ if keepOpenCodeGo {
586+ latest = filterCacheByProviderScopesExcept(latest, currentScopes, provider.ProviderOpenCodeGo)
587+ } else {
588+ latest = filterCacheByProviderScopes(latest, currentScopes)
589+ }
590+ all = append([]provider.Model(nil), latest.Models...)
591+ authoritativeProviders = append([]string(nil), latest.AuthoritativeProviders...)
592+ providerScopes = make(map[string]string, len(latest.ProviderScopes))
593+ for name, scope := range latest.ProviderScopes {
594+ providerScopes[name] = scope
595+ }
596+ for name, live := range discovered {
597+ all = filterModelsByProvider(all, name)
598+ authoritativeProviders = filterProviderNames(authoritativeProviders, name)
599+ delete(providerScopes, name)
600+ all = append(all, live...)
601+ if authoritativeDiscovered[name] {
602+ authoritativeProviders = append(authoritativeProviders, name)
603+ }
604+ if scope := discoveredScopes[name]; scope != "" {
605+ providerScopes[name] = scope
606+ }
607+ }
321608 if len(all) == 0 && len(authoritativeProviders) == 0 {
322609 return
323610 }
611+ if ctx.Err() != nil {
612+ return
613+ }
324614 provider.SetLiveModelsForProviders(all, authoritativeProviders)
615+ fetchedAt := time.Now().UTC()
616+ if onlyProvider != "" {
617+ if latest.Version == provider.ModelCacheVersion {
618+ // A partial refresh must not make an otherwise stale full
619+ // catalog look fresh to the next CLI refresh.
620+ fetchedAt = latest.FetchedAt
621+ } else {
622+ fetchedAt = time.Time{}
623+ }
624+ } else if preserveFullRefreshFreshness {
625+ // A failed eligible discovery must not make stale metadata appear
626+ // fresh merely because another provider refreshed successfully.
627+ fetchedAt = latest.FetchedAt
628+ }
629+ if ctx.Err() != nil {
630+ return
631+ }
325632 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
326- FetchedAt: time.Now().UTC(),
633+ Version: provider.ModelCacheVersion,
634+ FetchedAt: fetchedAt,
327635 Models: all,
328636 AuthoritativeProviders: authoritativeProviders,
329637 ProviderScopes: providerScopes,
M packages/agent/modelsync_test.go +926 -0
@@ -3,14 +3,18 @@ package agent
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "net/http"
89 "net/http/httptest"
910 "os"
1011 "path/filepath"
12+ "sync"
1113 "testing"
14+ "time"
1215
1316 "github.com/bnema/zut/packages/provider"
17+ "github.com/bnema/zut/packages/provider/auth"
1418 )
1519
1620 func TestFilterCacheByProviderScopesRemovesMismatchedProvider(t *testing.T) {
@@ -48,6 +52,81 @@ func TestFilterCacheByProviderScopesRetainsMatchingScopedProvider(t *testing.T)
4852 }
4953 }
5054
55+func 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+
68+func 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+
91+func 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+
103+func 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+
51130 func TestFilterCacheByProviderScopesDropsLegacyAuthoritativeCodex(t *testing.T) {
52131 cache := provider.ModelCache{
53132 Models: []provider.Model{{Provider: "openai-codex", ID: "legacy"}},
@@ -169,6 +248,776 @@ func TestValidateAndRepairConfig_UnknownModel(t *testing.T) {
169248 }
170249 }
171250
251+func 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+
272+func 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+
292+func 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+
313+func 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+
355+func 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+
381+func 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+
393+func 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+
409+func 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+
426+func 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+
469+func 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+
511+func 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+
560+func 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+
603+func 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+
635+func 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+
681+func 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+
707+func 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+
747+func 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+
793+func 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+
836+func 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+
918+func 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+
935+func 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+
987+func 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+
996+func 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+
1721021 func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *testing.T) {
1731022 home := t.TempDir()
1741023 t.Setenv("ZUT_HOME", home)
@@ -242,3 +1091,80 @@ func TestValidateAndRepairConfig_HappyPath(t *testing.T) {
2421091 t.Errorf("model mutated: %q", out.Model)
2431092 }
2441093 }
1094+
1095+func 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+}
M packages/agent/modes/interactive_rendering.go +8 -2
@@ -492,7 +492,10 @@ func (i *Interactive) redraw() {
492492 dialog = i.llamaDialog.Render(i.cfg.Theme, dialogWidth)
493493 case i.rescueDialog.Active():
494494 dialogID = "rescue"
495- dialog = i.rescueDialog.Render(i.cfg.Theme, dialogWidth)
495+ if i.rescueDialog.details {
496+ dialogID = "rescue-error"
497+ }
498+ dialog = i.rescueDialog.Render(i.cfg.Theme, dialogWidth, paneMax.ContentHeight())
496499 case i.sessionDialog.Active():
497500 dialogID = "sessions"
498501 i.sessionDialog.MaxRows = max(3, paneMax.ContentHeight()-5)
@@ -548,7 +551,10 @@ func (i *Interactive) redraw() {
548551 dialogTitle := floatingOverlayTitle(dialogID)
549552 dialogRemovedTopRows := 0
550553 if len(dialog) > 0 {
551- dialog = padDialogFrame(dialog)
554+ // The error viewer budgets every content row for its scroll viewport.
555+ if dialogID != "rescue-error" {
556+ dialog = padDialogFrame(dialog)
557+ }
552558 frameTitle, body, removedTopRows := floatingDialogBody(dialog)
553559 if frameTitle != "" {
554560 dialogTitle = frameTitle
M packages/agent/modes/rescue_dialog.go +59 -20
@@ -22,8 +22,10 @@ type rescueDialog struct {
2222 current string // currently active model id (excluded from view)
2323 query string
2424 failedAt string // failed provider/model pair, e.g. "kimi/kimi-for-coding"
25- reason string // short human-readable reason ("token expired", "rate limited", ...)
25+ reason string // complete diagnostic, including provider response
2626 prompt string // the user prompt that should be retried on Select
27+ details bool
28+ scroll int
2729 }
2830
2931 type rescueDialogAction struct {
@@ -43,6 +45,8 @@ func (d *rescueDialog) Open(current string, loggedInProviders []string, failedPr
4345 d.active = true
4446 d.current = current
4547 d.query = ""
48+ d.details = false
49+ d.scroll = 0
4650 d.reason = reason
4751 d.prompt = prompt
4852 d.failedAt = strings.TrimSpace(failedProvider + "/" + failedModel)
@@ -95,7 +99,7 @@ func (d *rescueDialog) refilter() {
9599
96100 // Render mirrors modelDialog so a rescue prompt feels identical to
97101 // every other picker in the TUI.
98-func (d *rescueDialog) Render(th tui.Theme, width int) []string {
102+func (d *rescueDialog) Render(th tui.Theme, width, height int) []string {
99103 if !d.Active() {
100104 return nil
101105 }
@@ -104,10 +108,34 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string {
104108 if d.failedAt != "" && d.failedAt != "/" {
105109 header = "rescue turn — " + d.failedAt + " failed"
106110 }
111+ if d.details {
112+ header = "provider error"
113+ }
107114 lines = append(lines, frameHeader(th, header, width))
108115
116+ // The picker keeps a bounded preview; the details view retains the entire
117+ // diagnostic and owns its viewport rather than letting the pane clip it.
118+ errorRows := wrapDialogTextRows(sanitizeSessionTreeText(d.reason), width)
119+ if d.details {
120+ hint := wrapDialogTextRows("↑/↓ or pgup/pgdn: scroll; tab/esc: back", width)
121+ hint = hint[:min(len(hint), max(0, height-1))]
122+ visible := max(1, height-len(hint))
123+ d.scroll = min(max(0, d.scroll), max(0, len(errorRows)-visible))
124+ for _, row := range hint {
125+ lines = append(lines, th.FGColor(th.Muted, row))
126+ }
127+ for _, row := range errorRows[d.scroll:min(len(errorRows), d.scroll+visible)] {
128+ lines = append(lines, th.FGColor(th.Warning, row))
129+ }
130+ return append(lines, frameRule(th, width))
131+ }
109132 if d.reason != "" {
110- lines = append(lines, th.FGColor(th.Warning, " "+d.reason))
133+ for _, row := range errorRows[:min(3, len(errorRows))] {
134+ lines = append(lines, th.FGColor(th.Warning, row))
135+ }
136+ for _, row := range wrapDialogTextRows("tab: full error", width) {
137+ lines = append(lines, th.FGColor(th.Muted, row))
138+ }
111139 }
112140
113141 hint := "retry this turn with another model (↑/↓, enter, esc to cancel)"
@@ -176,6 +204,28 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string {
176204 }
177205
178206 func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction {
207+ if k.Kind == tui.KeyTab {
208+ d.details = !d.details
209+ d.scroll = 0
210+ return rescueDialogAction{}
211+ }
212+ if d.details {
213+ switch k.Kind {
214+ case tui.KeyEsc:
215+ d.details = false
216+ case tui.KeyUp, tui.KeyMouseWheelUp:
217+ d.scroll = max(0, d.scroll-1)
218+ case tui.KeyDown, tui.KeyMouseWheelDown:
219+ d.scroll++
220+ case tui.KeyPageUp:
221+ d.scroll = max(0, d.scroll-8)
222+ case tui.KeyPageDown:
223+ d.scroll += 8
224+ case tui.KeyHome:
225+ d.scroll = 0
226+ }
227+ return rescueDialogAction{}
228+ }
179229 switch k.Kind {
180230 case tui.KeyUp:
181231 if d.cursor > 0 {
@@ -220,7 +270,7 @@ func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction {
220270
221271 // classifyRescueError inspects an agent error and decides whether
222272 // it's a recoverable provider failure that the user should be offered
223-// a rescue picker for, plus a short human-readable reason. Returns
273+// a rescue picker for, plus a category and the complete diagnostic. Returns
224274 // (false, "") for errors we should NOT auto-rescue (bad request,
225275 // context length, transcript serialization issues, etc.) so the
226276 // regular red status banner still surfaces them.
@@ -245,18 +295,18 @@ func classifyRescueError(err error) (bool, string) {
245295 strings.Contains(low, "no such host") ||
246296 strings.Contains(low, "tls handshake") ||
247297 strings.Contains(low, "eof") {
248- return true, "network failure: " + shortError(msg)
298+ return true, "network failure: " + msg
249299 }
250300
251301 switch {
252302 case containsAny(low, "http 401", " 401:", "invalid_authentication", "token expired", "api key appears to be invalid"):
253- return true, "authentication failed: " + shortError(msg)
303+ return true, "authentication failed: " + msg
254304 case containsAny(low, "http 403", " 403:", "permission denied", "forbidden"):
255- return true, "permission denied: " + shortError(msg)
305+ return true, "permission denied: " + msg
256306 case containsAny(low, "http 429", " 429:", "rate limit", "rate_limit", "too many requests", "quota"):
257- return true, "rate limited: " + shortError(msg)
307+ return true, "rate limited: " + msg
258308 case containsAny(low, "http 500", "http 502", "http 503", "http 504", " 500:", " 502:", " 503:", " 504:", "upstream connect error", "service unavailable", "internal server error", "bad gateway", "gateway timeout"):
259- return true, "provider unavailable: " + shortError(msg)
309+ return true, "provider unavailable: " + msg
260310 }
261311
262312 // Anything else (400 bad request, validation errors, etc.) is
@@ -273,17 +323,6 @@ func containsAny(haystack string, needles ...string) bool {
273323 return false
274324 }
275325
276-// shortError trims a long http-payload error to something readable in
277-// the rescue dialog header without dropping the most useful prefix.
278-func shortError(msg string) string {
279- msg = strings.TrimSpace(msg)
280- const max = 140
281- if len(msg) <= max {
282- return msg
283- }
284- return msg[:max] + "..."
285-}
286-
287326 // extractFailedProvider tries to pull the failing provider name out
288327 // of an error string ("kimi: http 401: ...", "openai: http 503: ...").
289328 // Returns "" when nothing recognisable is found.
A packages/agent/modes/rescue_dialog_test.go +56 -0
@@ -0,0 +1,56 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/tui"
9+)
10+
11+func TestRescueErrorPreservesFullDiagnostic(t *testing.T) {
12+ msg := `opencode-go: http 403: {"type":"error","error":{"type":"DataPolicyError","message":"` + strings.Repeat("policy details ", 30) + `review consent at https://example.com/workspace/go"}}`
13+ ok, reason := classifyRescueError(errors.New(msg))
14+ if !ok || !strings.Contains(reason, msg) {
15+ t.Fatalf("diagnostic was truncated: %q", reason)
16+ }
17+}
18+
19+func TestRescueDialogFullErrorView(t *testing.T) {
20+ d := newRescueDialog()
21+ reason := strings.Repeat("diagnostic 界 details ", 100) + "FINAL-DIAGNOSTIC"
22+ d.Open("", nil, "opencode-go", "example", reason, "prompt")
23+ d.HandleKey(tui.Key{Kind: tui.KeyTab})
24+ const width, height = 40, 10
25+ rows := d.Render(tui.Theme{}, width, height)
26+ assertRowsFitWidth(t, rows, width)
27+ if len(rows) > height+2 {
28+ t.Fatalf("error view exceeds viewport: %d", len(rows))
29+ }
30+ if !strings.Contains(strings.Join(rows, "\n"), "diagnostic") {
31+ t.Fatal("missing start of error")
32+ }
33+ for n := 0; n < 100; n++ {
34+ d.HandleKey(tui.Key{Kind: tui.KeyPageDown})
35+ rows = d.Render(tui.Theme{}, width, height)
36+ }
37+ if !strings.Contains(strings.Join(rows, "\n"), "FINAL-DIAGNOSTIC") {
38+ t.Fatal("cannot scroll to end of error")
39+ }
40+ d.HandleKey(tui.Key{Kind: tui.KeyHome})
41+ rows = d.Render(tui.Theme{}, width, height)
42+ if !strings.Contains(strings.Join(rows, "\n"), "diagnostic") {
43+ t.Fatal("cannot return to start")
44+ }
45+ if act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); act.Select || act.Close {
46+ t.Fatal("error view must not retry a model")
47+ }
48+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
49+ if !d.Active() || d.details {
50+ t.Fatal("escape should return to picker")
51+ }
52+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
53+ if d.Active() {
54+ t.Fatal("escape should dismiss picker")
55+ }
56+}
M packages/agent/rpc.go +36 -4
@@ -286,6 +286,17 @@ func (s *rpcServer) run(in io.Reader) error {
286286 return err
287287 }
288288
289+func resolveRPCModel(providerName, modelID string) (provider.Model, error) {
290+ model, err := provider.FindModel(providerName, modelID)
291+ if err == nil {
292+ return model, nil
293+ }
294+ if providerName == provider.ProviderOpenCodeGo && provider.AcceptsUnlistedModels(providerName) {
295+ return provider.DynamicOpenCodeGoModel(providerName, modelID, ""), nil
296+ }
297+ return provider.Model{}, err
298+}
299+
289300 // dispatch routes a command. Long-running commands (prompt, compact)
290301 // run on their own goroutine so the read loop stays responsive.
291302 func (s *rpcServer) dispatch(cmd, id string, raw []byte) {
@@ -341,6 +352,12 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) {
341352 s.writeResponse(id, cmd, nil)
342353
343354 case "set_model":
355+ if !s.turnMu.TryLock() {
356+ s.writeError(id, cmd, "runtime is busy")
357+ return
358+ }
359+ defer s.turnMu.Unlock()
360+
344361 var req struct {
345362 Model string `json:"model"`
346363 }
@@ -348,13 +365,28 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) {
348365 s.writeError(id, cmd, err.Error())
349366 return
350367 }
351- if _, err := provider.FindModel(s.provider, req.Model); err != nil {
368+ model := strings.TrimSpace(req.Model)
369+ if model == "" {
370+ s.writeError(id, cmd, "model must not be empty")
371+ return
372+ }
373+ metadata, err := resolveRPCModel(s.provider, model)
374+ if err != nil {
352375 s.writeError(id, cmd, err.Error())
353376 return
354377 }
355- s.agent.Model = req.Model
356- s.model = req.Model
357- s.writeResponse(id, cmd, map[string]any{"model": req.Model})
378+ if setter, ok := s.agent.Client.(provider.ModelMetadataSetter); ok {
379+ setter.SetModelMetadata(metadata)
380+ }
381+ if metadata.ContextWindow > 0 {
382+ s.agent.ContextWindow = metadata.ContextWindow
383+ }
384+ if metadata.MaxOutput > 0 {
385+ s.agent.MaxTokens = metadata.MaxOutput
386+ }
387+ s.agent.Model = model
388+ s.model = model
389+ s.writeResponse(id, cmd, map[string]any{"model": model})
358390
359391 case "set_reasoning":
360392 var req struct {
M packages/agent/rpc_reasoning_test.go +104 -0
@@ -6,8 +6,112 @@ import (
66 "testing"
77
88 "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
910 )
1011
12+func TestRPCSetModelRejectsBusyTurn(t *testing.T) {
13+ var out bytes.Buffer
14+ s := &rpcServer{
15+ provider: provider.ProviderOpenCodeGo,
16+ model: "old-model",
17+ agent: &core.Agent{Model: "old-model", ContextWindow: 111, MaxTokens: 222},
18+ out: &out,
19+ }
20+ s.turnMu.Lock()
21+ s.dispatch("set_model", "1", []byte(`{"model":"new-model"}`))
22+ s.turnMu.Unlock()
23+ if s.agent.Model != "old-model" || s.model != "old-model" {
24+ t.Fatalf("busy set_model changed active model: agent=%q server=%q", s.agent.Model, s.model)
25+ }
26+ if !strings.Contains(out.String(), `"success":false`) {
27+ t.Fatalf("busy set_model response = %q", out.String())
28+ }
29+}
30+
31+func TestRPCSetModelAllowsUnlistedOpenCodeGoModel(t *testing.T) {
32+ preserveProviderCatalog(t)
33+ var out bytes.Buffer
34+ const model = "muse-spark-1.2-contributor"
35+ s := &rpcServer{
36+ provider: "opencode-go",
37+ model: "kimi-k2.6",
38+ agent: &core.Agent{Model: "kimi-k2.6"},
39+ out: &out,
40+ }
41+ s.dispatch("set_model", "1", []byte(`{"model":" `+model+` "}`))
42+
43+ if s.agent.Model != model || s.model != model {
44+ t.Fatalf("model = agent:%q server:%q, want trimmed %q", s.agent.Model, s.model, model)
45+ }
46+ if s.agent.ContextWindow != 128000 || s.agent.MaxTokens != 16384 {
47+ t.Fatalf("bootstrap limits = context %d output %d, want 128000/16384", s.agent.ContextWindow, s.agent.MaxTokens)
48+ }
49+ if !strings.Contains(out.String(), `"success":true`) || !strings.Contains(out.String(), `"model":"`+model+`"`) {
50+ t.Fatalf("response = %q", out.String())
51+ }
52+}
53+
54+func TestRPCSetModelUsesKnownModelMetadata(t *testing.T) {
55+ preserveProviderCatalog(t)
56+ provider.SetLiveModels([]provider.Model{{
57+ Provider: provider.ProviderOpenCodeGo,
58+ ID: "served-model",
59+ ContextWindow: 300000,
60+ MaxOutput: 60000,
61+ }})
62+
63+ var out bytes.Buffer
64+ s := &rpcServer{
65+ provider: provider.ProviderOpenCodeGo,
66+ model: "old-model",
67+ agent: &core.Agent{Model: "old-model", ContextWindow: 1, MaxTokens: 2},
68+ out: &out,
69+ }
70+ s.dispatch("set_model", "1", []byte(`{"model":"served-model"}`))
71+ if s.agent.ContextWindow != 300000 || s.agent.MaxTokens != 60000 {
72+ t.Fatalf("known limits = context %d output %d, want 300000/60000", s.agent.ContextWindow, s.agent.MaxTokens)
73+ }
74+}
75+
76+func TestRPCSetModelPreservesExistingLimitsWhenMetadataIsMissing(t *testing.T) {
77+ preserveProviderCatalog(t)
78+ provider.SetLiveModels([]provider.Model{{
79+ Provider: provider.ProviderOpenCodeGo,
80+ ID: "metadata-without-limits",
81+ }})
82+
83+ var out bytes.Buffer
84+ s := &rpcServer{
85+ provider: provider.ProviderOpenCodeGo,
86+ model: "old-model",
87+ agent: &core.Agent{Model: "old-model", ContextWindow: 111, MaxTokens: 222},
88+ out: &out,
89+ }
90+ s.dispatch("set_model", "1", []byte(`{"model":"metadata-without-limits"}`))
91+ if s.agent.ContextWindow != 111 || s.agent.MaxTokens != 222 {
92+ t.Fatalf("missing limits changed agent values = context %d output %d, want 111/222", s.agent.ContextWindow, s.agent.MaxTokens)
93+ }
94+}
95+
96+func TestRPCSetModelRejectsBlankOpenCodeGoModel(t *testing.T) {
97+ for _, model := range []string{"", " "} {
98+ var out bytes.Buffer
99+ s := &rpcServer{
100+ provider: "opencode-go",
101+ model: "kimi-k2.6",
102+ agent: &core.Agent{Model: "kimi-k2.6"},
103+ out: &out,
104+ }
105+ s.dispatch("set_model", "1", []byte(`{"model":"`+model+`"}`))
106+ if s.agent.Model != "kimi-k2.6" || s.model != "kimi-k2.6" {
107+ t.Fatalf("blank model %q changed active model: agent=%q server=%q", model, s.agent.Model, s.model)
108+ }
109+ if !strings.Contains(out.String(), `"success":false`) {
110+ t.Fatalf("blank model %q response = %q", model, out.String())
111+ }
112+ }
113+}
114+
11115 func TestRPCSetReasoningMax(t *testing.T) {
12116 var out bytes.Buffer
13117 s := &rpcServer{agent: &core.Agent{}, out: &out}
A packages/agent/sdk/catalog_isolation_test.go +152 -0
@@ -0,0 +1,152 @@
1+package sdk
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "math"
9+ "net/http"
10+ "net/http/httptest"
11+ "path/filepath"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+func TestSDKRuntimeKeepsOpenCodeGoMetadataPerEndpoint(t *testing.T) {
19+ const modelID = "scoped-opencode-model"
20+ home := t.TempDir()
21+ t.Setenv("ZUT_HOME", home)
22+ provider.SetLiveModels(nil)
23+ provider.SetUserModels(nil)
24+ t.Cleanup(func() {
25+ provider.SetLiveModels(nil)
26+ provider.SetUserModels(nil)
27+ })
28+
29+ newServer := func(apiKey, wantEffort string) *httptest.Server {
30+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31+ if r.URL.Path != "/v1/chat/completions" {
32+ http.NotFound(w, r)
33+ return
34+ }
35+ if got := r.Header.Get("authorization"); got != "Bearer "+apiKey {
36+ t.Errorf("authorization = %q, want bearer key %q", got, apiKey)
37+ }
38+ body, err := io.ReadAll(r.Body)
39+ if err != nil {
40+ t.Errorf("read request: %v", err)
41+ }
42+ var request struct {
43+ Model string `json:"model"`
44+ ReasoningEffort string `json:"reasoning_effort"`
45+ }
46+ if err := json.Unmarshal(body, &request); err != nil {
47+ t.Errorf("decode request: %v", err)
48+ }
49+ if request.Model != modelID || request.ReasoningEffort != wantEffort {
50+ t.Errorf("request = model %q effort %q, want model %q effort %q", request.Model, request.ReasoningEffort, modelID, wantEffort)
51+ }
52+ w.Header().Set("Content-Type", "text/event-stream")
53+ _, _ = fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
54+ _, _ = fmt.Fprintln(w)
55+ _, _ = fmt.Fprintln(w, "data: [DONE]")
56+ }))
57+ }
58+ serverA := newServer("key-a", "minimal")
59+ defer serverA.Close()
60+ serverB := newServer("key-b", "")
61+ defer serverB.Close()
62+
63+ saveCache := func(server *httptest.Server, apiKey string, reasoning bool, effort string) {
64+ t.Helper()
65+ model := provider.Model{
66+ Provider: provider.ProviderOpenCodeGo,
67+ ID: modelID,
68+ Reasoning: reasoning,
69+ ContextWindow: 128000,
70+ MaxOutput: 8192,
71+ PriceInput: 100,
72+ PriceOutput: 200,
73+ BaseURL: server.URL + "/v1",
74+ }
75+ if reasoning {
76+ model.PriceInput = 10
77+ model.PriceOutput = 20
78+ }
79+ if reasoning {
80+ model.ReasoningLevelMap = map[string]string{"minimum": "minimum", "low": "", "medium": "", "high": "", "xhigh": "", "max": ""}
81+ model.ReasoningEffortMap = map[string]string{"minimum": effort}
82+ }
83+ if err := provider.SaveCache(filepath.Join(home, "models-cache.json"), provider.ModelCache{
84+ Version: provider.ModelCacheVersion,
85+ FetchedAt: time.Now().UTC(),
86+ Models: []provider.Model{model},
87+ AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
88+ ProviderScopes: map[string]string{provider.ProviderOpenCodeGo: testCredentialEndpointScope(apiKey, server.URL+"/v1")},
89+ }); err != nil {
90+ t.Fatal(err)
91+ }
92+ }
93+
94+ saveCache(serverA, "key-a", true, "minimal")
95+ runtimeA, err := New(Config{
96+ Provider: provider.ProviderOpenCodeGo,
97+ Model: modelID,
98+ APIKey: "key-a",
99+ BaseURL: serverA.URL + "/v1",
100+ Reasoning: "minimum",
101+ CWD: t.TempDir(),
102+ NoTools: true,
103+ })
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+
108+ // Replace the disk cache and process-global catalog with another scope.
109+ // Runtime A must continue using its own endpoint metadata afterward.
110+ saveCache(serverB, "key-b", false, "")
111+ runtimeB, err := New(Config{
112+ Provider: provider.ProviderOpenCodeGo,
113+ Model: modelID,
114+ APIKey: "key-b",
115+ BaseURL: serverB.URL + "/v1",
116+ Reasoning: "minimum",
117+ CWD: t.TempDir(),
118+ NoTools: true,
119+ })
120+ if err != nil {
121+ runtimeA.Close()
122+ t.Fatal(err)
123+ }
124+
125+ prompt := func(runtime *Runtime) {
126+ t.Helper()
127+ events, err := runtime.Prompt(context.Background(), "hello", nil)
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ for event := range events {
132+ if event.Type == "error" {
133+ t.Fatalf("prompt error: %s", event.Error)
134+ }
135+ }
136+ }
137+ prompt(runtimeB)
138+ prompt(runtimeA)
139+
140+ if got, want := runtimeB.Cost().CostUSD, 0.0003; math.Abs(got-want) > 1e-12 {
141+ t.Fatalf("runtime B cost = %v, want %v", got, want)
142+ }
143+ if got, want := runtimeA.Cost().CostUSD, 0.00003; math.Abs(got-want) > 1e-12 {
144+ t.Fatalf("runtime A cost = %v, want %v", got, want)
145+ }
146+ if err := runtimeB.Close(); err != nil {
147+ t.Fatal(err)
148+ }
149+ if err := runtimeA.Close(); err != nil {
150+ t.Fatal(err)
151+ }
152+}
A packages/agent/sdk/catalog_test.go +136 -0
@@ -0,0 +1,136 @@
1+package sdk
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "path/filepath"
12+ "strings"
13+ "testing"
14+ "time"
15+
16+ "github.com/bnema/zut/packages/provider"
17+)
18+
19+func testCredentialEndpointScope(credential, baseURL string) string {
20+ endpoint := strings.TrimRight(strings.TrimSpace(baseURL), "/")
21+ if endpoint == "" {
22+ endpoint = provider.OpenCodeGoDefaultBaseURL
23+ }
24+ digest := sha256.Sum256([]byte(credential + "\x00" + endpoint))
25+ return fmt.Sprintf("sha256:%x", digest)
26+}
27+
28+func TestNewLoadsCachedOpenCodeGoMetadataBeforeResolving(t *testing.T) {
29+ const (
30+ apiKey = "synthetic-opencode-key"
31+ modelID = "cached-opencode-model"
32+ )
33+ home := t.TempDir()
34+ t.Setenv("ZUT_HOME", home)
35+ provider.SetLiveModels(nil)
36+ provider.SetUserModels(nil)
37+ t.Cleanup(func() {
38+ provider.SetLiveModels(nil)
39+ provider.SetUserModels(nil)
40+ })
41+
42+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43+ if r.URL.Path != "/v1/chat/completions" {
44+ http.NotFound(w, r)
45+ return
46+ }
47+ if got := r.Header.Get("authorization"); got != "Bearer "+apiKey {
48+ t.Errorf("authorization = %q, want bearer API key", got)
49+ }
50+ body, err := io.ReadAll(r.Body)
51+ if err != nil {
52+ t.Errorf("read request: %v", err)
53+ }
54+ var request struct {
55+ Model string `json:"model"`
56+ ReasoningEffort string `json:"reasoning_effort"`
57+ }
58+ if err := json.Unmarshal(body, &request); err != nil {
59+ t.Errorf("decode request: %v", err)
60+ }
61+ if request.Model != modelID {
62+ t.Errorf("request model = %q, want %q", request.Model, modelID)
63+ }
64+ if request.ReasoningEffort != "minimal" {
65+ t.Errorf("request reasoning_effort = %q, want minimal", request.ReasoningEffort)
66+ }
67+ w.Header().Set("Content-Type", "text/event-stream")
68+ _, _ = fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}`)
69+ _, _ = fmt.Fprintln(w)
70+ _, _ = fmt.Fprintln(w, "data: [DONE]")
71+ }))
72+ defer server.Close()
73+
74+ cached := provider.Model{
75+ Provider: provider.ProviderOpenCodeGo,
76+ ID: modelID,
77+ DisplayName: "Cached OpenCode model",
78+ Reasoning: true,
79+ ReasoningLevelMap: map[string]string{"minimum": "minimum", "low": "", "medium": "", "high": "", "xhigh": "", "max": ""},
80+ ReasoningEffortMap: map[string]string{"minimum": "minimal"},
81+ ContextWindow: 128000,
82+ MaxOutput: 8192,
83+ BaseURL: server.URL + "/v1",
84+ Source: "live",
85+ }
86+ if err := provider.SaveCache(filepath.Join(home, "models-cache.json"), provider.ModelCache{
87+ Version: provider.ModelCacheVersion,
88+ FetchedAt: time.Now().UTC(),
89+ Models: []provider.Model{cached},
90+ AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
91+ ProviderScopes: map[string]string{provider.ProviderOpenCodeGo: testCredentialEndpointScope(apiKey, server.URL+"/v1")},
92+ }); err != nil {
93+ t.Fatal(err)
94+ }
95+
96+ runtime, err := New(Config{
97+ Provider: provider.ProviderOpenCodeGo,
98+ Model: " " + modelID + " ",
99+ APIKey: apiKey,
100+ BaseURL: server.URL + "/v1",
101+ Reasoning: "minimum",
102+ CWD: t.TempDir(),
103+ NoTools: true,
104+ })
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ defer runtime.Close()
109+
110+ models := runtime.ListModels()
111+ var found bool
112+ for _, model := range models {
113+ if model.ID == modelID {
114+ found = model.ContextWindow == 128000 && model.MaxOutput == 8192 && model.Reasoning
115+ }
116+ }
117+ if !found {
118+ t.Fatalf("SDK did not load cached OpenCode Go metadata: %+v", models)
119+ }
120+ if err := runtime.SetModel(" " + modelID + " "); err != nil {
121+ t.Fatal(err)
122+ }
123+ if runtime.Model() != modelID {
124+ t.Fatalf("SDK model = %q, want trimmed id", runtime.Model())
125+ }
126+
127+ events, err := runtime.Prompt(context.Background(), "hello", nil)
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ for event := range events {
132+ if event.Type == "error" {
133+ t.Fatalf("prompt error: %s", event.Error)
134+ }
135+ }
136+}
M packages/agent/sdk/reasoning_test.go +118 -0
@@ -1,12 +1,130 @@
11 package sdk
22
33 import (
4+ "context"
5+ "errors"
46 "testing"
57
68 "github.com/bnema/zut/packages/agent/tools"
79 "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
811 )
912
13+func TestNewContextHonorsCancellation(t *testing.T) {
14+ t.Setenv("ZUT_HOME", t.TempDir())
15+ t.Setenv("OPENCODE_API_KEY", "")
16+ ctx, cancel := context.WithCancel(context.Background())
17+ cancel()
18+ if _, err := NewContext(ctx, Config{Provider: provider.ProviderOpenCodeGo}); !errors.Is(err, context.Canceled) {
19+ t.Fatalf("NewContext error = %v, want context.Canceled", err)
20+ }
21+}
22+
23+func TestRuntimeSetModelAllowsUnlistedOpenCodeGoModel(t *testing.T) {
24+ const model = "muse-spark-1.2-contributor"
25+ r := &Runtime{
26+ provider: "opencode-go",
27+ model: "kimi-k2.6",
28+ agent: &core.Agent{Model: "kimi-k2.6"},
29+ }
30+ if err := r.SetModel(" " + model + " "); err != nil {
31+ t.Fatal(err)
32+ }
33+ if r.model != model || r.agent.Model != model {
34+ t.Fatalf("model = runtime:%q agent:%q, want trimmed %q", r.model, r.agent.Model, model)
35+ }
36+ if r.agent.ContextWindow != 128000 || r.agent.MaxTokens != 16384 {
37+ t.Fatalf("bootstrap limits = context %d output %d, want 128000/16384", r.agent.ContextWindow, r.agent.MaxTokens)
38+ }
39+}
40+
41+func TestRuntimeSetModelUpdatesAgentMetadata(t *testing.T) {
42+ const model = "served-opencode-model"
43+ r := &Runtime{
44+ provider: provider.ProviderOpenCodeGo,
45+ model: "old-model",
46+ agent: &core.Agent{Model: "old-model", ContextWindow: 1, MaxTokens: 2},
47+ modelCatalog: []provider.Model{{
48+ Provider: provider.ProviderOpenCodeGo,
49+ ID: model,
50+ ContextWindow: 200000,
51+ MaxOutput: 50000,
52+ }},
53+ }
54+ if err := r.SetModel(model); err != nil {
55+ t.Fatal(err)
56+ }
57+ if r.agent.ContextWindow != 200000 || r.agent.MaxTokens != 50000 {
58+ t.Fatalf("agent limits = context %d output %d, want 200000/50000", r.agent.ContextWindow, r.agent.MaxTokens)
59+ }
60+}
61+
62+func TestRuntimeSetModelPreservesExistingLimitsWhenMetadataIsMissing(t *testing.T) {
63+ const model = "metadata-without-limits"
64+ r := &Runtime{
65+ provider: provider.ProviderOpenCodeGo,
66+ model: "old-model",
67+ agent: &core.Agent{Model: "old-model", ContextWindow: 111, MaxTokens: 222},
68+ modelCatalog: []provider.Model{{
69+ Provider: provider.ProviderOpenCodeGo,
70+ ID: model,
71+ }},
72+ }
73+ if err := r.SetModel(model); err != nil {
74+ t.Fatal(err)
75+ }
76+ if r.agent.ContextWindow != 111 || r.agent.MaxTokens != 222 {
77+ t.Fatalf("missing limits changed agent values = context %d output %d, want 111/222", r.agent.ContextWindow, r.agent.MaxTokens)
78+ }
79+}
80+
81+func TestRuntimeSetModelRejectsUnknownAuthoritativeModel(t *testing.T) {
82+ r := &Runtime{
83+ provider: provider.ProviderOpenCodeGo,
84+ model: "served-model",
85+ modelCatalogAuthoritative: true,
86+ modelCatalog: []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "served-model"}},
87+ agent: &core.Agent{Model: "served-model"},
88+ }
89+ if err := r.SetModel("removed-model"); err == nil {
90+ t.Fatal("authoritative SDK snapshot accepted removed model")
91+ }
92+ if r.model != "served-model" || r.agent.Model != "served-model" {
93+ t.Fatalf("rejected switch changed model: runtime=%q agent=%q", r.model, r.agent.Model)
94+ }
95+}
96+
97+func TestRuntimeSetModelRejectsBlankOpenCodeGoModel(t *testing.T) {
98+ for _, model := range []string{"", " "} {
99+ r := &Runtime{
100+ provider: "opencode-go",
101+ model: "kimi-k2.6",
102+ agent: &core.Agent{Model: "kimi-k2.6"},
103+ }
104+ if err := r.SetModel(model); err == nil {
105+ t.Fatalf("SetModel(%q) returned nil", model)
106+ }
107+ if r.model != "kimi-k2.6" || r.agent.Model != "kimi-k2.6" {
108+ t.Fatalf("blank model %q changed active model: runtime=%q agent=%q", model, r.model, r.agent.Model)
109+ }
110+ }
111+}
112+
113+func TestRuntimeSetModelRejectsChangesWhileBusy(t *testing.T) {
114+ r := &Runtime{
115+ provider: "opencode-go",
116+ model: "old-model",
117+ activeCancel: func() {},
118+ agent: &core.Agent{Model: "old-model"},
119+ }
120+ if err := r.SetModel("new-model"); err != ErrBusy {
121+ t.Fatalf("SetModel while busy = %v, want ErrBusy", err)
122+ }
123+ if r.model != "old-model" || r.agent.Model != "old-model" {
124+ t.Fatalf("busy SetModel changed model: runtime=%q agent=%q", r.model, r.agent.Model)
125+ }
126+}
127+
10128 func TestRuntimeSetReasoningMax(t *testing.T) {
11129 r := &Runtime{agent: &core.Agent{}}
12130 if err := r.SetReasoning("max"); err != nil {
M packages/agent/sdk/sdk.go +72 -10
@@ -27,6 +27,7 @@ import (
2727 "context"
2828 "errors"
2929 "fmt"
30+ "strings"
3031 "sync"
3132
3233 "github.com/bnema/zut/packages/agent"
@@ -40,8 +41,8 @@ import (
4041 // defaults are read from $ZUT_HOME/config.json, env vars, and the
4142 // resolver chain (the same one the cli uses).
4243 type Config struct {
43- // Provider is "anthropic" or "openai". Empty = use the user's
44- // default from config.json or env.
44+ // Provider is a configured provider id such as "anthropic", "openai",
45+ // or "opencode-go". Empty = use the user's default from config.json or env.
4546 Provider string
4647
4748 // Model is the model id. Empty = use the provider's default.
@@ -98,6 +99,9 @@ type Runtime struct {
9899 provider string
99100 model string
100101 cwd string
102+ // modelCatalog is a private metadata snapshot for dynamic providers.
103+ modelCatalog []provider.Model
104+ modelCatalogAuthoritative bool
101105
102106 // activeCancel is set while a Prompt or Compact is running.
103107 activeCancel context.CancelFunc
@@ -110,6 +114,16 @@ type Runtime struct {
110114 // New constructs a Runtime from cfg. Returns an error if no
111115 // credential is available for the requested provider.
112116 func New(cfg Config) (*Runtime, error) {
117+ return NewContext(context.Background(), cfg)
118+}
119+
120+// NewContext constructs a Runtime from cfg and uses ctx for credential
121+// resolution and initial model catalog discovery. Canceling ctx stops setup
122+// and any refresh started for this runtime.
123+func NewContext(ctx context.Context, cfg Config) (*Runtime, error) {
124+ if ctx == nil {
125+ ctx = context.Background()
126+ }
113127 webSearchPolicy := subagents.WebSearchDeny
114128 for _, name := range cfg.Tools {
115129 if name == "web_search" {
@@ -134,7 +148,7 @@ func New(cfg Config) (*Runtime, error) {
134148 WebSearchPolicy: webSearchPolicy,
135149 NoSess: true, // SDK callers manage persistence themselves
136150 }
137- r, err := agent.Resolve(args, true)
151+ r, err := agent.ResolveSDK(ctx, args)
138152 if err != nil {
139153 return nil, err
140154 }
@@ -143,10 +157,12 @@ func New(cfg Config) (*Runtime, error) {
143157 }
144158 ag := r.NewAgent()
145159 return &Runtime{
146- agent: ag,
147- provider: r.Provider,
148- model: r.Model,
149- cwd: r.CWD,
160+ agent: ag,
161+ provider: r.Provider,
162+ model: r.Model,
163+ cwd: r.CWD,
164+ modelCatalog: r.ModelCatalogSnapshot(),
165+ modelCatalogAuthoritative: r.ModelCatalogIsAuthoritative(),
150166 }, nil
151167 }
152168
@@ -309,8 +325,48 @@ func (r *Runtime) SetModel(model string) error {
309325 if r.agent == nil {
310326 return fmt.Errorf("sdk: no agent")
311327 }
312- if _, err := provider.FindModel(r.provider, model); err != nil {
313- return err
328+ if r.activeCancel != nil {
329+ return ErrBusy
330+ }
331+ model = strings.TrimSpace(model)
332+ if model == "" {
333+ return fmt.Errorf("sdk: model must not be empty")
334+ }
335+
336+ var metadata provider.Model
337+ found := false
338+ if r.provider == provider.ProviderOpenCodeGo {
339+ for _, candidate := range r.modelCatalog {
340+ if candidate.ID == model {
341+ metadata = candidate
342+ found = true
343+ break
344+ }
345+ }
346+ if !found && r.modelCatalogAuthoritative {
347+ return fmt.Errorf("unknown model %q (provider=%q)", model, r.provider)
348+ }
349+ if !found {
350+ metadata = provider.DynamicOpenCodeGoModel(r.provider, model, "")
351+ }
352+ } else {
353+ var err error
354+ metadata, err = provider.FindModel(r.provider, model)
355+ if err != nil {
356+ return err
357+ }
358+ found = true
359+ }
360+ if found || r.provider == provider.ProviderOpenCodeGo {
361+ if setter, ok := r.agent.Client.(provider.ModelMetadataSetter); ok {
362+ setter.SetModelMetadata(metadata)
363+ }
364+ if metadata.ContextWindow > 0 {
365+ r.agent.ContextWindow = metadata.ContextWindow
366+ }
367+ if metadata.MaxOutput > 0 {
368+ r.agent.MaxTokens = metadata.MaxOutput
369+ }
314370 }
315371 r.agent.Model = model
316372 r.model = model
@@ -379,7 +435,13 @@ func (r *Runtime) Close() error {
379435 // ListModels returns every model known to the runtime for the
380436 // current provider (catalog + live discovery if cached).
381437 func (r *Runtime) ListModels() []ModelInfo {
382- models := provider.ModelsForProvider(r.Provider())
438+ r.mu.Lock()
439+ providerName := r.provider
440+ models := append([]provider.Model(nil), r.modelCatalog...)
441+ r.mu.Unlock()
442+ if providerName != provider.ProviderOpenCodeGo {
443+ models = provider.ModelsForProvider(providerName)
444+ }
383445 out := make([]ModelInfo, 0, len(models))
384446 for _, m := range models {
385447 out = append(out, ModelInfo{
M packages/agent/sdk/sdk_defaults_test.go +19 -1
@@ -1,6 +1,10 @@
11 package sdk
22
3-import "testing"
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+)
48
59 func TestRuntimeLeavesAgentStepsUnlimitedByDefault(t *testing.T) {
610 runtime, err := New(Config{
@@ -17,3 +21,17 @@ func TestRuntimeLeavesAgentStepsUnlimitedByDefault(t *testing.T) {
1721 t.Fatalf("max steps = %d, want unlimited", runtime.agent.MaxSteps)
1822 }
1923 }
24+
25+func TestNewContextWithCanceledContextAndAPIKeyFails(t *testing.T) {
26+ ctx, cancel := context.WithCancel(context.Background())
27+ cancel()
28+ _, err := NewContext(ctx, Config{
29+ Provider: "openai",
30+ Model: "gpt-5",
31+ APIKey: "synthetic-test-key",
32+ NoTools: true,
33+ })
34+ if !errors.Is(err, context.Canceled) {
35+ t.Fatalf("err = %v, want context.Canceled", err)
36+ }
37+}
M packages/agent/zutfile.go +132 -5
@@ -4,6 +4,7 @@ import (
44 "archive/tar"
55 "bytes"
66 "compress/gzip"
7+ "context"
78 "crypto/sha256"
89 "encoding/hex"
910 "encoding/json"
@@ -127,7 +128,6 @@ func runZutfileCommand(rawArgs []string, version string) (bool, error) {
127128 }
128129
129130 func runLocalZutfile(ref string, args Args, version string) error {
130- prepareRuntimeCatalog()
131131 zf, cleanup, err := loadZutfile(ref)
132132 if cleanup != nil {
133133 defer cleanup()
@@ -157,6 +157,9 @@ func runLocalZutfile(ref string, args Args, version string) error {
157157 if !allowed {
158158 return nil
159159 }
160+ userModels := LoadUserModels()
161+ catalogProvider, catalogModel := zutfileCatalogSelection(args, zf.Manifest, userModels)
162+ PrepareRuntimeCatalog(context.Background(), true, catalogProvider, args.APIKey, args.BaseURL, userModels, catalogModel)
160163 if err := applyZutfileModelRequirements(&args, zf.Manifest); err != nil {
161164 return err
162165 }
@@ -220,6 +223,56 @@ func zutInspect(ref string) error {
220223 })
221224 }
222225
226+// zutfileCatalogSelection derives the provider/model scope needed to refresh
227+// dynamic catalogs before manifest preferences are applied. Unknown preferred
228+// IDs remain useful as a model scope when the configured provider is dynamic.
229+func zutfileCatalogSelection(args Args, m ZutfileManifest, userModels []provider.Model) (string, string) {
230+ providerName := canonicalProvider(args.Provider)
231+ modelID := strings.TrimSpace(args.Model)
232+ if cfg, err := LoadConfig(); err == nil {
233+ cfg.applyActiveModelProfile()
234+ if providerName == "" {
235+ providerName = canonicalProvider(cfg.Provider)
236+ }
237+ if modelID == "" {
238+ modelID = strings.TrimSpace(cfg.Model)
239+ }
240+ }
241+ if providerName == "" {
242+ if _, ok := provider.CustomProviders()[provider.ProviderOpenCodeGo]; ok {
243+ providerName = provider.ProviderOpenCodeGo
244+ }
245+ }
246+ findProvider := func(id string) string {
247+ for _, model := range userModels {
248+ if strings.TrimSpace(model.ID) == id {
249+ return canonicalProvider(model.Provider)
250+ }
251+ }
252+ if model, err := provider.FindModel("", id); err == nil {
253+ return canonicalProvider(model.Provider)
254+ }
255+ return ""
256+ }
257+ if providerName == "" && modelID != "" {
258+ providerName = findProvider(modelID)
259+ }
260+ if modelID == "" {
261+ for _, preferred := range m.Model.Preferred {
262+ preferred = strings.TrimSpace(preferred)
263+ if preferred == "" {
264+ continue
265+ }
266+ modelID = preferred
267+ if providerName == "" {
268+ providerName = findProvider(preferred)
269+ }
270+ break
271+ }
272+ }
273+ return providerName, modelID
274+}
275+
223276 func applyZutfileModelRequirements(args *Args, m ZutfileManifest) error {
224277 minCtx := m.Model.MinContext
225278 requires := map[string]bool{}
@@ -286,9 +339,83 @@ func applyZutfileModelRequirements(args *Args, m ZutfileManifest) error {
286339 return fmt.Errorf("no catalog model satisfies the agent requirements")
287340 }
288341
289-func prepareRuntimeCatalog() {
290- LoadCachedModels()
291- LoadUserModels()
342+// PrepareRuntimeCatalog loads cached and user model metadata before a runtime
343+// is resolved, then refreshes every eligible provider in the foreground or
344+// background. The explicit endpoint is forwarded to account-scoped discovery
345+// so credentials for a private OpenCode Go proxy never reach the production
346+// endpoint. Callers pass the already-loaded user model slice so packaged-agent
347+// startup does not parse models.json twice.
348+func PrepareRuntimeCatalog(ctx context.Context, waitForRefresh bool, explicitProvider, explicitAPIKey, explicitBaseURL string, userModels []provider.Model, explicitModel ...string) {
349+ if ctx == nil {
350+ ctx = context.Background()
351+ }
352+ modelID := ""
353+ if len(explicitModel) > 0 {
354+ modelID = explicitModel[0]
355+ }
356+ catalogAPIKey := explicitAPIKey
357+ catalogProvider := explicitProvider
358+ if waitForRefresh && catalogAPIKey == "" {
359+ catalogAPIKey = synchronousOpenCodeGoAPIKey(ctx, explicitProvider, explicitAPIKey)
360+ if err := ctx.Err(); err != nil {
361+ return
362+ }
363+ if catalogAPIKey != "" && effectiveCatalogProvider(explicitProvider) == "" {
364+ catalogProvider = provider.ProviderOpenCodeGo
365+ }
366+ }
367+ modelCatalogMu.Lock()
368+ preparedProvider, preparedBaseURL := prepareRuntimeCatalog(catalogProvider, catalogAPIKey, explicitBaseURL, userModels, modelID)
369+ modelCatalogMu.Unlock()
370+ if waitForRefresh {
371+ refreshModelsWithContext(ctx, preparedProvider, catalogAPIKey, preparedBaseURL, "", apiKeyCommandExecute)
372+ } else {
373+ RefreshModelsAsync(preparedProvider, explicitAPIKey, preparedBaseURL)
374+ }
375+}
376+
377+func effectiveCatalogProvider(explicitProvider string) string {
378+ if providerName := canonicalProvider(explicitProvider); providerName != "" {
379+ return providerName
380+ }
381+ cfg, err := LoadConfig()
382+ if err != nil {
383+ return ""
384+ }
385+ cfg.applyActiveModelProfile()
386+ return canonicalProvider(cfg.Provider)
387+}
388+
389+func effectiveCatalogBaseURL(explicitProvider, explicitModel, explicitBaseURL string, userModels []provider.Model) string {
390+ baseURL := strings.TrimSpace(explicitBaseURL)
391+ if baseURL != "" || explicitProvider != provider.ProviderOpenCodeGo {
392+ return baseURL
393+ }
394+ modelID := strings.TrimSpace(explicitModel)
395+ if modelID == "" {
396+ if cfg, err := LoadConfig(); err == nil {
397+ cfg.applyActiveModelProfile()
398+ modelID = strings.TrimSpace(cfg.Model)
399+ }
400+ }
401+ for _, model := range userModels {
402+ if model.Provider != provider.ProviderOpenCodeGo {
403+ continue
404+ }
405+ if modelID == "" || strings.TrimSpace(model.ID) == modelID {
406+ return strings.TrimSpace(model.BaseURL)
407+ }
408+ }
409+ if cfg, ok := provider.CustomProviders()[provider.ProviderOpenCodeGo]; ok {
410+ return strings.TrimSpace(cfg.BaseURL)
411+ }
412+ return ""
413+}
414+
415+func prepareRuntimeCatalog(explicitProvider, explicitAPIKey, explicitBaseURL string, userModels []provider.Model, explicitModel string) (string, string) {
416+ explicitProvider = effectiveCatalogProvider(explicitProvider)
417+ explicitBaseURL = effectiveCatalogBaseURL(explicitProvider, explicitModel, explicitBaseURL, userModels)
418+ loadCachedModels(modelProviderScopes(explicitProvider, explicitAPIKey, explicitBaseURL))
292419 if cps := provider.CustomProviders(); len(cps) > 0 {
293420 var names []string
294421 for name := range cps {
@@ -299,7 +426,7 @@ func prepareRuntimeCatalog() {
299426 auth.SetExtraAPIKeyProviders(names)
300427 }
301428 ValidateAndRepairConfig()
302- RefreshModelsAsync()
429+ return explicitProvider, explicitBaseURL
303430 }
304431
305432 func zutPack(dir, out string) error {
M packages/provider/anthropic.go +52 -19
@@ -10,12 +10,25 @@ import (
1010 "net/http"
1111 "os"
1212 "strings"
13+ "sync"
1314 "time"
1415 )
1516
1617 const anthropicDefaultBaseURL = "https://api.anthropic.com"
1718 const anthropicAPIVersion = "2023-06-01"
1819
20+// anthropicMessagesURL builds the Messages endpoint for an API root. A base
21+// URL that already ends in a numeric version (for example /v1) gets
22+// /messages appended directly; a bare or provider-specific root gets the
23+// conventional /v1/messages suffix.
24+func anthropicMessagesURL(baseURL string) string {
25+ baseURL = strings.TrimRight(baseURL, "/")
26+ if versionSegmentSuffix.MatchString(baseURL) {
27+ return baseURL + "/messages"
28+ }
29+ return baseURL + "/v1/messages"
30+}
31+
1932 // Stealth identity used when talking to Anthropic via subscription OAuth.
2033 // These values mimic the official Claude Code CLI so Anthropic's edge
2134 // accepts the request; diverging from them causes 429 rate_limit_error
@@ -69,6 +82,9 @@ type anthropicClient struct {
6982
7083 // headers carries extra request headers (e.g. Kimi Code's X-Msh-*).
7184 headers map[string]string
85+
86+ modelOverridesMu sync.RWMutex
87+ modelOverrides map[string]Model
7288 }
7389
7490 // NewAnthropic creates an Anthropic client using an API key. baseURL may be empty.
@@ -102,6 +118,34 @@ func (c *anthropicClient) Name() string {
102118 return "anthropic"
103119 }
104120
121+func (c *anthropicClient) SetModelMetadata(model Model) {
122+ model = cloneModel(model)
123+ c.modelOverridesMu.Lock()
124+ if c.modelOverrides == nil {
125+ c.modelOverrides = make(map[string]Model)
126+ }
127+ c.modelOverrides[model.ID] = model
128+ c.modelOverridesMu.Unlock()
129+}
130+
131+func (c *anthropicClient) modelForRequest(id string) (Model, error) {
132+ id = strings.TrimSpace(id)
133+ c.modelOverridesMu.RLock()
134+ model, ok := c.modelOverrides[id]
135+ if ok {
136+ model = cloneModel(model)
137+ }
138+ c.modelOverridesMu.RUnlock()
139+ if ok {
140+ return model, nil
141+ }
142+ model, err := FindModel(c.Name(), id)
143+ if err != nil {
144+ model, err = FindModel("", id)
145+ }
146+ return model, err
147+}
148+
105149 // ---- wire types ----
106150
107151 type anthTextBlock struct {
@@ -210,18 +254,8 @@ func usesAdaptiveThinking(m Model) bool {
210254 // ---- request building ----
211255
212256 func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
213- // Look up under the client's actual provider id, which may be "anthropic"
214- // (default) or a third party that speaks the Anthropic Messages API
215- // (kimi, fireworks, minimax, vercel-ai-gateway, ...). Falling back to a
216- // provider-agnostic lookup keeps things working for catalog-less
217- // configurations (e.g. user passes --model on an obscure third party).
218- m, err := FindModel(c.Name(), req.Model)
219- if err != nil {
220- if m2, err2 := FindModel("", req.Model); err2 == nil {
221- m = m2
222- err = nil
223- }
224- }
257+ req.Model = strings.TrimSpace(req.Model)
258+ m, err := c.modelForRequest(req.Model)
225259 if err != nil {
226260 return nil, err
227261 }
@@ -552,8 +586,9 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event
552586 }
553587 }
554588
589+ endpoint := anthropicMessagesURL(c.baseURL)
555590 newReq := func() (*http.Request, error) {
556- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
591+ httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
557592 if err != nil {
558593 return nil, err
559594 }
@@ -580,6 +615,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event
580615 for k, v := range c.headers {
581616 httpReq.Header.Set(k, v)
582617 }
618+ setOpenCodeGoHeaders(httpReq.Header, c.Name(), req.Context)
583619 return httpReq, nil
584620 }
585621
@@ -602,12 +638,9 @@ func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, re
602638 defer close(out)
603639 defer resp.Body.Close()
604640
605- // Same lookup-by-actual-provider-id pattern as buildRequest, so cost
606- // calculation works for third-party Anthropic-Messages endpoints.
607- model, _ := FindModel(c.Name(), req.Model)
608- if model.ID == "" {
609- model, _ = FindModel("", req.Model)
610- }
641+ // Use the same runtime-owned metadata as buildRequest so cost and
642+ // capability handling stay isolated from other SDK runtimes.
643+ model, _ := c.modelForRequest(req.Model)
611644 out <- EventStart{Model: req.Model, Provider: c.Name()}
612645
613646 raw := make(chan sseEvent, 16)
M packages/provider/astra_test.go +1 -1
@@ -30,7 +30,7 @@ func TestAstraReleasedMetadata(t *testing.T) {
3030 usage Usage
3131 want float64
3232 }{
33- {"at_threshold", Usage{InputTokens: 100000, CacheReadTokens: 100000, CacheWriteTokens: 72000, OutputTokens: 1000}, 2.05},
33+ {"at_threshold", Usage{InputTokens: 100000, CacheReadTokens: 100000, CacheWriteTokens: 72000, OutputTokens: 1000}, 4.075},
3434 {"above_threshold", Usage{InputTokens: 100000, CacheReadTokens: 100001, CacheWriteTokens: 72000, OutputTokens: 1000}, 4.075002},
3535 } {
3636 t.Run(tc.name, func(t *testing.T) {
M packages/provider/cache.go +15 -4
@@ -10,14 +10,22 @@ import (
1010
1111 // ModelCache is the on-disk shape for discovered models.
1212 type ModelCache struct {
13+ Version int `json:"version,omitempty"`
1314 FetchedAt time.Time `json:"fetched_at"`
1415 Models []Model `json:"models"`
1516 AuthoritativeProviders []string `json:"authoritative_providers,omitempty"`
1617 ProviderScopes map[string]string `json:"provider_scopes,omitempty"`
1718 }
1819
19-// CacheTTL is how long a discovered list is considered fresh.
20-const CacheTTL = 6 * time.Hour
20+// ModelCacheVersion invalidates caches created before OpenCode Go protocol
21+// routing was based on models.dev adapters. Older caches must not be loaded as
22+// routing metadata because they can silently select the wrong wire protocol.
23+const ModelCacheVersion = 4
24+
25+// CacheTTL is how long a discovered list is considered fresh. Model metadata
26+// and availability change less often than a typical process starts, so keep
27+// the synchronized catalog for one day before querying its sources again.
28+const CacheTTL = 24 * time.Hour
2129
2230 // LoadCache reads the model cache from path. Returns an empty ModelCache
2331 // (no error) if the file does not exist.
@@ -43,6 +51,9 @@ func LoadCache(path string) (ModelCache, error) {
4351
4452 // SaveCache writes the cache atomically.
4553 func SaveCache(path string, c ModelCache) error {
54+ if c.Version == 0 {
55+ c.Version = ModelCacheVersion
56+ }
4657 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
4758 return err
4859 }
@@ -57,9 +68,9 @@ func SaveCache(path string, c ModelCache) error {
5768 return os.Rename(tmp, path)
5869 }
5970
60-// IsFresh reports whether the cache was fetched within CacheTTL.
71+// IsFresh reports whether the current-format cache was fetched within CacheTTL.
6172 func (c ModelCache) IsFresh() bool {
62- if c.FetchedAt.IsZero() {
73+ if c.Version != ModelCacheVersion || c.FetchedAt.IsZero() {
6374 return false
6475 }
6576 return time.Since(c.FetchedAt) < CacheTTL
M packages/provider/cache_test.go +20 -0
@@ -5,6 +5,7 @@ import (
55 "path/filepath"
66 "runtime"
77 "testing"
8+ "time"
89 )
910
1011 func TestSaveCacheUsesRestrictivePermissions(t *testing.T) {
@@ -22,4 +23,23 @@ func TestSaveCacheUsesRestrictivePermissions(t *testing.T) {
2223 if got := info.Mode().Perm(); got != 0o600 {
2324 t.Fatalf("cache permissions = %04o, want 0600", got)
2425 }
26+ loaded, err := LoadCache(path)
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if loaded.Version != ModelCacheVersion {
31+ t.Fatalf("cache version = %d, want %d", loaded.Version, ModelCacheVersion)
32+ }
33+}
34+
35+func TestModelCacheUsesTwentyFourHourTTL(t *testing.T) {
36+ if !(ModelCache{Version: ModelCacheVersion, FetchedAt: time.Now().Add(-23 * time.Hour)}).IsFresh() {
37+ t.Fatal("cache from 23 hours ago should be fresh")
38+ }
39+ if (ModelCache{Version: ModelCacheVersion, FetchedAt: time.Now().Add(-25 * time.Hour)}).IsFresh() {
40+ t.Fatal("cache from 25 hours ago should be stale")
41+ }
42+ if (ModelCache{Version: ModelCacheVersion - 1, FetchedAt: time.Now()}).IsFresh() {
43+ t.Fatal("old-version cache should not be fresh")
44+ }
2545 }
M packages/provider/catalog_builtin.go +4 -22
@@ -6,7 +6,9 @@ package provider
66 //
77 // This file is data; the values are tracked verbatim from each
88 // vendor's own public pricing / model-list pages. They drift; users
9-// override via $ZUT_HOME/models.json (user entries win).
9+// override via $ZUT_HOME/models.json (user entries win). OpenCode Go is
10+// intentionally omitted because its catalog is synchronized at runtime from
11+// models.dev and the provider's /v1/models endpoint.
1012 //
1113 // Curated entries in models.go (anthropic / openai / openai-codex /
1214 // the small seed list for kimi / deepseek / google) are not duplicated
@@ -302,28 +304,8 @@ var builtinCatalog = []Model{
302304 {Provider: "opencode", ID: "gpt-5-nano", DisplayName: "GPT-5 Nano", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.05, PriceOutput: 0.4, PriceCacheRead: 0.005, BaseURL: "https://opencode.ai/zen/v1"},
303305 {Provider: "opencode", ID: "gpt-5.1", DisplayName: "GPT-5.1", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 1.07, PriceOutput: 8.5, PriceCacheRead: 0.107, BaseURL: "https://opencode.ai/zen/v1"},
304306 {Provider: "opencode", ID: "gpt-5.1-codex", DisplayName: "GPT-5.1 Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 1.07, PriceOutput: 8.5, PriceCacheRead: 0.107, BaseURL: "https://opencode.ai/zen/v1"},
305- // ----- opencode-go -----
306- {Provider: "opencode-go", ID: "deepseek-v4-flash", DisplayName: "DeepSeek V4 Flash", ContextWindow: 1000000, MaxOutput: 384000, Reasoning: true, PriceInput: 0.14, PriceOutput: 0.28, PriceCacheRead: 0.0028, BaseURL: "https://opencode.ai/zen/go/v1"},
307- {Provider: "opencode-go", ID: "deepseek-v4-pro", DisplayName: "DeepSeek V4 Pro", ContextWindow: 1000000, MaxOutput: 384000, Reasoning: true, PriceInput: 1.74, PriceOutput: 3.48, PriceCacheRead: 0.0145, BaseURL: "https://opencode.ai/zen/go/v1"},
308- {Provider: "opencode-go", ID: "glm-5", DisplayName: "GLM-5", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1, PriceOutput: 3.2, PriceCacheRead: 0.2, BaseURL: "https://opencode.ai/zen/go/v1"},
309- {Provider: "opencode-go", ID: "glm-5.1", DisplayName: "GLM-5.1", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1.4, PriceOutput: 4.4, PriceCacheRead: 0.26, BaseURL: "https://opencode.ai/zen/go/v1"},
310- {Provider: "opencode-go", ID: "glm-5.2", DisplayName: "GLM-5.2", ContextWindow: 1024000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.91, PriceOutput: 2.86, PriceCacheRead: 0.169, BaseURL: "https://opencode.ai/zen/go/v1"},
311- {Provider: "opencode-go", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, PriceCacheWrite: 0.25, PriceTierInputTokens: 272000, PriceInputAbove: 0.4, PriceOutputAbove: 1.8, PriceCacheReadAbove: 0.04, PriceCacheWriteAbove: 0.5, BaseURL: "https://opencode.ai/zen/go/v1"},
312- {Provider: "opencode-go", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 500000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.3, BaseURL: "https://opencode.ai/zen/go/v1"},
313- {Provider: "opencode-go", ID: "kimi-k2.5", DisplayName: "Kimi K2.5", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.6, PriceOutput: 3, PriceCacheRead: 0.1, BaseURL: "https://opencode.ai/zen/go/v1"},
314- {Provider: "opencode-go", ID: "kimi-k2.6", DisplayName: "Kimi K2.6", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.16, BaseURL: "https://opencode.ai/zen/go/v1"},
315- {Provider: "opencode-go", ID: "kimi-k2.7-code", DisplayName: "Kimi K2.7 Code", ContextWindow: 262144, MaxOutput: 16384, Reasoning: true, PriceInput: 0.74, PriceOutput: 3.5, PriceCacheRead: 0.15, BaseURL: "https://opencode.ai/zen/go/v1"},
316- {Provider: "opencode-go", ID: "kimi-k3", DisplayName: "Kimi K3", ContextWindow: 1048576, MaxOutput: 131072, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, BaseURL: "https://opencode.ai/zen/go/v1"},
317- {Provider: "opencode-go", ID: "mimo-v2.5", DisplayName: "MiMo V2.5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.4, PriceOutput: 2, PriceCacheRead: 0.08, BaseURL: "https://opencode.ai/zen/go/v1"},
318- {Provider: "opencode-go", ID: "mimo-v2.5-pro", DisplayName: "MiMo V2.5 Pro", ContextWindow: 1048576, MaxOutput: 128000, Reasoning: true, PriceInput: 1, PriceOutput: 3, PriceCacheRead: 0.2, BaseURL: "https://opencode.ai/zen/go/v1"},
319- {Provider: "opencode-go", ID: "minimax-m2.5", DisplayName: "MiniMax M2.5", ContextWindow: 204800, MaxOutput: 65536, Reasoning: true, PriceInput: 0.3, PriceOutput: 1.2, PriceCacheRead: 0.03, BaseURL: "https://opencode.ai/zen/go"},
320- {Provider: "opencode-go", ID: "minimax-m2.7", DisplayName: "MiniMax M2.7", ContextWindow: 204800, MaxOutput: 131072, Reasoning: true, PriceInput: 0.3, PriceOutput: 1.2, PriceCacheRead: 0.06, BaseURL: "https://opencode.ai/zen/go/v1"},
321- {Provider: "opencode-go", ID: "minimax-m3", DisplayName: "MiniMax M3", ContextWindow: 524288, MaxOutput: 512000, Reasoning: true, PriceInput: 0.3, PriceOutput: 1.2, PriceCacheRead: 0.06, BaseURL: "https://opencode.ai/zen/go/v1"},
322- {Provider: "opencode-go", ID: "qwen3.5-plus", DisplayName: "Qwen3.5 Plus", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, PriceCacheWrite: 0.25, BaseURL: "https://opencode.ai/zen/go/v1"},
323- {Provider: "opencode-go", ID: "qwen3.6-plus", DisplayName: "Qwen3.6 Plus", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.5, PriceOutput: 3, PriceCacheRead: 0.05, PriceCacheWrite: 0.625, BaseURL: "https://opencode.ai/zen/go/v1"},
324- {Provider: "opencode-go", ID: "qwen3.7-plus", DisplayName: "Qwen3.7 Plus", ContextWindow: 1000000, MaxOutput: 65536, Reasoning: true, PriceInput: 0.32, PriceOutput: 1.28, PriceCacheRead: 0.064, PriceCacheWrite: 0.4, BaseURL: "https://opencode.ai/zen/go/v1"},
325- {Provider: "opencode-go", ID: "qwen3.7-max", DisplayName: "Qwen3.7 Max", ContextWindow: 1000000, MaxOutput: 65536, Reasoning: true, PriceInput: 1.25, PriceOutput: 3.75, PriceCacheRead: 0.25, PriceCacheWrite: 1.5625, BaseURL: "https://opencode.ai/zen/go/v1"},
326307 // openrouter: discovered live via DiscoverOpenRouter, none baked in.
308+ // opencode-go: discovered live from models.dev and the provider API.
327309 // ----- together -----
328310 {Provider: "together", ID: "MiniMaxAI/MiniMax-M2.5", DisplayName: "MiniMax-M2.5", ContextWindow: 204800, MaxOutput: 131072, Reasoning: true, PriceInput: 0.3, PriceOutput: 1.2, PriceCacheRead: 0.06, BaseURL: "https://api.together.ai/v1"},
329311 {Provider: "together", ID: "MiniMaxAI/MiniMax-M2.7", DisplayName: "MiniMax-M2.7", ContextWindow: 202752, MaxOutput: 131072, Reasoning: true, PriceInput: 0.3, PriceOutput: 1.2, PriceCacheRead: 0.06, BaseURL: "https://api.together.ai/v1"},
M packages/provider/discover.go +301 -0
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "io"
88 "net/http"
9+ "sort"
910 "strconv"
1011 "strings"
1112 "time"
@@ -113,6 +114,306 @@ func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error
113114 return out, nil
114115 }
115116
117+const (
118+ modelsDevAPIURL = "https://models.dev/api.json"
119+ OpenCodeGoDefaultBaseURL = "https://opencode.ai/zen/go/v1"
120+)
121+
122+// DiscoverOpenCodeGo joins the model ids published by OpenCode Go with
123+// metadata from models.dev. The /models endpoint is authoritative for the
124+// provider's published catalog, but it does not prove account entitlement;
125+// consent, region, quota, and other access checks still happen at inference.
126+// models.dev supplies display names, limits, reasoning capabilities, pricing,
127+// and the adapter that selects the wire protocol.
128+func DiscoverOpenCodeGo(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
129+ return discoverOpenCodeGo(ctx, apiKey, baseURL, modelsDevAPIURL)
130+}
131+
132+// discoverOpenCodeGo is split out so tests can use a local models.dev fixture.
133+func discoverOpenCodeGo(ctx context.Context, apiKey, baseURL, metadataURL string) ([]Model, error) {
134+ if baseURL == "" {
135+ baseURL = OpenCodeGoDefaultBaseURL
136+ }
137+ if metadataURL == "" {
138+ metadataURL = modelsDevAPIURL
139+ }
140+
141+ client := &http.Client{Timeout: 15 * time.Second}
142+ // Models.dev is enrichment only. Keep serving IDs even when its public
143+ // catalog is temporarily unavailable or has not published this provider.
144+ var metadata modelsDevProvider
145+ if metadataBody, err := fetchDiscoveryJSON(ctx, client, metadataURL, ""); err == nil {
146+ var providers map[string]modelsDevProvider
147+ if err := json.Unmarshal(metadataBody, &providers); err == nil {
148+ metadata = providers[ProviderOpenCodeGo]
149+ }
150+ }
151+
152+ modelsBody, err := fetchDiscoveryJSON(ctx, client, strings.TrimRight(baseURL, "/")+"/models", "Bearer "+apiKey)
153+ if err != nil {
154+ return nil, fmt.Errorf("%s models: %w", ProviderOpenCodeGo, err)
155+ }
156+ var page struct {
157+ Data []struct {
158+ ID string `json:"id"`
159+ } `json:"data"`
160+ }
161+ if err := json.Unmarshal(modelsBody, &page); err != nil {
162+ return nil, fmt.Errorf("%s models parse: %w", ProviderOpenCodeGo, err)
163+ }
164+
165+ out := make([]Model, 0, len(page.Data))
166+ for _, live := range page.Data {
167+ id := strings.TrimSpace(live.ID)
168+ if id == "" {
169+ continue
170+ }
171+ model := Model{
172+ Provider: ProviderOpenCodeGo,
173+ ID: id,
174+ DisplayName: id,
175+ BaseURL: strings.TrimRight(baseURL, "/"),
176+ Source: "live",
177+ API: openCodeGoAPIForModel(id),
178+ }
179+ if details, ok := metadata.Models[id]; ok {
180+ model.API = modelsDevAPIForModel(metadata, details, id)
181+ if details.Name != "" {
182+ model.DisplayName = details.Name
183+ }
184+ model.ContextWindow = details.Limit.Context
185+ model.MaxOutput = details.Limit.Output
186+ model.Reasoning = details.Reasoning
187+ if details.ReasoningOptions != nil {
188+ model.ReasoningLevelMap, model.ReasoningEffortMap = modelsDevReasoningMaps(details.Reasoning, *details.ReasoningOptions)
189+ }
190+ model.PriceInput = details.Cost.Input
191+ model.PriceOutput = details.Cost.Output
192+ model.PriceCacheRead = details.Cost.CacheRead
193+ model.PriceCacheWrite = details.Cost.CacheWrite
194+ model.PriceTiers = modelsDevPriceTiers(details.Cost.Tiers)
195+ if len(model.PriceTiers) > 0 {
196+ // Keep the legacy fields pointed at the first threshold for
197+ // callers that do not yet understand PriceTiers.
198+ first := model.PriceTiers[0]
199+ model.PriceTierInputTokens = first.InputTokens
200+ model.PriceInputAbove = first.PriceInput
201+ model.PriceOutputAbove = first.PriceOutput
202+ model.PriceCacheReadAbove = first.PriceCacheRead
203+ model.PriceCacheWriteAbove = first.PriceCacheWrite
204+ }
205+ }
206+ out = append(out, model)
207+ }
208+ return out, nil
209+}
210+
211+// modelsDevProvider and its nested types intentionally cover only the stable
212+// subset needed by provider.Model. models.dev adds fields over time, and the
213+// JSON decoder safely ignores those additions.
214+type modelsDevProvider struct {
215+ NPM string `json:"npm"`
216+ Models map[string]modelsDevModel `json:"models"`
217+}
218+
219+type modelsDevModel struct {
220+ Name string `json:"name"`
221+ Reasoning bool `json:"reasoning"`
222+ ReasoningOptions *[]modelsDevReasoningOption `json:"reasoning_options"`
223+ Provider *modelsDevModelProviderOverride `json:"provider"`
224+ Limit modelsDevLimit `json:"limit"`
225+ Cost modelsDevCost `json:"cost"`
226+}
227+
228+type modelsDevModelProviderOverride struct {
229+ NPM string `json:"npm"`
230+}
231+
232+// modelsDevAPIForModel resolves the models.dev adapter without trusting its
233+// endpoint URL. A model-level adapter overrides the provider default; when
234+// neither is published, retain the narrow model-name bootstrap heuristic.
235+// Unknown explicit adapters are returned unchanged so the router can fail
236+// with an actionable unsupported-protocol error instead of silently selecting
237+// Chat Completions.
238+func modelsDevAPIForModel(provider modelsDevProvider, model modelsDevModel, id string) string {
239+ npm := ""
240+ if model.Provider != nil {
241+ npm = strings.TrimSpace(model.Provider.NPM)
242+ }
243+ if npm == "" {
244+ npm = strings.TrimSpace(provider.NPM)
245+ }
246+ if npm == "" {
247+ return openCodeGoAPIForModel(id)
248+ }
249+ switch strings.ToLower(npm) {
250+ case "@ai-sdk/openai":
251+ return APIResponses
252+ case "@ai-sdk/openai-compatible":
253+ return APICompletions
254+ case "@ai-sdk/anthropic":
255+ return APIAnthropicMessages
256+ default:
257+ return npm
258+ }
259+}
260+
261+type modelsDevReasoningOption struct {
262+ Type string `json:"type"`
263+ Values []string `json:"values"`
264+}
265+
266+type modelsDevLimit struct {
267+ Context int `json:"context"`
268+ Output int `json:"output"`
269+}
270+
271+type modelsDevCost struct {
272+ Input float64 `json:"input"`
273+ Output float64 `json:"output"`
274+ CacheRead float64 `json:"cache_read"`
275+ CacheWrite float64 `json:"cache_write"`
276+ Tiers []modelsDevCostTier `json:"tiers"`
277+}
278+
279+type modelsDevCostTier struct {
280+ Input float64 `json:"input"`
281+ Output float64 `json:"output"`
282+ CacheRead float64 `json:"cache_read"`
283+ CacheWrite float64 `json:"cache_write"`
284+ Tier modelsDevCostLimit `json:"tier"`
285+}
286+
287+type modelsDevCostLimit struct {
288+ Type string `json:"type"`
289+ Size int `json:"size"`
290+}
291+
292+func modelsDevPriceTiers(tiers []modelsDevCostTier) []ModelPriceTier {
293+ out := make([]ModelPriceTier, 0, len(tiers))
294+ for _, tier := range tiers {
295+ if tier.Tier.Type != "context" || tier.Tier.Size <= 0 {
296+ continue
297+ }
298+ out = append(out, ModelPriceTier{
299+ InputTokens: tier.Tier.Size,
300+ PriceInput: tier.Input,
301+ PriceOutput: tier.Output,
302+ PriceCacheRead: tier.CacheRead,
303+ PriceCacheWrite: tier.CacheWrite,
304+ })
305+ }
306+ sort.SliceStable(out, func(i, j int) bool {
307+ return out[i].InputTokens < out[j].InputTokens
308+ })
309+ return out
310+}
311+
312+// modelsDevReasoningMaps translates the effort values used by models.dev
313+// into zut's reasoning levels and exact provider wire values. The explicit
314+// metadata list is authoritative: remove every unsupported default and add
315+// supported minimum/xhigh/max levels.
316+func modelsDevReasoningMaps(reasoning bool, options []modelsDevReasoningOption) (map[string]string, map[string]string) {
317+ if !reasoning {
318+ return nil, nil
319+ }
320+ supported := make(map[string]string)
321+ for _, option := range options {
322+ if option.Type != "effort" {
323+ continue
324+ }
325+ for _, value := range option.Values {
326+ level := NormalizeReasoning(value)
327+ if reasoningLevelRank(level) > 0 {
328+ supported[level] = strings.ToLower(strings.TrimSpace(value))
329+ }
330+ }
331+ }
332+ levels := []string{"minimum", "low", "medium", "high", "xhigh", "max"}
333+ if len(supported) == 0 {
334+ levelMap := make(map[string]string, len(levels))
335+ for _, level := range levels {
336+ levelMap[level] = ""
337+ }
338+ return levelMap, nil
339+ }
340+
341+ levelMap := make(map[string]string, len(levels))
342+ effortMap := make(map[string]string, len(supported))
343+ for _, level := range levels {
344+ if wire, ok := supported[level]; ok {
345+ levelMap[level] = level
346+ effortMap[level] = wire
347+ continue
348+ }
349+ // An explicit models.dev effort list is authoritative for every
350+ // protocol default, including Responses-only xhigh and max.
351+ levelMap[level] = ""
352+ }
353+ return levelMap, effortMap
354+}
355+
356+// DynamicOpenCodeGoModel returns bootstrap metadata for an OpenCode Go model
357+// that is not present in a discovered catalog yet.
358+func DynamicOpenCodeGoModel(providerName, modelID, baseURL string) Model {
359+ return Model{
360+ Provider: providerName,
361+ ID: modelID,
362+ DisplayName: modelID,
363+ ContextWindow: 128000,
364+ MaxOutput: 16384,
365+ Reasoning: true,
366+ API: OpenCodeGoAPIForModel(modelID),
367+ BaseURL: baseURL,
368+ Source: "dynamic",
369+ }
370+}
371+
372+// OpenCodeGoAPIForModel reports the narrow bootstrap wire API for an
373+// OpenCode Go model when adapter metadata is unavailable. Discovery metadata
374+// is authoritative whenever it is present; this heuristic only recognizes the
375+// GPT-5.6 Responses family and leaves other models on the Chat Completions
376+// fallback.
377+func OpenCodeGoAPIForModel(id string) string {
378+ return openCodeGoAPIForModel(id)
379+}
380+
381+// Keep the family rule private so discovery and client fallbacks share one
382+// implementation without another model-id list.
383+func openCodeGoAPIForModel(id string) string {
384+ id = strings.ToLower(strings.TrimSpace(id))
385+ if strings.HasPrefix(id, "gpt-5.6-") {
386+ return APIResponses
387+ }
388+ return ""
389+}
390+
391+func fetchDiscoveryJSON(ctx context.Context, client *http.Client, url, authorization string) ([]byte, error) {
392+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
393+ if err != nil {
394+ return nil, err
395+ }
396+ if authorization != "" {
397+ req.Header.Set("authorization", authorization)
398+ }
399+ resp, err := client.Do(req)
400+ if err != nil {
401+ return nil, err
402+ }
403+ defer resp.Body.Close()
404+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryResponseBytes+1))
405+ if err != nil {
406+ return nil, err
407+ }
408+ if len(body) > maxDiscoveryResponseBytes {
409+ return nil, fmt.Errorf("response exceeds %d bytes", maxDiscoveryResponseBytes)
410+ }
411+ if resp.StatusCode != http.StatusOK {
412+ return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
413+ }
414+ return body, nil
415+}
416+
116417 // DiscoverGoogle lists Gemini model ids visible to key on
117418 // generativelanguage.googleapis.com. The API paginates with
118419 // nextPageToken; we follow it until exhausted.
M packages/provider/extra_providers.go +26 -7
@@ -124,14 +124,33 @@ func NewOpenCode(apiKey, baseURL string) Client {
124124 return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1")
125125 }
126126
127-// NewOpenCodeGo routes each model through the wire API exposed by OpenCode Go.
127+// NewOpenCodeGo routes each model through the Responses, Anthropic Messages,
128+// or Chat Completions wire API exposed by OpenCode Go. Model availability and
129+// metadata are synchronized by the agent package at startup; this constructor
130+// keeps a bootstrap endpoint for early requests.
128131 func NewOpenCodeGo(apiKey, baseURL string) Client {
129- const defaultBaseURL = "https://opencode.ai/zen/go/v1"
130- return NewModelRouter("opencode-go",
131- NewOpenAICompat("opencode-go", apiKey, baseURL, defaultBaseURL),
132- map[string]Client{
133- APIResponses: NewOpenAIResponsesNamed(apiKey, firstNonEmptyString(baseURL, defaultBaseURL), "opencode-go"),
134- })
132+ return NewOpenCodeGoWithModels(apiKey, baseURL, nil)
133+}
134+
135+// NewOpenCodeGoWithModels creates an OpenCode Go client with a private
136+// snapshot of model metadata. Runtime-owned clients use this to keep one
137+// SDK runtime's credential and endpoint metadata isolated from another's
138+// process-global catalog refresh.
139+func NewOpenCodeGoWithModels(apiKey, baseURL string, models []Model) Client {
140+ endpoint := firstNonEmptyString(baseURL, OpenCodeGoDefaultBaseURL)
141+ fallback := NewOpenAICompat(ProviderOpenCodeGo, apiKey, endpoint, OpenCodeGoDefaultBaseURL)
142+ responses := NewOpenAIResponsesNamed(apiKey, endpoint, ProviderOpenCodeGo)
143+ messages := NewAnthropicCompat(ProviderOpenCodeGo, apiKey, endpoint)
144+ router := NewModelRouter(ProviderOpenCodeGo, fallback, map[string]Client{
145+ APIResponses: responses,
146+ APIAnthropicMessages: messages,
147+ }).(*modelRouter)
148+ router.dynamicCatalog = models == nil
149+ router.scopedCatalog = models != nil
150+ for _, model := range models {
151+ router.SetModelMetadata(model)
152+ }
153+ return router
135154 }
136155
137156 // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case
M packages/provider/google_vertex.go +3 -0
@@ -366,6 +366,9 @@ type renamedClient struct {
366366 }
367367
368368 func (r *renamedClient) Name() string { return r.name }
369+func (r *renamedClient) SetModelMetadata(model Model) {
370+ setModelMetadata(r.inner, model)
371+}
369372 func (r *renamedClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
370373 if err := ValidateFastMode(r.Name(), req.FastMode); err != nil {
371374 return nil, err
M packages/provider/labels.go +1 -1
@@ -59,7 +59,7 @@ func ProviderLabel(id string) string {
5959 return "Vercel AI Gateway"
6060 case "opencode":
6161 return "OpenCode"
62- case "opencode-go":
62+ case ProviderOpenCodeGo:
6363 return "OpenCode Go"
6464 case "amazon-bedrock":
6565 return "Amazon Bedrock"
M packages/provider/llamacpp.go +3 -1
@@ -19,7 +19,9 @@ import (
1919 )
2020
2121 const (
22- LlamaCPPProviderID = "llama.cpp"
22+ // LlamaCPPProviderID is kept as a compatibility alias for callers that
23+ // used the original provider-specific constant.
24+ LlamaCPPProviderID = ProviderLlamaCPP
2325 defaultHuggingFaceURL = "https://huggingface.co"
2426 llamaCPPRequestTimeout = 15 * time.Second
2527 llamaCPPProgressPollTime = 250 * time.Millisecond
M packages/provider/merge_test.go +14 -10
@@ -64,19 +64,23 @@ func TestSetLiveModelsForProvidersPrunesEmptyAuthoritativeCatalog(t *testing.T)
6464 if _, err := FindModel("openai-codex", "gpt-5.6-luna"); err != nil {
6565 t.Fatalf("fixture drift: static Codex model missing: %v", err)
6666 }
67- activeMu.RLock()
68- previousActive := active
69- previousSet := activeSet
70- activeMu.RUnlock()
71- t.Cleanup(func() {
72- activeMu.Lock()
73- active = previousActive
74- activeSet = previousSet
75- activeMu.Unlock()
76- })
67+ preserveActiveCatalog(t)
7768
7869 SetLiveModelsForProviders(nil, []string{"openai-codex"})
7970 if _, err := FindModel("openai-codex", "gpt-5.6-luna"); err == nil {
8071 t.Fatal("empty authoritative catalog retained a static Codex model")
8172 }
8273 }
74+
75+func TestAcceptsUnlistedModelsStopsAfterAuthoritativeDiscovery(t *testing.T) {
76+ preserveActiveCatalog(t)
77+
78+ SetLiveModels([]Model{{Provider: ProviderOpenCodeGo, ID: "bootstrap"}})
79+ if !AcceptsUnlistedModels(ProviderOpenCodeGo) {
80+ t.Fatal("non-authoritative OpenCode Go overlay rejected unlisted models")
81+ }
82+ SetLiveModelsForProviders([]Model{{Provider: ProviderOpenCodeGo, ID: "served"}}, []string{ProviderOpenCodeGo})
83+ if AcceptsUnlistedModels(ProviderOpenCodeGo) {
84+ t.Fatal("authoritative OpenCode Go catalog accepted an unlisted model")
85+ }
86+}
M packages/provider/model_router.go +91 -7
@@ -3,6 +3,8 @@ package provider
33 import (
44 "context"
55 "fmt"
6+ "strings"
7+ "sync"
68 )
79
810 const (
@@ -10,34 +12,116 @@ const (
1012 APICompletions = "openai-completions"
1113 // APIResponses identifies the OpenAI Responses wire API.
1214 APIResponses = "openai-responses"
15+ // APIAnthropicMessages identifies the Anthropic Messages wire API.
16+ APIAnthropicMessages = "anthropic-messages"
1317 )
1418
1519 // modelRouter dispatches each request according to the selected model's API.
1620 // This lets one provider expose models that use different wire protocols while
1721 // remaining reusable across model switches.
1822 type modelRouter struct {
19- name string
20- fallback Client
21- byAPI map[string]Client
23+ name string
24+ fallback Client
25+ byAPI map[string]Client
26+ modelOverridesMu sync.RWMutex
27+ modelOverrides map[string]Model
28+ dynamicCatalog bool
29+ scopedCatalog bool
30+}
31+
32+// ModelMetadataSetter lets a runtime-owned client retain model metadata
33+// without consulting the process-global catalog on every request.
34+type ModelMetadataSetter interface {
35+ SetModelMetadata(Model)
2236 }
2337
2438 // NewModelRouter creates a client that dispatches requests using Model.API.
25-// Models with no API override, and models absent from the catalog, use fallback.
39+// Models with no API override, and models absent from the catalog, use fallback;
40+// an explicit API without a registered client returns an error.
2641 func NewModelRouter(name string, fallback Client, byAPI map[string]Client) Client {
2742 return &modelRouter{name: name, fallback: fallback, byAPI: byAPI}
2843 }
2944
3045 func (c *modelRouter) Name() string { return c.name }
3146
47+func (c *modelRouter) SetModelMetadata(model Model) {
48+ model = cloneModel(model)
49+ c.modelOverridesMu.Lock()
50+ if c.modelOverrides == nil {
51+ c.modelOverrides = make(map[string]Model)
52+ }
53+ c.modelOverrides[model.ID] = model
54+ c.modelOverridesMu.Unlock()
55+
56+ setModelMetadata(c.fallback, model)
57+ for _, client := range c.byAPI {
58+ setModelMetadata(client, model)
59+ }
60+}
61+
62+func (c *modelRouter) modelOverride(id string) (Model, bool) {
63+ c.modelOverridesMu.RLock()
64+ model, ok := c.modelOverrides[id]
65+ if ok {
66+ model = cloneModel(model)
67+ }
68+ c.modelOverridesMu.RUnlock()
69+ return model, ok
70+}
71+
72+func setModelMetadata(client Client, model Model) {
73+ if setter, ok := client.(ModelMetadataSetter); ok {
74+ setter.SetModelMetadata(model)
75+ }
76+}
77+
3278 func (c *modelRouter) Stream(ctx context.Context, req Request) (<-chan Event, error) {
79+ req.Model = strings.TrimSpace(req.Model)
3380 if err := ValidateFastMode(c.Name(), req.FastMode); err != nil {
3481 return nil, err
3582 }
3683 client := c.fallback
37- if model, err := FindModel(c.name, req.Model); err == nil && model.API != "" {
38- routed := c.byAPI[model.API]
84+ api := ""
85+ found := false
86+ if c.dynamicCatalog {
87+ // Normal CLI/RPC runtimes follow the process-global catalog so a
88+ // background refresh can replace stale request metadata. Keep a
89+ // bootstrap override only until an authoritative live entry exists.
90+ if model, err := FindModel(c.name, req.Model); err == nil {
91+ api = model.API
92+ found = true
93+ c.SetModelMetadata(model)
94+ } else if !IsProviderCatalogAuthoritative(c.name) {
95+ if model, ok := c.modelOverride(req.Model); ok {
96+ api = model.API
97+ found = true
98+ }
99+ }
100+ } else if model, ok := c.modelOverride(req.Model); ok {
101+ api = model.API
102+ found = true
103+ } else if !c.scopedCatalog {
104+ if model, err := FindModel(c.name, req.Model); err == nil {
105+ api = model.API
106+ found = true
107+ }
108+ }
109+ if !found && c.dynamicCatalog && IsProviderCatalogAuthoritative(c.name) {
110+ return nil, fmt.Errorf("unknown model %q (provider=%q)", req.Model, c.name)
111+ }
112+ if api == "" && !c.scopedCatalog && AcceptsUnlistedModels(c.name) {
113+ // The OpenCode Go catalog is intentionally live-only. Preserve the
114+ // family route for an explicit model while the first discovery is
115+ // still in flight or unavailable.
116+ api = openCodeGoAPIForModel(req.Model)
117+ }
118+ if api != "" {
119+ routed := c.byAPI[api]
120+ if routed == nil && api == APICompletions {
121+ routed = c.fallback
122+ }
39123 if routed == nil {
40- return nil, fmt.Errorf("provider %q has no client for model API %q", c.name, model.API)
124+ return nil, fmt.Errorf("provider %q model %q requests unsupported wire API %q", c.name, req.Model, api)
41125 }
42126 client = routed
43127 }
M packages/provider/model_router_test.go +183 -2
@@ -2,6 +2,8 @@ package provider
22
33 import (
44 "context"
5+ "strings"
6+ "sync"
57 "testing"
68 )
79
@@ -18,6 +20,15 @@ func (c *routeCaptureClient) Stream(_ context.Context, req Request) (<-chan Even
1820 return out, nil
1921 }
2022
23+type noOpClient struct{ name string }
24+
25+func (c *noOpClient) Name() string { return c.name }
26+func (c *noOpClient) Stream(context.Context, Request) (<-chan Event, error) {
27+ out := make(chan Event)
28+ close(out)
29+ return out, nil
30+}
31+
2132 func TestModelRouterDispatchesByModelAPI(t *testing.T) {
2233 completions := &routeCaptureClient{name: "xai"}
2334 responses := &routeCaptureClient{name: "xai"}
@@ -43,6 +54,8 @@ func TestModelRouterDispatchesByModelAPI(t *testing.T) {
4354 }
4455
4556 func TestModelRouterRejectsMissingAPIClient(t *testing.T) {
57+ preserveActiveCatalog(t)
58+ SetLiveModels([]Model{{Provider: "xai", ID: "grok-4.5", API: APIResponses}})
4659 router := NewModelRouter("xai", &routeCaptureClient{name: "xai"}, nil)
4760 if _, err := router.Stream(context.Background(), Request{Model: "grok-4.5"}); err == nil {
4861 t.Fatal("expected missing API client error")
@@ -50,6 +63,13 @@ func TestModelRouterRejectsMissingAPIClient(t *testing.T) {
5063 }
5164
5265 func TestOpenCodeGoRoutesLunaToResponses(t *testing.T) {
66+ preserveActiveCatalog(t)
67+ SetLiveModels([]Model{
68+ {Provider: "opencode-go", ID: "gpt-5.6-luna", API: APIResponses},
69+ {Provider: "opencode-go", ID: "kimi-k3"},
70+ {Provider: "opencode-go", ID: "explicit-completions", API: APICompletions},
71+ })
72+
5373 router := NewOpenCodeGo("token", "https://example.com/go/v1").(*modelRouter)
5474 if got := router.fallback.(*openaiClient).baseURL; got != "https://example.com/go/v1" {
5575 t.Fatalf("Completions base URL = %q", got)
@@ -63,7 +83,7 @@ func TestOpenCodeGoRoutesLunaToResponses(t *testing.T) {
6383 responsesCapture := &routeCaptureClient{name: "opencode-go"}
6484 router.fallback = completionsCapture
6585 router.byAPI[APIResponses] = responsesCapture
66- for _, model := range []string{"gpt-5.6-luna", "kimi-k3"} {
86+ for _, model := range []string{"gpt-5.6-luna", "kimi-k3", "explicit-completions"} {
6787 stream, err := router.Stream(context.Background(), Request{Model: model})
6888 if err != nil {
6989 t.Fatal(err)
@@ -75,7 +95,168 @@ func TestOpenCodeGoRoutesLunaToResponses(t *testing.T) {
7595 if len(responsesCapture.models) != 1 || responsesCapture.models[0] != "gpt-5.6-luna" {
7696 t.Fatalf("Responses models = %v", responsesCapture.models)
7797 }
78- if len(completionsCapture.models) != 1 || completionsCapture.models[0] != "kimi-k3" {
98+ if len(completionsCapture.models) != 2 || completionsCapture.models[0] != "kimi-k3" || completionsCapture.models[1] != "explicit-completions" {
7999 t.Fatalf("Completions models = %v", completionsCapture.models)
80100 }
81101 }
102+
103+func TestOpenCodeGoScopedEmptyRouterIgnoresGlobalCatalog(t *testing.T) {
104+ preserveActiveCatalog(t)
105+ SetLiveModels([]Model{{Provider: ProviderOpenCodeGo, ID: "global-model", API: APIResponses}})
106+
107+ fallback := &routeCaptureClient{name: ProviderOpenCodeGo}
108+ responses := &routeCaptureClient{name: ProviderOpenCodeGo}
109+ router := NewOpenCodeGoWithModels("token", "https://example.com/go/v1", []Model{}).(*modelRouter)
110+ router.fallback = fallback
111+ router.byAPI[APIResponses] = responses
112+ stream, err := router.Stream(context.Background(), Request{Model: "global-model"})
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ for range stream {
117+ }
118+ if len(fallback.models) != 1 || len(responses.models) != 0 {
119+ t.Fatalf("scoped empty router used global catalog: fallback=%v responses=%v", fallback.models, responses.models)
120+ }
121+}
122+
123+func TestModelRouterRejectsUnsupportedExplicitModelAPI(t *testing.T) {
124+ router := NewOpenCodeGoWithModels("token", "https://example.com/go/v1", []Model{{
125+ Provider: ProviderOpenCodeGo,
126+ ID: "unsupported-model",
127+ API: "@ai-sdk/google",
128+ }})
129+ if _, err := router.Stream(context.Background(), Request{Model: "unsupported-model"}); err == nil || !strings.Contains(err.Error(), "unsupported wire API") {
130+ t.Fatalf("error = %v, want actionable unsupported wire API error", err)
131+ }
132+}
133+
134+func TestOpenCodeGoRoutesUncataloguedGPT56ToResponses(t *testing.T) {
135+ preserveActiveCatalog(t)
136+ SetLiveModels(nil)
137+
138+ responses := &routeCaptureClient{name: "opencode-go"}
139+ completions := &routeCaptureClient{name: "opencode-go"}
140+ router := NewModelRouter("opencode-go", completions, map[string]Client{
141+ APIResponses: responses,
142+ })
143+ stream, err := router.Stream(context.Background(), Request{Model: "gpt-5.6-future"})
144+ if err != nil {
145+ t.Fatal(err)
146+ }
147+ for range stream {
148+ }
149+ if len(responses.models) != 1 || responses.models[0] != "gpt-5.6-future" {
150+ t.Fatalf("Responses models = %v", responses.models)
151+ }
152+ if len(completions.models) != 0 {
153+ t.Fatalf("Completions models = %v, want none", completions.models)
154+ }
155+}
156+
157+func TestOpenCodeGoDynamicRouterRefreshesMetadata(t *testing.T) {
158+ preserveActiveCatalog(t)
159+ SetLiveModels([]Model{{Provider: ProviderOpenCodeGo, ID: "refreshable-model", API: APIResponses}})
160+
161+ responses := &routeCaptureClient{name: ProviderOpenCodeGo}
162+ completions := &routeCaptureClient{name: ProviderOpenCodeGo}
163+ router := NewOpenCodeGo("token", "https://example.com/go/v1").(*modelRouter)
164+ router.fallback = completions
165+ router.byAPI[APIResponses] = responses
166+ stream, err := router.Stream(context.Background(), Request{Model: "refreshable-model"})
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ for range stream {
171+ }
172+ if router.modelOverrides["refreshable-model"].API != APIResponses {
173+ t.Fatalf("initial router metadata = %+v", router.modelOverrides["refreshable-model"])
174+ }
175+
176+ SetLiveModels([]Model{{Provider: ProviderOpenCodeGo, ID: "refreshable-model", API: APICompletions}})
177+ stream, err = router.Stream(context.Background(), Request{Model: "refreshable-model"})
178+ if err != nil {
179+ t.Fatal(err)
180+ }
181+ for range stream {
182+ }
183+ if router.modelOverrides["refreshable-model"].API != APICompletions {
184+ t.Fatalf("refreshed router metadata = %+v, want completions", router.modelOverrides["refreshable-model"])
185+ }
186+ if len(completions.models) != 1 || completions.models[0] != "refreshable-model" {
187+ t.Fatalf("Completions models = %v, want refreshed model", completions.models)
188+ }
189+}
190+
191+func TestOpenCodeGoDynamicRouterRejectsUnknownAuthoritativeModel(t *testing.T) {
192+ preserveActiveCatalog(t)
193+ SetLiveModelsForProviders([]Model{{Provider: ProviderOpenCodeGo, ID: "served-model"}}, []string{ProviderOpenCodeGo})
194+
195+ router := NewOpenCodeGo("token", "https://example.com/go/v1")
196+ if _, err := router.Stream(context.Background(), Request{Model: "removed-model"}); err == nil {
197+ t.Fatal("authoritative OpenCode Go router accepted removed model")
198+ }
199+}
200+
201+func TestModelRouterConcurrentStreamAndMetadataUpdates(t *testing.T) {
202+ router := NewModelRouter("test", &noOpClient{name: "test"}, map[string]Client{
203+ APIResponses: &noOpClient{name: "test"},
204+ }).(*modelRouter)
205+ model := Model{Provider: "test", ID: "concurrent-model", API: APIResponses}
206+ router.SetModelMetadata(model)
207+
208+ var wg sync.WaitGroup
209+ for i := 0; i < 100; i++ {
210+ wg.Add(2)
211+ go func(i int) {
212+ defer wg.Done()
213+ updated := model
214+ updated.API = APIResponses
215+ if i%2 == 0 {
216+ updated.API = APICompletions
217+ }
218+ router.SetModelMetadata(updated)
219+ }(i)
220+ go func() {
221+ defer wg.Done()
222+ stream, err := router.Stream(context.Background(), Request{Model: model.ID})
223+ if err != nil {
224+ t.Errorf("stream: %v", err)
225+ return
226+ }
227+ for range stream {
228+ }
229+ }()
230+ }
231+ wg.Wait()
232+}
233+
234+func TestModelMetadataConcurrentAccess(t *testing.T) {
235+ model := Model{Provider: ProviderOpenAICodex, ID: "concurrent-model", ContextWindow: 128000, MaxOutput: 8192}
236+ openai := &openaiClient{name: ProviderOpenAI}
237+ codex := &codexClient{providerName: ProviderOpenAICodex}
238+ openai.SetModelMetadata(model)
239+ codex.SetModelMetadata(model)
240+
241+ var wg sync.WaitGroup
242+ for i := 0; i < 100; i++ {
243+ wg.Add(4)
244+ go func() {
245+ defer wg.Done()
246+ openai.SetModelMetadata(model)
247+ }()
248+ go func() {
249+ defer wg.Done()
250+ _, _ = openai.buildRequest(Request{Model: model.ID})
251+ }()
252+ go func() {
253+ defer wg.Done()
254+ codex.SetModelMetadata(model)
255+ }()
256+ go func() {
257+ defer wg.Done()
258+ _, _ = codex.buildRequest(Request{Model: model.ID})
259+ }()
260+ }
261+ wg.Wait()
262+}
M packages/provider/models.go +251 -34
@@ -9,6 +9,17 @@ import (
99 // Deliberate working budget, not the models' advertised maximum context.
1010 const openAIContextWindowTarget = 500_000
1111
12+// ModelPriceTier describes the pricing that applies once the prompt reaches
13+// InputTokens. Tiers are retained in model metadata so cost calculation can
14+// select the highest applicable threshold.
15+type ModelPriceTier struct {
16+ InputTokens int
17+ PriceInput float64
18+ PriceOutput float64
19+ PriceCacheRead float64
20+ PriceCacheWrite float64
21+}
22+
1223 // Model describes a single LLM we know about.
1324 type Model struct {
1425 Provider string // "anthropic" | "openai"
@@ -19,6 +30,10 @@ type Model struct {
1930 MaxOutput int
2031 Reasoning bool // supports reasoning
2132 ReasoningLevelMap map[string]string // optional level overrides; empty values remove a level
33+ // ReasoningEffortMap translates normalized zut reasoning levels to the
34+ // provider's exact effort values when a model-specific catalog supplies
35+ // them (for example, minimum -> minimal).
36+ ReasoningEffortMap map[string]string
2237
2338 // AdaptiveThinking marks Anthropic models that only support the
2439 // adaptive thinking mode (Opus 4.7+). These reject explicit
@@ -34,11 +49,15 @@ type Model struct {
3449 AdaptiveThinkingCompat bool
3550
3651 // Prices are USD per 1M tokens. Price*Above fields apply to all token
37- // classes when the total prompt size exceeds PriceTierInputTokens.
38- PriceInput float64
39- PriceOutput float64
40- PriceCacheRead float64
41- PriceCacheWrite float64
52+ // classes when the total prompt size reaches PriceTierInputTokens.
53+ PriceInput float64
54+ PriceOutput float64
55+ PriceCacheRead float64
56+ PriceCacheWrite float64
57+ // PriceTiers contains all context-pricing thresholds, ordered by
58+ // InputTokens. The legacy Price*Above fields retain the first tier for
59+ // compatibility with callers that only understand one threshold.
60+ PriceTiers []ModelPriceTier
4261 PriceTierInputTokens int
4362 PriceInputAbove float64
4463 PriceOutputAbove float64
@@ -64,9 +83,10 @@ type Model struct {
6483
6584 // Catalog is the hardcoded, read-only list of supported models.
6685 // Prices are USD per 1M tokens. The list is curated to what zut's
67-// clients (Anthropic Messages + OpenAI Chat Completions) can actually
68-// talk to; models that are only reachable through the OpenAI Responses
69-// API (o1-pro, o3-pro, gpt-5-pro) are omitted.
86+// clients (Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses)
87+// can actually talk to; models that are only reachable through an unsupported
88+// provider-specific protocol are omitted. OpenCode Go is populated
89+// separately by runtime discovery from its provider API and models.dev.
7090 var Catalog = []Model{
7191 // ---- Anthropic / Claude 4.x ----
7292 {
@@ -424,12 +444,157 @@ var DefaultModel = Catalog[0] // claude-sonnet-4-5
424444 // models loaded via SetLiveModels.
425445
426446 var (
427- activeMu sync.RWMutex
428- active []Model // live overlay merged in via SetLiveModels; nil = none yet
429- activeSet bool // true once SetLiveModels has run (even with empty live)
430- managedModels []Model // ephemeral models exposed by local model managers
447+ activeMu sync.RWMutex
448+ active []Model // live overlay merged in via SetLiveModels; nil = none yet
449+ activeSet bool // true once SetLiveModels has run (even with empty live)
450+ authoritativeProviderSet map[string]struct{}
451+ managedModels []Model // ephemeral models exposed by local model managers
452+ userModels []Model // highest-precedence models loaded from models.json
431453 )
432454
455+// CatalogSnapshot captures the mutable catalog overlays so a caller can
456+// restore provider state after temporarily installing test or runtime data.
457+// Use SnapshotCatalog and RestoreCatalog instead of replacing the live
458+// overlay with nil, which can discard another caller's active catalog.
459+type CatalogSnapshot struct {
460+ active []Model
461+ activeSet bool
462+ authoritativeProviderSet map[string]struct{}
463+ managedModels []Model
464+ userModels []Model
465+}
466+
467+// SnapshotCatalog returns a deep copy of the mutable provider catalog state.
468+func SnapshotCatalog() CatalogSnapshot {
469+ activeMu.RLock()
470+ defer activeMu.RUnlock()
471+
472+ snapshot := CatalogSnapshot{
473+ activeSet: activeSet,
474+ authoritativeProviderSet: maps.Clone(authoritativeProviderSet),
475+ }
476+ if active != nil {
477+ snapshot.active = cloneModels(active)
478+ }
479+ if managedModels != nil {
480+ snapshot.managedModels = cloneModels(managedModels)
481+ }
482+ if userModels != nil {
483+ snapshot.userModels = cloneModels(userModels)
484+ }
485+ return snapshot
486+}
487+
488+// RestoreCatalog restores a snapshot returned by SnapshotCatalog.
489+func RestoreCatalog(snapshot CatalogSnapshot) {
490+ activeMu.Lock()
491+ defer activeMu.Unlock()
492+
493+ if snapshot.active != nil {
494+ active = cloneModels(snapshot.active)
495+ } else {
496+ active = nil
497+ }
498+ activeSet = snapshot.activeSet
499+ authoritativeProviderSet = maps.Clone(snapshot.authoritativeProviderSet)
500+ if snapshot.managedModels != nil {
501+ managedModels = cloneModels(snapshot.managedModels)
502+ } else {
503+ managedModels = nil
504+ }
505+ if snapshot.userModels != nil {
506+ userModels = cloneModels(snapshot.userModels)
507+ } else {
508+ userModels = nil
509+ }
510+}
511+
512+func cloneModel(model Model) Model {
513+ model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
514+ model.ReasoningEffortMap = maps.Clone(model.ReasoningEffortMap)
515+ if model.PriceTiers != nil {
516+ model.PriceTiers = append([]ModelPriceTier(nil), model.PriceTiers...)
517+ }
518+ return model
519+}
520+
521+func cloneModels(models []Model) []Model {
522+ out := make([]Model, len(models))
523+ for i, model := range models {
524+ out[i] = cloneModel(model)
525+ }
526+ return out
527+}
528+
529+func mergeUserModels(active, users []Model) []Model {
530+ if len(users) == 0 {
531+ return active
532+ }
533+ byKey := func(provider, id string) string { return provider + "\x00" + id }
534+ index := make(map[string]int, len(active))
535+ for i, model := range active {
536+ index[byKey(model.Provider, model.ID)] = i
537+ }
538+ for _, user := range users {
539+ user = cloneModel(user)
540+ key := byKey(user.Provider, user.ID)
541+ if idx, ok := index[key]; ok {
542+ existing := active[idx]
543+ // Preserve catalog values when an override leaves an optional
544+ // field at its zero value, matching models.json semantics.
545+ // A positive price override replaces tiered pricing too;
546+ // otherwise ComputeCost would retain discovered tier rates.
547+ if user.PriceInput > 0 || user.PriceOutput > 0 || user.PriceCacheRead > 0 || user.PriceCacheWrite > 0 {
548+ existing.PriceTiers = nil
549+ existing.PriceTierInputTokens = 0
550+ existing.PriceInputAbove, existing.PriceOutputAbove = 0, 0
551+ existing.PriceCacheReadAbove, existing.PriceCacheWriteAbove = 0, 0
552+ }
553+ if user.PriceInput > 0 {
554+ existing.PriceInput = user.PriceInput
555+ }
556+ if user.PriceOutput > 0 {
557+ existing.PriceOutput = user.PriceOutput
558+ }
559+ if user.PriceCacheRead > 0 {
560+ existing.PriceCacheRead = user.PriceCacheRead
561+ }
562+ if user.PriceCacheWrite > 0 {
563+ existing.PriceCacheWrite = user.PriceCacheWrite
564+ }
565+ if user.DisplayName != "" {
566+ existing.DisplayName = user.DisplayName
567+ }
568+ if user.ContextWindow > 0 {
569+ existing.ContextWindow = user.ContextWindow
570+ }
571+ if user.MaxOutput > 0 {
572+ existing.MaxOutput = user.MaxOutput
573+ }
574+ existing.Reasoning = user.Reasoning
575+ if user.ReasoningLevelMap != nil {
576+ existing.ReasoningLevelMap = user.ReasoningLevelMap
577+ }
578+ if user.ReasoningEffortMap != nil {
579+ existing.ReasoningEffortMap = user.ReasoningEffortMap
580+ }
581+ if user.API != "" {
582+ existing.API = user.API
583+ }
584+ if user.BaseURL != "" {
585+ existing.BaseURL = user.BaseURL
586+ }
587+ existing.Source = "user"
588+ existing.Speculative = false
589+ active[idx] = existing
590+ continue
591+ }
592+ index[key] = len(active)
593+ active = append(active, user)
594+ }
595+ return active
596+}
597+
433598 // SetLiveModels replaces the "live" overlay used by the active catalog.
434599 // Typically called after a successful /v1/models discovery or on load
435600 // from the on-disk cache.
@@ -444,6 +609,10 @@ func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
444609 activeMu.Lock()
445610 defer activeMu.Unlock()
446611 activeSet = true
612+ authoritativeProviderSet = make(map[string]struct{}, len(authoritativeProviders))
613+ for _, name := range authoritativeProviders {
614+ authoritativeProviderSet[name] = struct{}{}
615+ }
447616 if len(live) == 0 && len(authoritativeProviders) == 0 {
448617 active = nil
449618 return
@@ -451,6 +620,25 @@ func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
451620 active = MergeCatalogForProviders(live, authoritativeProviders)
452621 }
453622
623+// ClearLiveModelsForProvider removes a provider's live overlay without
624+// disturbing other providers. User models remain separate and continue to
625+// be applied by Active.
626+func ClearLiveModelsForProvider(name string) {
627+ activeMu.Lock()
628+ defer activeMu.Unlock()
629+ delete(authoritativeProviderSet, name)
630+ if !activeSet || len(active) == 0 {
631+ return
632+ }
633+ out := active[:0]
634+ for _, model := range active {
635+ if model.Provider != name {
636+ out = append(out, model)
637+ }
638+ }
639+ active = out
640+}
641+
454642 // Active returns the current merged catalog.
455643 //
456644 // When no live overlay has been set it returns the fully-assembled
@@ -468,29 +656,27 @@ func Active() []Model {
468656 if !activeSet || src == nil {
469657 src = Catalog
470658 }
471- out := make([]Model, len(src))
472- copy(out, src)
473- for i := range out {
474- out[i].ReasoningLevelMap = maps.Clone(out[i].ReasoningLevelMap)
475- }
476- if len(managedModels) == 0 {
477- return out
478- }
479- index := make(map[string]int, len(out))
480- for i, model := range out {
481- index[model.Provider+"\x00"+model.ID] = i
482- }
483- for _, model := range managedModels {
484- model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
485- key := model.Provider + "\x00" + model.ID
486- if i, ok := index[key]; ok {
487- out[i] = model
488- continue
659+ out := cloneModels(src)
660+ if len(managedModels) > 0 {
661+ index := make(map[string]int, len(out))
662+ for i, model := range out {
663+ index[model.Provider+"\x00"+model.ID] = i
664+ }
665+ for _, model := range managedModels {
666+ model = cloneModel(model)
667+ key := model.Provider + "\x00" + model.ID
668+ if i, ok := index[key]; ok {
669+ out[i] = model
670+ continue
671+ }
672+ index[key] = len(out)
673+ out = append(out, model)
489674 }
490- index[key] = len(out)
491- out = append(out, model)
492675 }
493- return out
676+ // User models are a durable, highest-precedence overlay. Apply them
677+ // after the live and managed overlays so a catalog refresh cannot
678+ // discard overrides or user-only entries.
679+ return mergeUserModels(out, userModels)
494680 }
495681
496682 // SetManagedModels replaces the ephemeral catalog entries supplied by local
@@ -514,6 +700,22 @@ func FindModel(provider, id string) (Model, error) {
514700 return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider)
515701 }
516702
703+// IsProviderCatalogAuthoritative reports whether a successful live catalog
704+// has declared the provider's available model IDs authoritative.
705+func IsProviderCatalogAuthoritative(provider string) bool {
706+ activeMu.RLock()
707+ defer activeMu.RUnlock()
708+ _, ok := authoritativeProviderSet[provider]
709+ return ok
710+}
711+
712+// AcceptsUnlistedModels reports whether a provider may be queried with an
713+// ID that is not in the local catalog. OpenCode Go permits this only before
714+// a successful authoritative live catalog has been loaded.
715+func AcceptsUnlistedModels(provider string) bool {
716+ return provider == ProviderOpenCodeGo && !IsProviderCatalogAuthoritative(provider)
717+}
718+
517719 // ModelsForProvider returns all models for the given provider, from the
518720 // merged active catalog.
519721 func ModelsForProvider(provider string) []Model {
@@ -533,7 +735,22 @@ func ComputeCost(m Model, u Usage) float64 {
533735 cacheReadPrice := m.PriceCacheRead
534736 cacheWritePrice := m.PriceCacheWrite
535737 promptTokens := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
536- if m.PriceTierInputTokens > 0 && promptTokens > m.PriceTierInputTokens {
738+ if len(m.PriceTiers) > 0 {
739+ selected := -1
740+ for i, tier := range m.PriceTiers {
741+ if tier.InputTokens <= promptTokens && (selected < 0 || tier.InputTokens > m.PriceTiers[selected].InputTokens) {
742+ selected = i
743+ }
744+ }
745+ if selected >= 0 {
746+ tier := m.PriceTiers[selected]
747+ inputPrice = tier.PriceInput
748+ outputPrice = tier.PriceOutput
749+ cacheReadPrice = tier.PriceCacheRead
750+ cacheWritePrice = tier.PriceCacheWrite
751+ }
752+ } else if m.PriceTierInputTokens > 0 && promptTokens >= m.PriceTierInputTokens {
753+ // Keep the legacy single-tier fields inclusive at the threshold.
537754 inputPrice = m.PriceInputAbove
538755 outputPrice = m.PriceOutputAbove
539756 cacheReadPrice = m.PriceCacheReadAbove
M packages/provider/openai.go +39 -10
@@ -10,6 +10,7 @@ import (
1010 "net/http"
1111 "regexp"
1212 "strings"
13+ "sync"
1314 "time"
1415 )
1516
@@ -42,6 +43,8 @@ type openaiClient struct {
4243 oauth bool // when true, apiKey actually holds an OAuth access token
4344 headers map[string]string
4445 http *http.Client
46+ modelOverridesMu sync.RWMutex
47+ modelOverrides map[string]Model
4548 }
4649
4750 // NewOpenAI creates an OpenAI client using an API key. baseURL may be empty.
@@ -113,6 +116,16 @@ func (c *openaiClient) Name() string {
113116 return "openai"
114117 }
115118
119+func (c *openaiClient) SetModelMetadata(model Model) {
120+ model = cloneModel(model)
121+ c.modelOverridesMu.Lock()
122+ if c.modelOverrides == nil {
123+ c.modelOverrides = make(map[string]Model)
124+ }
125+ c.modelOverrides[model.ID] = model
126+ c.modelOverridesMu.Unlock()
127+}
128+
116129 // ---- wire types ----
117130
118131 type oaiContentText struct {
@@ -181,16 +194,32 @@ type oaiRequest struct {
181194
182195 // ---- request building ----
183196
197+func (c *openaiClient) modelForRequest(id string) (Model, error) {
198+ id = strings.TrimSpace(id)
199+ c.modelOverridesMu.RLock()
200+ model, ok := c.modelOverrides[id]
201+ if ok {
202+ model = cloneModel(model)
203+ }
204+ c.modelOverridesMu.RUnlock()
205+ if ok {
206+ return model, nil
207+ }
208+ model, err := FindModel(c.Name(), id)
209+ if err != nil {
210+ model, err = FindModel("", id)
211+ }
212+ return model, err
213+}
214+
184215 func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
216+ req.Model = strings.TrimSpace(req.Model)
185217 if err := ValidateFastMode(c.Name(), req.FastMode); err != nil {
186218 return nil, err
187219 }
188220 // The OpenAI wire client is shared by many providers, so prefer its own
189221 // provider namespace before falling back to a provider-agnostic lookup.
190- m, err := FindModel(c.Name(), req.Model)
191- if err != nil {
192- m, err = FindModel("", req.Model)
193- }
222+ m, err := c.modelForRequest(req.Model)
194223 if err != nil {
195224 // Unknown model: use sensible defaults so local/custom
196225 // models still work without a catalog entry.
@@ -264,11 +293,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
264293 // don't clamp zut's "maximum" to "high" for those models.
265294 effort = OpenAICompatAnthropicEffort(reasoning)
266295 }
267- if hasReasoningLevelOverride(m, req.Reasoning) {
268- // Explicit model mappings describe the endpoint's actual effort
269- // values and take precedence over conservative protocol defaults.
270- effort = reasoning
271- }
296+ // Explicit model mappings describe the endpoint's actual effort
297+ // values and take precedence over conservative protocol defaults.
298+ effort = reasoningEffortForModel(m, req.Reasoning, reasoning, effort)
272299 if effort != "" {
273300 out.ReasoningEffort = effort
274301 }
@@ -496,6 +523,7 @@ func (c *openaiClient) chatCompletionsURL() string {
496523 }
497524
498525 func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
526+ req.Model = strings.TrimSpace(req.Model)
499527 endpoint := c.chatCompletionsURL()
500528 wire, err := c.buildRequest(req)
501529 if err != nil {
@@ -516,6 +544,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
516544 for k, v := range c.headers {
517545 httpReq.Header.Set(k, v)
518546 }
547+ setOpenCodeGoHeaders(httpReq.Header, c.Name(), req.Context)
519548 return httpReq, nil
520549 }
521550
@@ -538,7 +567,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
538567 defer close(out)
539568 defer resp.Body.Close()
540569
541- model, _ := FindModel("", req.Model)
570+ model, _ := c.modelForRequest(req.Model)
542571 out <- EventStart{Model: req.Model, Provider: c.Name()}
543572
544573 raw := make(chan sseEvent, 16)
M packages/provider/openai_clamp_test.go +1 -12
@@ -8,19 +8,8 @@ import "testing"
88 // the real catalog.
99 func withLiveModels(t *testing.T, models []Model) {
1010 t.Helper()
11- activeMu.Lock()
12- prevActive := active
13- prevSet := activeSet
14- activeMu.Unlock()
15-
11+ preserveActiveCatalog(t)
1612 SetLiveModels(models)
17-
18- t.Cleanup(func() {
19- activeMu.Lock()
20- active = prevActive
21- activeSet = prevSet
22- activeMu.Unlock()
23- })
2413 }
2514
2615 // outputBudget pulls whichever max-output field buildRequest populated
M packages/provider/openai_codex.go +50 -9
@@ -10,6 +10,7 @@ import (
1010 "net/http"
1111 "runtime"
1212 "strings"
13+ "sync"
1314 "time"
1415 )
1516
@@ -46,6 +47,8 @@ type codexClient struct {
4647 cliRoutingAll bool
4748 capabilities responsesCapabilities
4849 http *http.Client
50+ modelOverridesMu sync.RWMutex
51+ modelOverrides map[string]Model
4952 }
5053
5154 // responsesCapabilities records extensions verified for the exact provider
@@ -93,7 +96,17 @@ func newOpenAICodexClient(token, accountID, baseURL string) *codexClient {
9396 }
9497 }
9598
96-func (c *codexClient) Name() string { return "openai-codex" }
99+func (c *codexClient) Name() string { return ProviderOpenAICodex }
100+
101+func (c *codexClient) SetModelMetadata(model Model) {
102+ model = cloneModel(model)
103+ c.modelOverridesMu.Lock()
104+ if c.modelOverrides == nil {
105+ c.modelOverrides = make(map[string]Model)
106+ }
107+ c.modelOverrides[model.ID] = model
108+ c.modelOverridesMu.Unlock()
109+}
97110
98111 // ---- Responses API wire types (subset needed for zut's surface) ----
99112
@@ -203,15 +216,41 @@ type codexRequest struct {
203216 // ---- Request building ----
204217
205218 func (c *codexClient) findModel(id string) (Model, error) {
206- if c.providerName != "" && c.providerName != "openai-codex" {
219+ id = strings.TrimSpace(id)
220+ c.modelOverridesMu.RLock()
221+ model, ok := c.modelOverrides[id]
222+ if ok {
223+ model = cloneModel(model)
224+ }
225+ c.modelOverridesMu.RUnlock()
226+ if ok {
227+ return model, nil
228+ }
229+ if c.providerName != "" && c.providerName != ProviderOpenAICodex {
207230 if m, err := FindModel(c.providerName, id); err == nil {
208231 return m, nil
209232 }
210233 }
211- if m, err := FindModel("openai-codex", id); err == nil {
234+ if m, err := FindModel(ProviderOpenAICodex, id); err == nil {
212235 return m, nil
213236 }
214- return FindModel("openai", id)
237+ m, err := FindModel(ProviderOpenAI, id)
238+ if err == nil {
239+ return m, nil
240+ }
241+ if c.providerName == ProviderOpenCodeGo {
242+ if api := openCodeGoAPIForModel(id); api != "" {
243+ return Model{
244+ Provider: ProviderOpenCodeGo,
245+ ID: id,
246+ API: api,
247+ ContextWindow: 128000,
248+ MaxOutput: 16384,
249+ Reasoning: true,
250+ }, nil
251+ }
252+ }
253+ return m, err
215254 }
216255
217256 func supportsOpenAIExplicitPromptCache(model string) bool {
@@ -220,9 +259,10 @@ func supportsOpenAIExplicitPromptCache(model string) bool {
220259 }
221260
222261 func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
262+ req.Model = strings.TrimSpace(req.Model)
223263 providerName := c.providerName
224264 if providerName == "" {
225- providerName = "openai-codex"
265+ providerName = ProviderOpenAICodex
226266 }
227267 if err := ValidateFastMode(providerName, req.FastMode); err != nil {
228268 return nil, err
@@ -259,9 +299,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
259299 }
260300 if m.Reasoning {
261301 effort := OpenAICodexReasoningEffort(reasoning, req.Model)
262- if hasReasoningLevelOverride(m, req.Reasoning) {
263- effort = reasoning
264- }
302+ effort = reasoningEffortForModel(m, req.Reasoning, reasoning, effort)
265303 if effort != "" {
266304 body.Reasoning = &codexReasoningConfig{Effort: effort}
267305 }
@@ -443,6 +481,7 @@ func usesCodexCLIRouting(model string) bool {
443481 // ---- Streaming ----
444482
445483 func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
484+ req.Model = strings.TrimSpace(req.Model)
446485 wire, err := c.buildRequest(req)
447486 if err != nil {
448487 return nil, err
@@ -514,6 +553,7 @@ func (c *codexClient) responsesHeaders(req Request, useCodexCLIRouting, websocke
514553 headers.Set("x-client-request-id", threadID)
515554 }
516555 headers.Set("user-agent", "codex_cli_rs/0.0.0")
556+ setOpenCodeGoHeaders(headers, c.providerName, req.Context)
517557 return headers
518558 }
519559
@@ -523,6 +563,7 @@ func (c *codexClient) responsesHeaders(req Request, useCodexCLIRouting, websocke
523563 headers.Set("originator", "zot")
524564 headers.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
525565 }
566+ setOpenCodeGoHeaders(headers, c.providerName, req.Context)
526567 return headers
527568 }
528569
@@ -551,7 +592,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
551592 model, _ := c.findModel(req.Model)
552593 providerName := c.providerName
553594 if providerName == "" {
554- providerName = "openai-codex"
595+ providerName = ProviderOpenAICodex
555596 }
556597 out <- EventStart{Model: req.Model, Provider: providerName}
557598
M packages/provider/openai_codex_test.go +4 -1
@@ -137,10 +137,13 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
137137
138138 func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
139139 c := newOpenAICodexClient("token", "acct", "")
140- wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"})
140+ wire, err := c.buildRequest(Request{Model: " gpt-5.6-sol ", Reasoning: "max"})
141141 if err != nil {
142142 t.Fatal(err)
143143 }
144+ if wire.Model != "gpt-5.6-sol" {
145+ t.Fatalf("model = %q, want trimmed id", wire.Model)
146+ }
144147 if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
145148 t.Fatalf("reasoning = %+v", wire.Reasoning)
146149 }
M packages/provider/openai_responses.go +5 -2
@@ -5,7 +5,8 @@ package provider
55 // Strategy: reuse codexClient, which already speaks the Responses wire
66 // format, via a header-rewriting RoundTripper. The public Responses API on
77 // api.openai.com does not need ChatGPT subscription identity headers, so the
8-// transport strips them if a shared request path supplied any.
8+// transport strips them if a shared request path supplied any while retaining
9+// the intentional OpenCode Go client identity.
910 //
1011 // This is a separate provider from `openai` (which is Chat Completions);
1112 // users opt in by passing `--provider openai-responses` or by picking a
@@ -49,7 +50,9 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response,
4950 clone.Header.Del("chatgpt-account-id")
5051 clone.Header.Del("openai-beta")
5152 clone.Header.Del("originator")
52- clone.Header.Del("user-agent")
53+ if clone.Header.Get("x-opencode-client") != openCodeGoClient {
54+ clone.Header.Del("user-agent")
55+ }
5356 // Keep Authorization: Bearer <key> as set by the codex client.
5457 return t.inner.RoundTrip(clone)
5558 }
M packages/provider/openai_responses_websocket.go +4 -0
@@ -56,6 +56,9 @@ func newResponsesWebSocketClient(httpClient *codexClient) Client {
5656 }
5757
5858 func (c *responsesWebSocketClient) Name() string { return c.http.Name() }
59+func (c *responsesWebSocketClient) SetModelMetadata(model Model) {
60+ c.http.SetModelMetadata(model)
61+}
5962
6063 // Close releases all persistent sockets held by this client. Callers that
6164 // create a client for a bounded runtime (such as a resident child) must call
@@ -186,6 +189,7 @@ func responsesWebSocketURL(endpoint string) (string, error) {
186189 }
187190
188191 func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
192+ req.Model = strings.TrimSpace(req.Model)
189193 // WebSocket continuation state is isolated per conversation thread. Cache
190194 // affinity may be shared by root and resident children, but their server
191195 // response chains must never be pooled.
A packages/provider/opencode_go_discovery_test.go +224 -0
@@ -0,0 +1,224 @@
1+package provider
2+
3+import (
4+ "context"
5+ "math"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+)
10+
11+func preserveActiveCatalog(t *testing.T) {
12+ t.Helper()
13+ snapshot := SnapshotCatalog()
14+ t.Cleanup(func() { RestoreCatalog(snapshot) })
15+}
16+
17+func TestOpenCodeGoCatalogIsDiscoveredAtRuntime(t *testing.T) {
18+ for _, model := range Catalog {
19+ if model.Provider == "opencode-go" {
20+ t.Fatalf("OpenCode Go model %q is still manually cataloged", model.ID)
21+ }
22+ }
23+}
24+
25+func TestDiscoverOpenCodeGoCombinesLiveIDsWithModelsDevMetadata(t *testing.T) {
26+ const apiKey = "synthetic-opencode-key"
27+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ switch r.URL.Path {
29+ case "/api.json":
30+ if got := r.Header.Get("authorization"); got != "" {
31+ t.Errorf("models.dev authorization = %q, want empty", got)
32+ }
33+ _, _ = w.Write([]byte(`{
34+ "opencode-go": {
35+ "id": "opencode-go",
36+ "api": "https://opencode.ai/zen/go/v1",
37+ "npm": "@ai-sdk/openai-compatible",
38+ "models": {
39+ "muse-spark-1.2-contributor": {
40+ "id": "muse-spark-1.2-contributor",
41+ "name": "Muse Spark 1.2 Contributor",
42+ "reasoning": true,
43+ "reasoning_options": [{"type":"effort","values":["minimal","low","medium","high","xhigh"]}],
44+ "limit": {"context": 1048576, "output": 131072},
45+ "cost": {"input": 0.1, "output": 0.2, "cache_read": 0.002, "cache_write": 0.003,
46+ "tiers": [
47+ {"input": 0.9, "output": 1.8, "cache_read": 0.009, "cache_write": 0.01,
48+ "tier": {"type": "context", "size": 500000}},
49+ {"input": 0.4, "output": 0.8, "cache_read": 0.004, "cache_write": 0.005,
50+ "tier": {"type": "context", "size": 272000}}]}
51+ },
52+ "not-served": {
53+ "id": "not-served",
54+ "name": "Not Served",
55+ "limit": {"context": 999, "output": 99}
56+ }
57+ }
58+ }
59+ }`))
60+ case "/v1/models":
61+ if got := r.Header.Get("authorization"); got != "Bearer "+apiKey {
62+ t.Errorf("OpenCode Go authorization = %q, want bearer key", got)
63+ }
64+ _, _ = w.Write([]byte(`{"object":"list","data":[
65+ {"id":"muse-spark-1.2-contributor","object":"model"},
66+ {"id":"future-model","object":"model"},
67+ {"id":" padded-model ","object":"model"},
68+ {"id":" \t ","object":"model"}
69+ ]}`))
70+ default:
71+ http.NotFound(w, r)
72+ }
73+ }))
74+ defer server.Close()
75+
76+ models, err := discoverOpenCodeGo(context.Background(), apiKey, server.URL+"/v1", server.URL+"/api.json")
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ if len(models) != 3 {
81+ t.Fatalf("models = %+v, want three nonblank served models", models)
82+ }
83+
84+ if got := models[0]; got.Provider != "opencode-go" || got.ID != "muse-spark-1.2-contributor" || got.DisplayName != "Muse Spark 1.2 Contributor" || got.ContextWindow != 1048576 || got.MaxOutput != 131072 || !got.Reasoning || got.API != APICompletions || got.PriceInput != 0.1 || got.PriceOutput != 0.2 || got.PriceCacheRead != 0.002 || got.PriceCacheWrite != 0.003 || got.PriceTierInputTokens != 272000 || got.PriceInputAbove != 0.4 || got.PriceOutputAbove != 0.8 || got.PriceCacheReadAbove != 0.004 || got.PriceCacheWriteAbove != 0.005 || got.BaseURL != server.URL+"/v1" || got.Source != "live" {
85+ t.Fatalf("metadata model = %+v", got)
86+ }
87+ if got := models[0].ReasoningLevelMap; got["minimum"] != "minimum" || got["xhigh"] != "xhigh" {
88+ t.Fatalf("reasoning level map = %#v, want minimum/xhigh identity mappings", got)
89+ }
90+ if got := models[0].ReasoningEffortMap; got["minimum"] != "minimal" || got["xhigh"] != "xhigh" {
91+ t.Fatalf("reasoning effort map = %#v, want minimal/xhigh wire values", got)
92+ }
93+
94+ if got := models[0].PriceTiers; len(got) != 2 || got[0].InputTokens != 272000 || got[0].PriceInput != 0.4 || got[1].InputTokens != 500000 || got[1].PriceInput != 0.9 {
95+ t.Fatalf("price tiers = %+v, want sorted 272k/500k tiers", got)
96+ }
97+ for _, tc := range []struct {
98+ name string
99+ input int
100+ want float64
101+ }{
102+ {"below_first", 271999, 271999 * 0.1 / 1_000_000},
103+ {"at_272k", 272000, 272000 * 0.4 / 1_000_000},
104+ {"at_500k", 500000, 500000 * 0.9 / 1_000_000},
105+ {"above_500k", 600000, 600000 * 0.9 / 1_000_000},
106+ } {
107+ t.Run(tc.name, func(t *testing.T) {
108+ if got := ComputeCost(models[0], Usage{InputTokens: tc.input}); math.Abs(got-tc.want) > 1e-12 {
109+ t.Fatalf("cost = %v, want %v", got, tc.want)
110+ }
111+ })
112+ }
113+
114+ if got := models[1]; got.Provider != "opencode-go" || got.ID != "future-model" || got.DisplayName != "future-model" || got.BaseURL != server.URL+"/v1" || got.Source != "live" || got.ContextWindow != 0 || got.MaxOutput != 0 || got.Reasoning {
115+ t.Fatalf("unmapped live model = %+v", got)
116+ }
117+ if got := models[2]; got.ID != "padded-model" || got.DisplayName != "padded-model" {
118+ t.Fatalf("padded live model = %+v, want trimmed id", got)
119+ }
120+}
121+
122+func TestDiscoverOpenCodeGoKeepsLiveIDsWithoutProviderMetadata(t *testing.T) {
123+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124+ switch r.URL.Path {
125+ case "/api.json":
126+ _, _ = w.Write([]byte(`{"other-provider":{"models":{}}}`))
127+ case "/models":
128+ _, _ = w.Write([]byte(`{"data":[{"id":"served"}]}`))
129+ default:
130+ http.NotFound(w, r)
131+ }
132+ }))
133+ defer server.Close()
134+
135+ models, err := discoverOpenCodeGo(context.Background(), "key", server.URL, server.URL+"/api.json")
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ if len(models) != 1 || models[0].ID != "served" || models[0].DisplayName != "served" || models[0].API != "" {
140+ t.Fatalf("models = %+v, want the live id with fallback metadata and no explicit adapter", models)
141+ }
142+}
143+
144+func TestDiscoverOpenCodeGoUsesModelsDevAdaptersAndEmptyReasoningMetadata(t *testing.T) {
145+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146+ switch r.URL.Path {
147+ case "/api.json":
148+ _, _ = w.Write([]byte(`{
149+ "opencode-go": {
150+ "npm": "@ai-sdk/openai-compatible",
151+ "api": "https://metadata.example/v1",
152+ "models": {
153+ "muse-spark-1.3-contributor": {
154+ "name": "Muse Spark 1.3 Contributor",
155+ "reasoning": true,
156+ "reasoning_options": [{"type":"effort","values":["low","high"]}],
157+ "provider": {"npm":"@ai-sdk/openai", "api":"https://untrusted.example/v1"}
158+ },
159+ "minimax-m2": {
160+ "name": "MiniMax M2",
161+ "reasoning": true,
162+ "provider": {"npm":"@ai-sdk/anthropic"}
163+ },
164+ "gpt-5.6-completions": {
165+ "name": "GPT Completions Override",
166+ "reasoning": true,
167+ "reasoning_options": []
168+ }
169+ }
170+ }
171+ }`))
172+ case "/v1/models":
173+ _, _ = w.Write([]byte(`{"data":[{"id":"muse-spark-1.3-contributor"},{"id":"minimax-m2"},{"id":"gpt-5.6-completions"}]}`))
174+ default:
175+ http.NotFound(w, r)
176+ }
177+ }))
178+ defer server.Close()
179+
180+ models, err := discoverOpenCodeGo(context.Background(), "key", server.URL+"/v1", server.URL+"/api.json")
181+ if err != nil {
182+ t.Fatal(err)
183+ }
184+ if len(models) != 3 {
185+ t.Fatalf("models = %+v, want three models", models)
186+ }
187+ if models[0].API != APIResponses {
188+ t.Fatalf("Responses model API = %q, want %q", models[0].API, APIResponses)
189+ }
190+ if models[0].BaseURL != server.URL+"/v1" {
191+ t.Fatalf("model-level metadata redirected base URL to %q", models[0].BaseURL)
192+ }
193+ if models[1].API != APIAnthropicMessages {
194+ t.Fatalf("Anthropic model API = %q, want %q", models[1].API, APIAnthropicMessages)
195+ }
196+ if models[2].API != APICompletions {
197+ t.Fatalf("provider-default model API = %q, want %q", models[2].API, APICompletions)
198+ }
199+ if got := AvailableReasoningLevels(models[2]); len(got) != 1 || got[0] != "" {
200+ t.Fatalf("empty reasoning options produced levels %q, want only off", got)
201+ }
202+}
203+
204+func TestDiscoverOpenCodeGoRetainsUnsupportedExplicitAdapterForRoutingError(t *testing.T) {
205+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206+ switch r.URL.Path {
207+ case "/api.json":
208+ _, _ = w.Write([]byte(`{"opencode-go":{"npm":"@ai-sdk/google","models":{"unknown-adapter":{"name":"Unknown Adapter"}}}}`))
209+ case "/models":
210+ _, _ = w.Write([]byte(`{"data":[{"id":"unknown-adapter"}]}`))
211+ default:
212+ http.NotFound(w, r)
213+ }
214+ }))
215+ defer server.Close()
216+
217+ models, err := discoverOpenCodeGo(context.Background(), "key", server.URL, server.URL+"/api.json")
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ if len(models) != 1 || models[0].API != "@ai-sdk/google" {
222+ t.Fatalf("unsupported adapter model = %+v, want preserved adapter", models)
223+ }
224+}
A packages/provider/opencode_go_headers.go +34 -0
@@ -0,0 +1,34 @@
1+package provider
2+
3+import (
4+ "net/http"
5+ "strings"
6+)
7+
8+const (
9+ openCodeGoClient = "zut"
10+ openCodeGoUserAgent = "zut"
11+)
12+
13+// setOpenCodeGoHeaders adds the identity OpenCode Go asks third-party agents
14+// to send. The values come from one logical Request and are rebuilt for every
15+// transport attempt, so retries preserve identity without sharing mutable
16+// header state between clients or runtimes.
17+func setOpenCodeGoHeaders(headers http.Header, providerName string, requestContext RequestContext) {
18+ if providerName != ProviderOpenCodeGo {
19+ return
20+ }
21+
22+ session := strings.TrimSpace(requestContext.ThreadID)
23+ if session == "" {
24+ session = strings.TrimSpace(requestContext.CacheSessionID)
25+ }
26+ if session != "" {
27+ headers.Set("x-opencode-session", session)
28+ }
29+ if requestID := strings.TrimSpace(requestContext.TurnID); requestID != "" {
30+ headers.Set("x-opencode-request", requestID)
31+ }
32+ headers.Set("x-opencode-client", openCodeGoClient)
33+ headers.Set("user-agent", openCodeGoUserAgent)
34+}
A packages/provider/opencode_go_identity_test.go +154 -0
@@ -0,0 +1,154 @@
1+package provider
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "sync"
10+ "testing"
11+)
12+
13+func TestOpenCodeGoRetriesKeepRequestIdentityStable(t *testing.T) {
14+ const apiKey = "synthetic-key"
15+ models := []Model{
16+ {Provider: ProviderOpenCodeGo, ID: "retry-responses", API: APIResponses, MaxOutput: 1024},
17+ {Provider: ProviderOpenCodeGo, ID: "retry-messages", API: APIAnthropicMessages, MaxOutput: 1024},
18+ {Provider: ProviderOpenCodeGo, ID: "retry-chat", API: APICompletions, MaxOutput: 1024},
19+ }
20+ var (
21+ mu sync.Mutex
22+ calls = make(map[string]int)
23+ seen = make(map[string][]http.Header)
24+ )
25+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26+ switch r.URL.Path {
27+ case "/v1/responses", "/v1/messages", "/v1/chat/completions":
28+ default:
29+ http.NotFound(w, r)
30+ return
31+ }
32+ mu.Lock()
33+ calls[r.URL.Path]++
34+ attempt := calls[r.URL.Path]
35+ seen[r.URL.Path] = append(seen[r.URL.Path], r.Header.Clone())
36+ mu.Unlock()
37+ if attempt == 1 {
38+ http.Error(w, "temporary gateway failure", http.StatusInternalServerError)
39+ return
40+ }
41+ writeTextResponse(w, r.URL.Path)
42+ }))
43+ defer server.Close()
44+
45+ client := NewOpenCodeGoWithModels(apiKey, server.URL+"/v1", models)
46+ for _, model := range models {
47+ req := Request{
48+ Model: model.ID,
49+ Context: RequestContext{
50+ CacheSessionID: "cache-root",
51+ ThreadID: "thread-stable",
52+ TurnID: "turn-stable",
53+ },
54+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}},
55+ }
56+ if _, err := consumeTextStream(client, req); err != nil {
57+ t.Fatalf("%s: %v", model.ID, err)
58+ }
59+ }
60+
61+ for _, model := range models {
62+ path := map[string]string{
63+ APIResponses: "/v1/responses",
64+ APIAnthropicMessages: "/v1/messages",
65+ APICompletions: "/v1/chat/completions",
66+ }[model.API]
67+ mu.Lock()
68+ got := append([]http.Header(nil), seen[path]...)
69+ mu.Unlock()
70+ if len(got) != 2 {
71+ t.Fatalf("%s attempts = %d, want initial failure and retry", path, len(got))
72+ }
73+ for i, headers := range got {
74+ if headers.Get("x-opencode-client") != "zut" || headers.Get("user-agent") != openCodeGoUserAgent {
75+ t.Fatalf("%s attempt %d identity = client %q user-agent %q", path, i+1, headers.Get("x-opencode-client"), headers.Get("user-agent"))
76+ }
77+ if headers.Get("x-opencode-session") != "thread-stable" || headers.Get("x-opencode-request") != "turn-stable" {
78+ t.Fatalf("%s attempt %d correlation = session %q request %q", path, i+1, headers.Get("x-opencode-session"), headers.Get("x-opencode-request"))
79+ }
80+ }
81+ }
82+}
83+
84+func TestOpenCodeGoHeaderHelperScopesIdentity(t *testing.T) {
85+ openCodeHeaders := make(http.Header)
86+ setOpenCodeGoHeaders(openCodeHeaders, ProviderOpenCodeGo, RequestContext{CacheSessionID: "cache", TurnID: "turn"})
87+ if openCodeHeaders.Get("x-opencode-session") != "cache" || openCodeHeaders.Get("x-opencode-request") != "turn" {
88+ t.Fatalf("fallback identity = session %q request %q", openCodeHeaders.Get("x-opencode-session"), openCodeHeaders.Get("x-opencode-request"))
89+ }
90+ if openCodeHeaders.Get("x-opencode-client") != "zut" || openCodeHeaders.Get("user-agent") != openCodeGoUserAgent {
91+ t.Fatalf("OpenCode Go identity = %v", openCodeHeaders)
92+ }
93+
94+ ordinaryHeaders := make(http.Header)
95+ setOpenCodeGoHeaders(ordinaryHeaders, ProviderOpenAI, RequestContext{ThreadID: "thread", TurnID: "turn"})
96+ if ordinaryHeaders.Get("x-opencode-session") != "" || ordinaryHeaders.Get("x-opencode-request") != "" || ordinaryHeaders.Get("x-opencode-client") != "" || ordinaryHeaders.Get("user-agent") != "" {
97+ t.Fatalf("ordinary provider received OpenCode headers: %v", ordinaryHeaders)
98+ }
99+
100+ emptyHeaders := make(http.Header)
101+ setOpenCodeGoHeaders(emptyHeaders, ProviderOpenCodeGo, RequestContext{})
102+ if emptyHeaders.Get("x-opencode-session") != "" || emptyHeaders.Get("x-opencode-request") != "" {
103+ t.Fatalf("empty context got correlation headers: %v", emptyHeaders)
104+ }
105+ if emptyHeaders.Get("x-opencode-client") != "zut" || emptyHeaders.Get("user-agent") != openCodeGoUserAgent {
106+ t.Fatalf("empty context lost product identity: %v", emptyHeaders)
107+ }
108+
109+ firstSession := make(http.Header)
110+ secondSession := make(http.Header)
111+ setOpenCodeGoHeaders(firstSession, ProviderOpenCodeGo, RequestContext{ThreadID: "thread-a"})
112+ setOpenCodeGoHeaders(secondSession, ProviderOpenCodeGo, RequestContext{ThreadID: "thread-b"})
113+ if firstSession.Get("x-opencode-session") == secondSession.Get("x-opencode-session") {
114+ t.Fatalf("distinct conversations shared session identity %q", firstSession.Get("x-opencode-session"))
115+ }
116+}
117+
118+func TestOpenCodeGoResponsesRetainsIdentityThroughPublicTransport(t *testing.T) {
119+ named := NewOpenAIResponsesNamed("synthetic-key", "https://example.test/v1", ProviderOpenCodeGo).(*renamedClient)
120+ client := named.inner.(*codexClient)
121+ client.SetModelMetadata(Model{Provider: ProviderOpenCodeGo, ID: "responses-model", MaxOutput: 1024})
122+
123+ var got *http.Request
124+ client.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
125+ got = r
126+ return &http.Response{
127+ StatusCode: http.StatusOK,
128+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
129+ Body: io.NopCloser(strings.NewReader("")),
130+ }, nil
131+ })})
132+
133+ stream, err := client.Stream(context.Background(), Request{
134+ Model: "responses-model",
135+ Context: RequestContext{ThreadID: "thread", TurnID: "turn"},
136+ })
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ for range stream {
141+ }
142+ if got == nil {
143+ t.Fatal("request was not sent")
144+ }
145+ if got.Header.Get("x-opencode-client") != "zut" || got.Header.Get("user-agent") != openCodeGoUserAgent {
146+ t.Fatalf("identity stripped by public Responses transport: %v", got.Header)
147+ }
148+ if got.Header.Get("x-opencode-session") != "thread" || got.Header.Get("x-opencode-request") != "turn" {
149+ t.Fatalf("correlation stripped by public Responses transport: %v", got.Header)
150+ }
151+ if got.Header.Get("chatgpt-account-id") != "" || got.Header.Get("originator") != "" || got.Header.Get("openai-beta") != "" {
152+ t.Fatalf("Codex identity leaked through public Responses transport: %v", got.Header)
153+ }
154+}
A packages/provider/opencode_go_protocol_test.go +421 -0
@@ -0,0 +1,421 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "sync"
12+ "testing"
13+)
14+
15+func TestAnthropicMessagesURLKeepsVersionedBasePath(t *testing.T) {
16+ server := httptest.NewServer(nil)
17+ defer server.Close()
18+
19+ for _, test := range []struct {
20+ name string
21+ base string
22+ want string
23+ }{
24+ {name: "host", base: server.URL, want: server.URL + "/v1/messages"},
25+ {name: "v1", base: server.URL + "/v1", want: server.URL + "/v1/messages"},
26+ {name: "v2", base: server.URL + "/v2", want: server.URL + "/v2/messages"},
27+ } {
28+ t.Run(test.name, func(t *testing.T) {
29+ if got := anthropicMessagesURL(test.base); got != test.want {
30+ t.Fatalf("anthropicMessagesURL(%q) = %q, want %q", test.base, got, test.want)
31+ }
32+ })
33+ }
34+}
35+
36+func TestAnthropicCompatUsesScopedModelMetadata(t *testing.T) {
37+ const modelID = "scoped-anthropic-model"
38+ client := NewAnthropicCompat(ProviderOpenCodeGo, "key", "https://example.test/v1").(*anthropicClient)
39+ client.SetModelMetadata(Model{
40+ Provider: ProviderOpenCodeGo,
41+ ID: modelID,
42+ Reasoning: true,
43+ MaxOutput: 4096,
44+ ContextWindow: 32768,
45+ ReasoningLevelMap: map[string]string{
46+ "high": "high",
47+ },
48+ })
49+
50+ wire, err := client.buildRequest(Request{
51+ Model: modelID,
52+ Reasoning: "high",
53+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}},
54+ })
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ if wire.MaxTokens != 4096 || wire.Thinking == nil || wire.Thinking.Type != "enabled" {
59+ t.Fatalf("wire = %+v, want scoped output limit and reasoning", wire)
60+ }
61+}
62+
63+func TestOpenCodeGoAdaptersUsePublishedProtocolsAndIdentity(t *testing.T) {
64+ const apiKey = "synthetic-opencode-key"
65+ const (
66+ responsesModel = "muse-spark-1.3-contributor"
67+ messagesModel = "minimax-m2"
68+ qwenModel = "qwen3-coder"
69+ chatModel = "kimi-k2"
70+ glmModel = "glm-5"
71+ )
72+
73+ type requestRecord struct {
74+ path string
75+ method string
76+ headers http.Header
77+ body map[string]any
78+ }
79+ var (
80+ mu sync.Mutex
81+ calls = make(map[string]int)
82+ records []requestRecord
83+ )
84+ expectedPathByModel := map[string]string{
85+ responsesModel: "/v1/responses",
86+ messagesModel: "/v1/messages",
87+ qwenModel: "/v1/messages",
88+ chatModel: "/v1/chat/completions",
89+ glmModel: "/v1/chat/completions",
90+ }
91+
92+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93+ switch r.URL.Path {
94+ case "/api.json":
95+ _, _ = io.WriteString(w, `{
96+ "opencode-go": {
97+ "npm": "@ai-sdk/openai-compatible",
98+ "models": {
99+ "muse-spark-1.3-contributor": {
100+ "name": "Muse Spark 1.3 Contributor",
101+ "reasoning": true,
102+ "reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
103+ "limit": {"context":128000,"output":8192},
104+ "provider": {"npm":"@ai-sdk/openai"}
105+ },
106+ "minimax-m2": {
107+ "name": "MiniMax M2",
108+ "reasoning": true,
109+ "reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
110+ "limit": {"context":128000,"output":8192},
111+ "provider": {"npm":"@ai-sdk/anthropic"}
112+ },
113+ "qwen3-coder": {
114+ "name": "Qwen 3 Coder",
115+ "reasoning": true,
116+ "reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
117+ "limit": {"context":128000,"output":8192},
118+ "provider": {"npm":"@ai-sdk/anthropic"}
119+ },
120+ "kimi-k2": {
121+ "name": "Kimi K2",
122+ "reasoning": false
123+ },
124+ "glm-5": {
125+ "name": "GLM 5",
126+ "reasoning": false
127+ }
128+ }
129+ }
130+ }`)
131+ return
132+ case "/v1/models":
133+ if got := r.Header.Get("authorization"); got != "Bearer "+apiKey {
134+ t.Errorf("models authorization = %q, want bearer key", got)
135+ }
136+ _, _ = io.WriteString(w, `{"data":[{"id":"muse-spark-1.3-contributor"},{"id":"minimax-m2"},{"id":"qwen3-coder"},{"id":"kimi-k2"},{"id":"glm-5"}]}`)
137+ return
138+ case "/v1/responses", "/v1/messages", "/v1/chat/completions":
139+ default:
140+ http.NotFound(w, r)
141+ return
142+ }
143+
144+ body, err := io.ReadAll(r.Body)
145+ if err != nil {
146+ t.Errorf("read %s request: %v", r.URL.Path, err)
147+ return
148+ }
149+ var decoded map[string]any
150+ if err := json.Unmarshal(body, &decoded); err != nil {
151+ t.Errorf("decode %s request: %v", r.URL.Path, err)
152+ return
153+ }
154+ model, _ := decoded["model"].(string)
155+ if expectedPathByModel[model] != r.URL.Path {
156+ http.Error(w, "model routed to the wrong protocol", http.StatusBadRequest)
157+ return
158+ }
159+ if r.URL.Path == "/v1/responses" {
160+ if decoded["input"] == nil || decoded["messages"] != nil {
161+ http.Error(w, "Responses body has the wrong shape", http.StatusBadRequest)
162+ return
163+ }
164+ } else if decoded["messages"] == nil || decoded["input"] != nil {
165+ http.Error(w, "non-Responses body has the wrong shape", http.StatusBadRequest)
166+ return
167+ }
168+ mu.Lock()
169+ callKey := r.URL.Path + "\x00" + model
170+ calls[callKey]++
171+ call := calls[callKey]
172+ records = append(records, requestRecord{path: r.URL.Path, method: r.Method, headers: r.Header.Clone(), body: decoded})
173+ mu.Unlock()
174+
175+ if r.Header.Get("x-opencode-client") != "zut" || r.Header.Get("user-agent") != "zut" {
176+ t.Errorf("%s identity headers = client %q user-agent %q", r.URL.Path, r.Header.Get("x-opencode-client"), r.Header.Get("user-agent"))
177+ }
178+ if r.Header.Get("x-opencode-session") == "" || r.Header.Get("x-opencode-request") == "" {
179+ t.Errorf("%s missing request identity: %v", r.URL.Path, r.Header)
180+ }
181+ switch r.URL.Path {
182+ case "/v1/chat/completions", "/v1/responses":
183+ if got := r.Header.Get("authorization"); got != "Bearer "+apiKey {
184+ t.Errorf("%s authorization = %q, want bearer key", r.URL.Path, got)
185+ }
186+ if got := r.Header.Get("x-api-key"); got != "" {
187+ t.Errorf("%s leaked x-api-key %q", r.URL.Path, got)
188+ }
189+ case "/v1/messages":
190+ if got := r.Header.Get("x-api-key"); got != apiKey {
191+ t.Errorf("messages x-api-key = %q, want %q", got, apiKey)
192+ }
193+ if got := r.Header.Get("authorization"); got != "" {
194+ t.Errorf("messages leaked authorization %q", got)
195+ }
196+ }
197+ if call == 1 {
198+ writeToolResponse(w, r.URL.Path)
199+ } else {
200+ writeTextResponse(w, r.URL.Path)
201+ }
202+ }))
203+ defer server.Close()
204+
205+ models, err := discoverOpenCodeGo(context.Background(), apiKey, server.URL+"/v1", server.URL+"/api.json")
206+ if err != nil {
207+ t.Fatal(err)
208+ }
209+ client := NewOpenCodeGoWithModels(apiKey, server.URL+"/v1", models)
210+
211+ for _, test := range []struct {
212+ name string
213+ model string
214+ thread string
215+ firstWant string
216+ }{
217+ {name: "responses", model: responsesModel, thread: "thread-responses", firstWant: "/v1/responses"},
218+ {name: "messages minimax", model: messagesModel, thread: "thread-messages-minimax", firstWant: "/v1/messages"},
219+ {name: "messages qwen", model: qwenModel, thread: "thread-messages-qwen", firstWant: "/v1/messages"},
220+ {name: "chat completions kimi", model: chatModel, thread: "thread-chat-kimi", firstWant: "/v1/chat/completions"},
221+ {name: "chat completions glm", model: glmModel, thread: "thread-chat-glm", firstWant: "/v1/chat/completions"},
222+ } {
223+ t.Run(test.name, func(t *testing.T) {
224+ first := Request{
225+ Model: test.model,
226+ Context: RequestContext{ThreadID: test.thread, CacheSessionID: "cache-" + test.name, TurnID: "turn-1"},
227+ Tools: []Tool{{Name: "lookup", Schema: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`)}},
228+ Reasoning: "high",
229+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "find x"}}}},
230+ }
231+ assistant, err := consumeToolStream(client, first)
232+ if err != nil {
233+ t.Fatal(err)
234+ }
235+ second := first
236+ second.Context.TurnID = "turn-2"
237+ second.Messages = append(second.Messages,
238+ Message{Role: RoleAssistant, Content: []Content{assistant}},
239+ Message{Role: RoleTool, Content: []Content{ToolResultBlock{CallID: assistant.ID, Content: []Content{TextBlock{Text: "result"}}}}},
240+ )
241+ if _, err := consumeTextStream(client, second); err != nil {
242+ t.Fatal(err)
243+ }
244+
245+ mu.Lock()
246+ var got []requestRecord
247+ for _, record := range records {
248+ if record.path == test.firstWant && record.body["model"] == test.model {
249+ got = append(got, record)
250+ }
251+ }
252+ mu.Unlock()
253+ if len(got) != 2 {
254+ t.Fatalf("%s requests = %d, want initial and continuation", test.firstWant, len(got))
255+ }
256+ if got[0].method != http.MethodPost || got[1].method != http.MethodPost {
257+ t.Fatalf("methods = %q, %q", got[0].method, got[1].method)
258+ }
259+ if got[0].headers.Get("x-opencode-session") != test.thread || got[1].headers.Get("x-opencode-session") != test.thread {
260+ t.Fatalf("session headers = %q, %q", got[0].headers.Get("x-opencode-session"), got[1].headers.Get("x-opencode-session"))
261+ }
262+ if got[0].headers.Get("x-opencode-request") != "turn-1" || got[1].headers.Get("x-opencode-request") != "turn-2" {
263+ t.Fatalf("request headers = %q, %q", got[0].headers.Get("x-opencode-request"), got[1].headers.Get("x-opencode-request"))
264+ }
265+ assertOpenCodeGoBodyShape(t, test.firstWant, got[0].body, false)
266+ assertOpenCodeGoBodyShape(t, test.firstWant, got[1].body, true)
267+ })
268+ }
269+}
270+
271+func writeToolResponse(w http.ResponseWriter, path string) {
272+ w.Header().Set("content-type", "text/event-stream")
273+ switch path {
274+ case "/v1/chat/completions":
275+ _, _ = io.WriteString(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"q\\\":\\\"x\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n")
276+ case "/v1/responses":
277+ _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"lookup\"}}\n\ndata: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"q\\\":\\\"x\\\"}\"}\n\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\"}}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
278+ case "/v1/messages":
279+ _, _ = io.WriteString(w, "event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call-1\",\"name\":\"lookup\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"q\\\":\\\"x\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"index\":0}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":1}}\n\n\nevent: message_stop\ndata: {}\n\n")
280+ }
281+}
282+
283+func writeTextResponse(w http.ResponseWriter, path string) {
284+ w.Header().Set("content-type", "text/event-stream")
285+ switch path {
286+ case "/v1/chat/completions":
287+ _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
288+ case "/v1/responses":
289+ _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\ndata: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-2\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
290+ case "/v1/messages":
291+ _, _ = io.WriteString(w, "event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\nevent: content_block_stop\ndata: {\"index\":0}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {}\n\n")
292+ }
293+}
294+
295+func consumeToolStream(client Client, req Request) (ToolCallBlock, error) {
296+ stream, err := client.Stream(context.Background(), req)
297+ if err != nil {
298+ return ToolCallBlock{}, err
299+ }
300+ var call ToolCallBlock
301+ for event := range stream {
302+ if done, ok := event.(EventDone); ok {
303+ if done.Err != nil {
304+ return ToolCallBlock{}, done.Err
305+ }
306+ for _, content := range done.Message.Content {
307+ if candidate, ok := content.(ToolCallBlock); ok {
308+ call = candidate
309+ }
310+ }
311+ }
312+ }
313+ if call.ID == "" {
314+ return ToolCallBlock{}, errors.New("stream did not produce a tool call")
315+ }
316+ return call, nil
317+}
318+
319+func consumeTextStream(client Client, req Request) (string, error) {
320+ stream, err := client.Stream(context.Background(), req)
321+ if err != nil {
322+ return "", err
323+ }
324+ var text strings.Builder
325+ for event := range stream {
326+ switch event := event.(type) {
327+ case EventTextDelta:
328+ text.WriteString(event.Delta)
329+ case EventDone:
330+ if event.Err != nil {
331+ return "", event.Err
332+ }
333+ }
334+ }
335+ return text.String(), nil
336+}
337+
338+func assertOpenCodeGoBodyShape(t *testing.T, path string, body map[string]any, continuation bool) {
339+ t.Helper()
340+ if body["model"] == nil || body["stream"] != true {
341+ t.Fatalf("%s body = %#v, want model and stream", path, body)
342+ }
343+ switch path {
344+ case "/v1/responses":
345+ if body["input"] == nil || body["messages"] != nil {
346+ t.Fatalf("Responses body = %#v, want input without messages", body)
347+ }
348+ if reasoning, ok := body["reasoning"].(map[string]any); !ok || reasoning["effort"] != "high" {
349+ t.Fatalf("Responses reasoning = %#v, want effort=high", body["reasoning"])
350+ }
351+ if continuation && !containsResponseItemType(body["input"], "function_call_output") {
352+ t.Fatalf("Responses continuation = %#v, want function_call_output", body["input"])
353+ }
354+ case "/v1/messages":
355+ if body["messages"] == nil || body["input"] != nil {
356+ t.Fatalf("Messages body = %#v, want messages without input", body)
357+ }
358+ if thinking, ok := body["thinking"].(map[string]any); !ok || thinking["type"] != "enabled" {
359+ t.Fatalf("Messages thinking = %#v, want enabled thinking", body["thinking"])
360+ }
361+ if continuation && !containsAnthropicBlockType(body["messages"], "tool_result") {
362+ t.Fatalf("Messages continuation = %#v, want tool_result", body["messages"])
363+ }
364+ case "/v1/chat/completions":
365+ if body["messages"] == nil || body["input"] != nil {
366+ t.Fatalf("Chat Completions body = %#v, want messages without input", body)
367+ }
368+ if continuation && !containsChatRole(body["messages"], "tool") {
369+ t.Fatalf("Chat continuation = %#v, want tool message", body["messages"])
370+ }
371+ }
372+}
373+
374+func containsResponseItemType(value any, want string) bool {
375+ items, ok := value.([]any)
376+ if !ok {
377+ return false
378+ }
379+ for _, item := range items {
380+ if object, ok := item.(map[string]any); ok && object["type"] == want {
381+ return true
382+ }
383+ }
384+ return false
385+}
386+
387+func containsAnthropicBlockType(value any, want string) bool {
388+ messages, ok := value.([]any)
389+ if !ok {
390+ return false
391+ }
392+ for _, message := range messages {
393+ object, ok := message.(map[string]any)
394+ if !ok {
395+ continue
396+ }
397+ blocks, ok := object["content"].([]any)
398+ if !ok {
399+ continue
400+ }
401+ for _, block := range blocks {
402+ if object, ok := block.(map[string]any); ok && object["type"] == want {
403+ return true
404+ }
405+ }
406+ }
407+ return false
408+}
409+
410+func containsChatRole(value any, want string) bool {
411+ messages, ok := value.([]any)
412+ if !ok {
413+ return false
414+ }
415+ for _, message := range messages {
416+ if object, ok := message.(map[string]any); ok && object["role"] == want {
417+ return true
418+ }
419+ }
420+ return false
421+}
M packages/provider/provider_test.go +35 -49
@@ -205,16 +205,45 @@ func TestComputeCostInputTier(t *testing.T) {
205205 PriceTierInputTokens: 100,
206206 PriceInputAbove: 3, PriceOutputAbove: 4, PriceCacheReadAbove: 0.2,
207207 }
208- below := ComputeCost(m, Usage{InputTokens: 50, OutputTokens: 10, CacheReadTokens: 50})
209- wantBelow := (50.0*1 + 10*2 + 50*0.1) / 1_000_000
208+ below := ComputeCost(m, Usage{InputTokens: 50, OutputTokens: 10, CacheReadTokens: 49})
209+ wantBelow := (50.0*1 + 10*2 + 49*0.1) / 1_000_000
210210 if math.Abs(below-wantBelow) > 1e-12 {
211211 t.Fatalf("below-tier cost=%v want=%v", below, wantBelow)
212212 }
213213
214- above := ComputeCost(m, Usage{InputTokens: 51, OutputTokens: 10, CacheReadTokens: 50})
215- wantAbove := (51.0*3 + 10*4 + 50*0.2) / 1_000_000
216- if math.Abs(above-wantAbove) > 1e-12 {
217- t.Fatalf("above-tier cost=%v want=%v", above, wantAbove)
214+ atThreshold := ComputeCost(m, Usage{InputTokens: 50, OutputTokens: 10, CacheReadTokens: 50})
215+ wantAtThreshold := (50.0*3 + 10*4 + 50*0.2) / 1_000_000
216+ if math.Abs(atThreshold-wantAtThreshold) > 1e-12 {
217+ t.Fatalf("at-tier cost=%v want=%v", atThreshold, wantAtThreshold)
218+ }
219+}
220+
221+func TestComputeCostMultipleInputTiers(t *testing.T) {
222+ m := Model{
223+ PriceInput: 1,
224+ PriceTiers: []ModelPriceTier{
225+ {InputTokens: 500000, PriceInput: 5},
226+ {InputTokens: 272000, PriceInput: 3},
227+ },
228+ }
229+ for _, tc := range []struct {
230+ name string
231+ input int
232+ price float64
233+ }{
234+ {"below_first", 271999, 1},
235+ {"at_first", 272000, 3},
236+ {"between", 499999, 3},
237+ {"at_second", 500000, 5},
238+ {"above_second", 600000, 5},
239+ } {
240+ t.Run(tc.name, func(t *testing.T) {
241+ got := ComputeCost(m, Usage{InputTokens: tc.input})
242+ want := float64(tc.input) * tc.price / 1_000_000
243+ if math.Abs(got-want) > 1e-12 {
244+ t.Fatalf("cost = %.12f, want %.12f", got, want)
245+ }
246+ })
218247 }
219248 }
220249
@@ -687,49 +716,6 @@ func TestGPT56AndGPT6CatalogEntries(t *testing.T) {
687716 }
688717 }
689718
690-// TestOpenCodeGoCatalog pins entries added from the vendor's current Go
691-// model lineup (https://opencode.ai/docs/go).
692-func TestOpenCodeGoCatalog(t *testing.T) {
693- cases := []struct {
694- id string
695- context int
696- maxOut int
697- priceIn float64
698- priceOut float64
699- cacheRead float64
700- }{
701- {"gpt-5.6-luna", 1050000, 128000, 0.2, 1.2, 0.02},
702- {"grok-4.5", 500000, 500000, 2, 6, 0.3},
703- {"kimi-k3", 1048576, 131072, 3, 15, 0.3},
704- }
705- for _, tc := range cases {
706- m, err := FindModel("opencode-go", tc.id)
707- if err != nil {
708- t.Fatal(err)
709- }
710- if m.ContextWindow != tc.context || m.MaxOutput != tc.maxOut || !m.Reasoning {
711- t.Fatalf("opencode-go/%s limits: %+v", tc.id, m)
712- }
713- if m.PriceInput != tc.priceIn || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead {
714- t.Fatalf("opencode-go/%s prices: %+v", tc.id, m)
715- }
716- if m.BaseURL != "https://opencode.ai/zen/go/v1" {
717- t.Fatalf("opencode-go/%s baseURL: %s", tc.id, m.BaseURL)
718- }
719- }
720-
721- luna, err := FindModel("opencode-go", "gpt-5.6-luna")
722- if err != nil {
723- t.Fatal(err)
724- }
725- if luna.API != APIResponses || luna.PriceCacheWrite != 0.25 {
726- t.Fatalf("opencode-go/gpt-5.6-luna API or cache-write price: %+v", luna)
727- }
728- if luna.PriceTierInputTokens != 272000 || luna.PriceInputAbove != 0.4 || luna.PriceOutputAbove != 1.8 || luna.PriceCacheReadAbove != 0.04 || luna.PriceCacheWriteAbove != 0.5 {
729- t.Fatalf("opencode-go/gpt-5.6-luna long-context prices: %+v", luna)
730- }
731-}
732-
733719 // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for
734720 // discovered models that don't advertise an output cap (MaxOutput == 0).
735721 func TestOpenAIOmitsZeroMaxTokens(t *testing.T) {
A packages/provider/providers.go +61 -0
@@ -0,0 +1,61 @@
1+package provider
2+
3+// Provider IDs are compile-time constants used by model metadata, routing,
4+// discovery, and credential selection. Go has no enum type with string
5+// interoperability, so untyped constants keep the existing public string
6+// fields and wire formats while giving provider ownership one source of truth.
7+const (
8+ ProviderAnthropic = "anthropic"
9+ ProviderOpenAI = "openai"
10+ ProviderOpenAICodex = "openai-codex"
11+ ProviderOpenAIResponses = "openai-responses"
12+ ProviderKimi = "kimi"
13+ ProviderDeepSeek = "deepseek"
14+ ProviderGoogle = "google"
15+ ProviderOllama = "ollama"
16+ ProviderLlamaCPP = "llama.cpp"
17+ ProviderMoonshot = "moonshotai"
18+ ProviderMoonshotCN = "moonshotai-cn"
19+ ProviderCerebras = "cerebras"
20+ ProviderGroq = "groq"
21+ ProviderXAI = "xai"
22+ ProviderTogether = "together"
23+ ProviderHuggingFace = "huggingface"
24+ ProviderOpenRouter = "openrouter"
25+ ProviderMistral = "mistral"
26+ ProviderZAI = "zai"
27+ ProviderXiaomi = "xiaomi"
28+ ProviderXiaomiTokenPlanAMS = "xiaomi-token-plan-ams"
29+ ProviderXiaomiTokenPlanCN = "xiaomi-token-plan-cn"
30+ ProviderXiaomiTokenPlanSGP = "xiaomi-token-plan-sgp"
31+ ProviderMiniMax = "minimax"
32+ ProviderMiniMaxCN = "minimax-cn"
33+ ProviderFireworks = "fireworks"
34+ ProviderVercelAIGateway = "vercel-ai-gateway"
35+ ProviderOpenCode = "opencode"
36+ ProviderOpenCodeGo = "opencode-go"
37+ ProviderAmazonBedrock = "amazon-bedrock"
38+ ProviderGoogleVertex = "google-vertex"
39+ ProviderAzureOpenAIResponses = "azure-openai-responses"
40+ ProviderGitHubCopilot = "github-copilot"
41+ ProviderCloudflareWorkersAI = "cloudflare-workers-ai"
42+ ProviderCloudflareAIGateway = "cloudflare-ai-gateway"
43+)
44+
45+var builtinProviderIDs = []string{
46+ ProviderAnthropic, ProviderOpenAI, ProviderOpenAICodex, ProviderOpenAIResponses,
47+ ProviderKimi, ProviderDeepSeek, ProviderGoogle, ProviderOllama, ProviderLlamaCPP,
48+ ProviderMoonshot, ProviderMoonshotCN, ProviderCerebras, ProviderGroq, ProviderXAI,
49+ ProviderTogether, ProviderHuggingFace, ProviderOpenRouter, ProviderMistral,
50+ ProviderZAI, ProviderXiaomi, ProviderXiaomiTokenPlanAMS, ProviderXiaomiTokenPlanCN,
51+ ProviderXiaomiTokenPlanSGP, ProviderMiniMax, ProviderMiniMaxCN, ProviderFireworks,
52+ ProviderVercelAIGateway, ProviderOpenCode, ProviderOpenCodeGo, ProviderAmazonBedrock,
53+ ProviderGoogleVertex, ProviderAzureOpenAIResponses, ProviderGitHubCopilot,
54+ ProviderCloudflareWorkersAI, ProviderCloudflareAIGateway,
55+}
56+
57+// BuiltinProviderIDs returns the canonical built-in provider IDs in fallback
58+// priority order. The returned slice is independent of package state.
59+func BuiltinProviderIDs() []string {
60+ return append([]string(nil), builtinProviderIDs...)
61+}
M packages/provider/reasoning.go +14 -1
@@ -92,6 +92,19 @@ func hasReasoningLevelOverride(model Model, level string) bool {
9292 return ok
9393 }
9494
95+// reasoningEffortForModel applies a model-specific wire effort mapping after
96+// the requested level has been clamped to one the model exposes. The fallback
97+// is the conservative protocol mapping for providers without exact metadata.
98+func reasoningEffortForModel(model Model, requested, clamped, fallback string) string {
99+ if wire, ok := model.ReasoningEffortMap[NormalizeReasoning(clamped)]; ok {
100+ return wire
101+ }
102+ if hasReasoningLevelOverride(model, requested) {
103+ return clamped
104+ }
105+ return fallback
106+}
107+
95108 // ClampReasoningForModel maps a configured level to the nearest level exposed
96109 // for the active model. Ties prefer the higher level.
97110 func ClampReasoningForModel(model Model, level string) string {
@@ -146,7 +159,7 @@ func usesReasoningBudget(model Model) bool {
146159 case "anthropic", "fireworks", "kimi", "minimax", "minimax-cn", "vercel-ai-gateway":
147160 return true
148161 }
149- return model.API == "anthropic"
162+ return model.API == APIAnthropicMessages || model.API == "anthropic" // "anthropic" is the legacy user-model API value
150163 }
151164
152165 // NormalizeReasoning canonicalizes zut's user-facing reasoning levels.
M packages/provider/reasoning_test.go +55 -0
@@ -22,6 +22,7 @@ func TestAvailableReasoningLevels(t *testing.T) {
2222 {name: "gemini 2.5 budget", model: Model{Provider: "google", ID: "gemini-2.5-flash", Reasoning: true}, want: []string{"", "minimum", "low", "medium", "high", "xhigh"}},
2323 {name: "gemini alias without control", model: Model{Provider: "google", ID: "gemini-flash-latest", Reasoning: true}, want: []string{""}},
2424 {name: "anthropic budget", model: Model{Provider: "anthropic", Reasoning: true}, want: []string{"", "minimum", "low", "medium", "high", "xhigh"}},
25+ {name: "anthropic messages adapter", model: Model{API: APIAnthropicMessages, Reasoning: true}, want: []string{"", "minimum", "low", "medium", "high", "xhigh"}},
2526 {name: "bedrock without effort control", model: Model{Provider: "amazon-bedrock", Reasoning: true}, want: []string{""}},
2627 {
2728 name: "per-model overrides",
@@ -112,6 +113,60 @@ func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) {
112113 }
113114 }
114115
116+func TestOpenAIRequestUsesExactReasoningEffortMap(t *testing.T) {
117+ preserveActiveCatalog(t)
118+ SetLiveModels([]Model{{
119+ Provider: "custom-compatible",
120+ ID: "compatible-reasoning-model",
121+ Reasoning: true,
122+ ReasoningLevelMap: map[string]string{"minimum": "minimum"},
123+ ReasoningEffortMap: map[string]string{"minimum": "minimal"},
124+ }})
125+
126+ client := NewOpenAICompat("custom-compatible", "test", "", "").(*openaiClient)
127+ request, err := client.buildRequest(Request{Model: "compatible-reasoning-model", Reasoning: "minimum"})
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if request.ReasoningEffort != "minimal" {
132+ t.Fatalf("reasoning effort = %q, want minimal", request.ReasoningEffort)
133+ }
134+}
135+
136+func TestModelsDevReasoningMapsRestrictResponsesDefaults(t *testing.T) {
137+ levelMap, effortMap := modelsDevReasoningMaps(true, []modelsDevReasoningOption{{
138+ Type: "effort",
139+ Values: []string{"low", "medium", "high"},
140+ }})
141+ model := Model{
142+ Provider: "opencode-go",
143+ ID: "gpt-5.6-restricted",
144+ API: APIResponses,
145+ Reasoning: true,
146+ ReasoningLevelMap: levelMap,
147+ ReasoningEffortMap: effortMap,
148+ }
149+ wantLevels := []string{"", "low", "medium", "high"}
150+ if got := AvailableReasoningLevels(model); !slices.Equal(got, wantLevels) {
151+ t.Fatalf("available levels = %q, want %q", got, wantLevels)
152+ }
153+ if got := ClampReasoningForModel(model, "max"); got != "high" {
154+ t.Fatalf("clamped max = %q, want high", got)
155+ }
156+
157+ preserveActiveCatalog(t)
158+ SetLiveModels([]Model{model})
159+ named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "opencode-go").(*renamedClient)
160+ client := named.inner.(*codexClient)
161+ wire, err := client.buildRequest(Request{Model: model.ID, Reasoning: "max"})
162+ if err != nil {
163+ t.Fatal(err)
164+ }
165+ if wire.Reasoning == nil || wire.Reasoning.Effort != "high" {
166+ t.Fatalf("wire reasoning = %+v, want high", wire.Reasoning)
167+ }
168+}
169+
115170 func TestReasoningEffortMappings(t *testing.T) {
116171 cases := []struct {
117172 level string
M packages/provider/usermodels.go +18 -72
@@ -6,6 +6,7 @@ import (
66 "maps"
77 "os"
88 "strings"
9+ "sync"
910 )
1011
1112 // UserModelsFile is the JSON format for user-defined models.
@@ -48,12 +49,18 @@ type CustomProviderConfig struct {
4849 API string // "openai", "openai-responses", or "anthropic"
4950 }
5051
51-var customProviders = map[string]CustomProviderConfig{}
52+var (
53+ customProvidersMu sync.RWMutex
54+ customProviders = map[string]CustomProviderConfig{}
55+)
5256
53-// CustomProviders returns the set of user-defined providers loaded from
54-// models.json. Keys are provider names; values carry the base URL and
55-// wire-format hint.
56-func CustomProviders() map[string]CustomProviderConfig { return customProviders }
57+// CustomProviders returns a snapshot of the user-defined providers loaded
58+// from models.json. Callers cannot mutate provider-owned registry state.
59+func CustomProviders() map[string]CustomProviderConfig {
60+ customProvidersMu.RLock()
61+ defer customProvidersMu.RUnlock()
62+ return maps.Clone(customProviders)
63+}
5764
5865 // UserModel is a single model entry in the user's models.json.
5966 type UserModel struct {
@@ -127,6 +134,8 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
127134 }
128135
129136 var out []Model
137+ customProvidersMu.Lock()
138+ defer customProvidersMu.Unlock()
130139 // Reset custom providers on each load so removed entries don't linger.
131140 customProviders = map[string]CustomProviderConfig{}
132141 for providerName, prov := range file.Providers {
@@ -236,74 +245,11 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
236245 return out, warnings
237246 }
238247
239-// SetUserModels merges user-defined models into the active catalog.
240-// User models take precedence over both the baked-in catalog and
241-// live-discovered models.
248+// SetUserModels replaces the durable user-model overlay. User models take
249+// precedence over the baked-in, live-discovered, and managed catalogs. Keeping
250+// the overlay separate means a later live refresh cannot discard it.
242251 func SetUserModels(models []Model) {
243- if len(models) == 0 {
244- return
245- }
246252 activeMu.Lock()
247253 defer activeMu.Unlock()
248-
249- // Ensure the active overlay starts from the full built-in catalog
250- // when no live/cache overlay has been applied. Otherwise a fresh
251- // install with only models.json would hide every built-in model.
252- if !activeSet || active == nil {
253- active = append([]Model(nil), Catalog...)
254- activeSet = true
255- }
256-
257- // Build index of current active models.
258- byKey := func(p, id string) string { return p + "/" + id }
259- index := make(map[string]int, len(active))
260- for i, m := range active {
261- index[byKey(m.Provider, m.ID)] = i
262- }
263-
264- for _, um := range models {
265- k := byKey(um.Provider, um.ID)
266- if idx, ok := index[k]; ok {
267- // Override existing entry but keep prices from user if
268- // they provided them, otherwise keep catalog prices.
269- existing := active[idx]
270- if um.PriceInput > 0 {
271- existing.PriceInput = um.PriceInput
272- }
273- if um.PriceOutput > 0 {
274- existing.PriceOutput = um.PriceOutput
275- }
276- if um.PriceCacheRead > 0 {
277- existing.PriceCacheRead = um.PriceCacheRead
278- }
279- if um.PriceCacheWrite > 0 {
280- existing.PriceCacheWrite = um.PriceCacheWrite
281- }
282- if um.DisplayName != "" {
283- existing.DisplayName = um.DisplayName
284- }
285- if um.ContextWindow > 0 {
286- existing.ContextWindow = um.ContextWindow
287- }
288- if um.MaxOutput > 0 {
289- existing.MaxOutput = um.MaxOutput
290- }
291- existing.Reasoning = um.Reasoning
292- if um.ReasoningLevelMap != nil {
293- existing.ReasoningLevelMap = maps.Clone(um.ReasoningLevelMap)
294- }
295- if um.API != "" {
296- existing.API = um.API
297- }
298- if um.BaseURL != "" {
299- existing.BaseURL = um.BaseURL
300- }
301- existing.Source = "user"
302- existing.Speculative = false
303- active[idx] = existing
304- } else {
305- // New model not in catalog.
306- active = append(active, um)
307- }
308- }
254+ userModels = cloneModels(models)
309255 }
M packages/provider/usermodels_test.go +87 -1
@@ -9,7 +9,11 @@ import (
99
1010 func TestSetUserModelsPreservesCatalogWithoutLiveOverlay(t *testing.T) {
1111 SetLiveModels(nil)
12- t.Cleanup(func() { SetLiveModels(nil) })
12+ SetUserModels(nil)
13+ t.Cleanup(func() {
14+ SetLiveModels(nil)
15+ SetUserModels(nil)
16+ })
1317
1418 SetUserModels([]Model{{
1519 Provider: "custom-test",
@@ -26,6 +30,51 @@ func TestSetUserModelsPreservesCatalogWithoutLiveOverlay(t *testing.T) {
2630 }
2731 }
2832
33+func TestSetUserModelsSurvivesLiveCatalogRefresh(t *testing.T) {
34+ SetLiveModels(nil)
35+ SetUserModels(nil)
36+ t.Cleanup(func() {
37+ SetLiveModels(nil)
38+ SetUserModels(nil)
39+ })
40+
41+ SetUserModels([]Model{
42+ {
43+ Provider: "opencode-go",
44+ ID: "served-model",
45+ DisplayName: "Pinned model",
46+ ContextWindow: 123456,
47+ MaxOutput: 7890,
48+ Reasoning: false,
49+ API: APIResponses,
50+ BaseURL: "https://proxy.example/v1",
51+ },
52+ {Provider: "opencode-go", ID: "user-only", DisplayName: "User only"},
53+ })
54+
55+ SetLiveModelsForProviders([]Model{
56+ {
57+ Provider: "opencode-go",
58+ ID: "served-model",
59+ DisplayName: "Live model",
60+ ContextWindow: 999999,
61+ MaxOutput: 99999,
62+ Reasoning: true,
63+ },
64+ }, []string{"opencode-go"})
65+
66+ got, err := FindModel("opencode-go", "served-model")
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ if got.DisplayName != "Pinned model" || got.ContextWindow != 123456 || got.MaxOutput != 7890 || got.Reasoning || got.API != APIResponses || got.BaseURL != "https://proxy.example/v1" || got.Source != "user" {
71+ t.Fatalf("user override was lost after refresh: %+v", got)
72+ }
73+ if _, err := FindModel("opencode-go", "user-only"); err != nil {
74+ t.Fatalf("user-only model was lost after refresh: %v", err)
75+ }
76+}
77+
2978 func TestLoadUserModelsRegistersModelLevelBaseURLCustomProvider(t *testing.T) {
3079 dir := t.TempDir()
3180 path := filepath.Join(dir, "models.json")
@@ -168,3 +217,40 @@ func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {
168217 t.Fatalf("api = %q, want openai", cfg.API)
169218 }
170219 }
220+
221+func TestUserPriceOverrideClearsCatalogTiers(t *testing.T) {
222+ SetLiveModels([]Model{{
223+ Provider: ProviderOpenCodeGo,
224+ ID: "tiered-model",
225+ PriceInput: 1,
226+ PriceOutput: 2,
227+ PriceTiers: []ModelPriceTier{{InputTokens: 100, PriceInput: 9, PriceOutput: 10}},
228+ PriceTierInputTokens: 100,
229+ PriceInputAbove: 9,
230+ PriceOutputAbove: 10,
231+ PriceCacheReadAbove: 11,
232+ PriceCacheWriteAbove: 12,
233+ }})
234+ SetUserModels([]Model{{
235+ Provider: ProviderOpenCodeGo,
236+ ID: "tiered-model",
237+ PriceInput: 3,
238+ PriceOutput: 4,
239+ }})
240+ t.Cleanup(func() {
241+ SetLiveModels(nil)
242+ SetUserModels(nil)
243+ })
244+
245+ model, err := FindModel(ProviderOpenCodeGo, "tiered-model")
246+ if err != nil {
247+ t.Fatal(err)
248+ }
249+ if len(model.PriceTiers) != 0 || model.PriceTierInputTokens != 0 || model.PriceInputAbove != 0 || model.PriceOutputAbove != 0 || model.PriceCacheReadAbove != 0 || model.PriceCacheWriteAbove != 0 {
250+ t.Fatalf("user price override retained catalog tiers: %+v", model)
251+ }
252+ want := 100 * 3 / 1_000_000.0
253+ if got := ComputeCost(model, Usage{InputTokens: 100}); got != want {
254+ t.Fatalf("cost = %v, want user base price %v", got, want)
255+ }
256+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close