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 Unverified
B5690EEEBB952194The key is unknown
49 files · +4625 -478
M README.md +4 -4
| @@ -148,7 +148,7 @@ $ZUT_HOME/ | |||
| 148 | 148 | ├── config.json # last-used provider/model/theme and persistent settings | |
| 149 | 149 | ├── auth.json # api keys and oauth tokens (mode 0600) | |
| 150 | 150 | ├── 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) | |
| 152 | 152 | ├── AGENTS.md # optional: global instructions appended to the prompt | |
| 153 | 153 | ├── SYSTEM.md # optional: replaces the built-in identity; addenda remain | |
| 154 | 154 | ├── skills/ # optional: user SKILL.md files | |
| @@ -310,7 +310,7 @@ Single-part names resolve only to a matching local directory or `.zut` archive. | |||
| 310 | 310 | ||
| 311 | 311 | Two ways to drive zut from another program: | |
| 312 | 312 | ||
| 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/`. | |
| 314 | 314 | - **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/`. | |
| 315 | 315 | ||
| 316 | 316 | 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 | |||
| 536 | 536 | `--list-models` or the `/model` picker shows the full catalog across all built-in providers. Three sources: | |
| 537 | 537 | ||
| 538 | 538 | - **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. | |
| 540 | 540 | - **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. | |
| 541 | 541 | ||
| 542 | 542 | 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 | |||
| 545 | 545 | ||
| 546 | 546 | 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. | |
| 547 | 547 | ||
| 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. | |
| 549 | 549 | ||
| 550 | 550 | 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. | |
| 551 | 551 | ||
M docs/providers.md +37 -4
| @@ -136,6 +136,36 @@ export OPENROUTER_API_KEY=... | |||
| 136 | 136 | zut --provider openrouter | |
| 137 | 137 | ``` | |
| 138 | 138 | ||
| 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 | + | ||
| 139 | 169 | ## Fast mode | |
| 140 | 170 | ||
| 141 | 171 | 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, | |||
| 175 | 205 | and endpoint declaration supports it. Resident children share the root cache | |
| 176 | 206 | identity but retain distinct thread identities. The OpenAI/Codex Responses | |
| 177 | 207 | 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` | |
| 182 | 215 | is a conservative local prompt-length estimate; OpenAI performs the authoritative | |
| 183 | 216 | tokenization and cache decision. These records never include prompts, durable | |
| 184 | 217 | IDs, request bodies, or credentials. | |
M packages/agent/args.go +10 -5
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | 10 | "github.com/bnema/zut/packages/agent/tools" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 11 | 12 | "github.com/bnema/zut/packages/tui" | |
| 12 | 13 | "github.com/google/uuid" | |
| 13 | 14 | "golang.org/x/term" | |
| @@ -35,11 +36,15 @@ type Args struct { | |||
| 35 | 36 | Model string | |
| 36 | 37 | APIKey string | |
| 37 | 38 | ||
| 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 | |
| 43 | 48 | ||
| 44 | 49 | // FastMode is an internal resident-child propagation value. | |
| 45 | 50 | FastMode bool | |
M packages/agent/build.go +195 -92
| @@ -57,6 +57,16 @@ type Resolved struct { | |||
| 57 | 57 | // long writes/edits with stopReason=length). | |
| 58 | 58 | MaxOutput int | |
| 59 | 59 | ||
| 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 | + | ||
| 60 | 70 | // SkillTool is the on-demand skill loader registered with the | |
| 61 | 71 | // agent's tool registry, or nil if no SKILL.md files were | |
| 62 | 72 | // discovered. Exposed so the tui can list / preview skills. | |
| @@ -84,6 +94,19 @@ type Resolved struct { | |||
| 84 | 94 | // HasCredential reports whether a credential was resolved. | |
| 85 | 95 | func (r Resolved) HasCredential() bool { return r.Credential != "" } | |
| 86 | 96 | ||
| 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 | + | ||
| 87 | 110 | // MergeExtensionTools folds every tool registered by an extension | |
| 88 | 111 | // into r's ToolRegistry and re-renders the system prompt's tool | |
| 89 | 112 | // 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 | |||
| 249 | 272 | return out | |
| 250 | 273 | } | |
| 251 | 274 | ||
| 275 | + | const openCodeGoBootstrapModel = "kimi-k2.6" | |
| 276 | + | ||
| 252 | 277 | // defaultModelForProvider returns the model id zut prefers when the | |
| 253 | 278 | // caller didn't pick one. Mirrors the per-provider switch used at | |
| 254 | 279 | // multiple points in Resolve; centralised so the unknown-model | |
| @@ -259,57 +284,63 @@ func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []To | |||
| 259 | 284 | // error or use whatever the user passed. | |
| 260 | 285 | func defaultModelForProvider(prov string) string { | |
| 261 | 286 | switch prov { | |
| 262 | - | case "openai": | |
| 287 | + | case provider.ProviderOpenAI: | |
| 263 | 288 | return "gpt-5" | |
| 264 | - | case "openai-codex": | |
| 289 | + | case provider.ProviderOpenAICodex: | |
| 265 | 290 | return "gpt-5.5" | |
| 266 | - | case "openai-responses": | |
| 291 | + | case provider.ProviderOpenAIResponses: | |
| 267 | 292 | return "gpt-5" | |
| 268 | - | case "kimi": | |
| 293 | + | case provider.ProviderKimi: | |
| 269 | 294 | return "kimi-for-coding" | |
| 270 | - | case "deepseek": | |
| 295 | + | case provider.ProviderDeepSeek: | |
| 271 | 296 | return "deepseek-v4-pro" | |
| 272 | - | case "google": | |
| 297 | + | case provider.ProviderGoogle: | |
| 273 | 298 | return "gemini-2.5-pro" | |
| 274 | - | case "ollama", provider.LlamaCPPProviderID: | |
| 299 | + | case provider.ProviderOllama, provider.ProviderLlamaCPP: | |
| 275 | 300 | return "" | |
| 276 | - | case "moonshotai", "moonshotai-cn": | |
| 301 | + | case provider.ProviderMoonshot, provider.ProviderMoonshotCN: | |
| 277 | 302 | return "kimi-k2.6" | |
| 278 | - | case "cerebras": | |
| 303 | + | case provider.ProviderCerebras: | |
| 279 | 304 | return "qwen-3-235b-a22b-instruct-2507" | |
| 280 | - | case "groq": | |
| 305 | + | case provider.ProviderGroq: | |
| 281 | 306 | return "llama-3.3-70b-versatile" | |
| 282 | - | case "xai": | |
| 307 | + | case provider.ProviderXAI: | |
| 283 | 308 | return "grok-4.5" | |
| 284 | - | case "together": | |
| 309 | + | case provider.ProviderTogether: | |
| 285 | 310 | return "Qwen/Qwen3-Coder-480B-A35B-Instruct" | |
| 286 | - | case "huggingface": | |
| 311 | + | case provider.ProviderHuggingFace: | |
| 287 | 312 | return "moonshotai/Kimi-K2-Instruct" | |
| 288 | - | case "openrouter": | |
| 313 | + | case provider.ProviderOpenRouter: | |
| 289 | 314 | return "anthropic/claude-sonnet-4.5" | |
| 290 | - | case "mistral": | |
| 315 | + | case provider.ProviderMistral: | |
| 291 | 316 | return "mistral-large-latest" | |
| 292 | - | case "zai": | |
| 317 | + | case provider.ProviderZAI: | |
| 293 | 318 | 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: | |
| 295 | 320 | return "mimo-v2.5" | |
| 296 | - | case "minimax", "minimax-cn": | |
| 321 | + | case provider.ProviderMiniMax, provider.ProviderMiniMaxCN: | |
| 297 | 322 | return "MiniMax-M2.7" | |
| 298 | - | case "fireworks": | |
| 323 | + | case provider.ProviderFireworks: | |
| 299 | 324 | return "accounts/fireworks/models/kimi-k2p6" | |
| 300 | - | case "vercel-ai-gateway": | |
| 325 | + | case provider.ProviderVercelAIGateway: | |
| 301 | 326 | return "anthropic/claude-sonnet-4.5" | |
| 302 | - | case "opencode": | |
| 327 | + | case provider.ProviderOpenCode: | |
| 303 | 328 | 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: | |
| 307 | 338 | return "anthropic.claude-sonnet-4-5-20250929-v1:0" | |
| 308 | - | case "google-vertex": | |
| 339 | + | case provider.ProviderGoogleVertex: | |
| 309 | 340 | return "gemini-2.5-pro" | |
| 310 | - | case "azure-openai-responses": | |
| 341 | + | case provider.ProviderAzureOpenAIResponses: | |
| 311 | 342 | return "gpt-5" | |
| 312 | - | case "github-copilot": | |
| 343 | + | case provider.ProviderGitHubCopilot: | |
| 313 | 344 | return "claude-sonnet-4.5" | |
| 314 | 345 | default: | |
| 315 | 346 | // Custom providers: pick the first model from the catalog for | |
| @@ -325,18 +356,7 @@ func defaultModelForProvider(prov string) string { | |||
| 325 | 356 | // Resolve to validate args.Provider, by extension-callers, and by the | |
| 326 | 357 | // auto-fallback logic that picks any logged-in provider when the user's | |
| 327 | 358 | // 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() | |
| 340 | 360 | ||
| 341 | 361 | func isKnownProvider(name string) bool { | |
| 342 | 362 | for _, p := range knownProviders { | |
| @@ -486,6 +506,35 @@ func webSearchAllowedForRegistry(args Args) bool { | |||
| 486 | 506 | } | |
| 487 | 507 | } | |
| 488 | 508 | ||
| 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 | + | ||
| 489 | 538 | // Resolve merges args, config, and env into a Resolved set. | |
| 490 | 539 | // | |
| 491 | 540 | // Unlike the earlier version, Resolve NEVER returns an error for | |
| @@ -606,12 +655,29 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 606 | 655 | } | |
| 607 | 656 | } | |
| 608 | 657 | ||
| 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 | + | } | |
| 610 | 670 | if model == "" { | |
| 611 | 671 | if provName == "ollama" || provName == provider.LlamaCPPProviderID { | |
| 612 | 672 | return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName) | |
| 613 | 673 | } | |
| 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 | + | } | |
| 615 | 681 | } | |
| 616 | 682 | // If the resolved model belongs to a different provider (e.g. config | |
| 617 | 683 | // 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) { | |||
| 620 | 686 | // router providers (openrouter, vercel-ai-gateway, etc.) can serve | |
| 621 | 687 | // models from any provider in the catalog. | |
| 622 | 688 | 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 { | |
| 624 | 694 | model = defaultModelForProvider(provName) | |
| 625 | 695 | } | |
| 626 | 696 | } | |
| 627 | - | resolvedModel, err := provider.FindModel(provName, model) | |
| 697 | + | resolvedModel, err := findModelForResolve(provName, model, args.modelCatalog) | |
| 628 | 698 | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) { | |
| 629 | 699 | // Local providers are intentionally open-catalogue: any model id the | |
| 630 | 700 | // configured server understands is valid, even if it is not cached. | |
| @@ -639,26 +709,32 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 639 | 709 | } | |
| 640 | 710 | err = nil | |
| 641 | 711 | } | |
| 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 | + | } | |
| 642 | 720 | // Custom providers are open-catalogue like ollama: any model id the | |
| 643 | 721 | // 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", | |
| 656 | 731 | } | |
| 732 | + | err = nil | |
| 657 | 733 | } | |
| 658 | - | if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" { | |
| 734 | + | if customProviderConfig != nil && err == nil && resolvedModel.BaseURL == "" && customProviderConfig.BaseURL != "" { | |
| 659 | 735 | // Fall back to the provider-level base URL when the model does | |
| 660 | 736 | // not define its own endpoint. | |
| 661 | - | resolvedModel.BaseURL = cfg.BaseURL | |
| 737 | + | resolvedModel.BaseURL = customProviderConfig.BaseURL | |
| 662 | 738 | } | |
| 663 | 739 | if err != nil { | |
| 664 | 740 | // The model the user (or persisted config) asked for is no | |
| @@ -687,13 +763,18 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 687 | 763 | } | |
| 688 | 764 | // Don't repair config — the routed model id may be valid upstream. | |
| 689 | 765 | } 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) | |
| 692 | 768 | if ferr != nil { | |
| 693 | 769 | // Even the provider default is gone (catastrophic | |
| 694 | 770 | // 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 { | |
| 697 | 778 | fm = candidates[0] | |
| 698 | 779 | } else { | |
| 699 | 780 | fm = provider.DefaultModel | |
| @@ -702,7 +783,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 702 | 783 | fmt.Fprintf(os.Stderr, | |
| 703 | 784 | "zut: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n", | |
| 704 | 785 | model, fm.ID) | |
| 705 | - | if args.Model == "" && cfg.Model == model { | |
| 786 | + | if strings.TrimSpace(args.Model) == "" && strings.TrimSpace(cfg.Model) == model { | |
| 706 | 787 | cfg.Model = fm.ID | |
| 707 | 788 | repairConfig = true | |
| 708 | 789 | } | |
| @@ -880,33 +961,51 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 880 | 961 | } | |
| 881 | 962 | max := args.MaxSteps // 0 = unlimited | |
| 882 | 963 | ||
| 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 | + | ||
| 883 | 979 | 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), | |
| 910 | 1009 | }, nil | |
| 911 | 1010 | } | |
| 912 | 1011 | ||
| @@ -1089,8 +1188,8 @@ func (r Resolved) NewClient() provider.Client { | |||
| 1089 | 1188 | return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)) | |
| 1090 | 1189 | case "opencode": | |
| 1091 | 1190 | 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)) | |
| 1094 | 1193 | case "minimax": | |
| 1095 | 1194 | return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)) | |
| 1096 | 1195 | case "minimax-cn": | |
| @@ -1115,8 +1214,12 @@ func (r Resolved) NewClient() provider.Client { | |||
| 1115 | 1214 | return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)) | |
| 1116 | 1215 | default: | |
| 1117 | 1216 | // 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 { | |
| 1120 | 1223 | case provider.APIResponses: | |
| 1121 | 1224 | return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider)) | |
| 1122 | 1225 | 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 { | |||
| 904 | 904 | } | |
| 905 | 905 | // Model catalog: load any cached discovery data before we inspect | |
| 906 | 906 | // 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) | |
| 908 | 909 | ||
| 909 | 910 | if args.ListModels { | |
| 910 | 911 | printModels() | |
M packages/agent/modelsync.go +373 -65
| @@ -2,15 +2,23 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/sha256" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| 9 | + | "reflect" | |
| 8 | 10 | "strings" | |
| 11 | + | "sync" | |
| 9 | 12 | "time" | |
| 10 | 13 | ||
| 11 | 14 | "github.com/bnema/zut/packages/provider" | |
| 12 | 15 | ) | |
| 13 | 16 | ||
| 17 | + | var ( | |
| 18 | + | discoverOpenCodeGoFn = provider.DiscoverOpenCodeGo | |
| 19 | + | modelCatalogMu sync.Mutex | |
| 20 | + | ) | |
| 21 | + | ||
| 14 | 22 | // ModelCachePath returns the on-disk location of the merged model cache. | |
| 15 | 23 | func ModelCachePath() string { | |
| 16 | 24 | return filepath.Join(ZutHome(), "models-cache.json") | |
| @@ -25,11 +33,68 @@ func UserModelsPath() string { | |||
| 25 | 33 | // package so FindModel / ModelsForProvider see live ids immediately. | |
| 26 | 34 | // Safe to call before any credentials are known. | |
| 27 | 35 | 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) | |
| 28 | 93 | c, err := provider.LoadCache(ModelCachePath()) | |
| 29 | - | if err != nil { | |
| 94 | + | if err != nil || c.Version != provider.ModelCacheVersion { | |
| 30 | 95 | return | |
| 31 | 96 | } | |
| 32 | - | c = filterCacheByProviderScopes(c, currentModelProviderScopes()) | |
| 97 | + | c = filterCacheByProviderScopes(c, scopes) | |
| 33 | 98 | if len(c.Models) > 0 || len(c.AuthoritativeProviders) > 0 { | |
| 34 | 99 | provider.SetLiveModelsForProviders(c.Models, c.AuthoritativeProviders) | |
| 35 | 100 | } | |
| @@ -39,10 +104,44 @@ func LoadCachedModels() { | |||
| 39 | 104 | // discovered catalog account-specific. The cache remains provider-neutral: | |
| 40 | 105 | // each provider supplies an opaque scope value when it needs one. | |
| 41 | 106 | func currentModelProviderScopes() map[string]string { | |
| 107 | + | return currentModelProviderScopesForBaseURL("") | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func currentModelProviderScopesForBaseURL(openCodeGoBaseURL string) map[string]string { | |
| 42 | 111 | scopes := make(map[string]string) | |
| 43 | 112 | if token := loadOAuthToken("openai"); token != nil && token.AccountID != "" { | |
| 44 | 113 | scopes["openai-codex"] = token.AccountID | |
| 45 | 114 | } | |
| 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 | + | } | |
| 46 | 145 | return scopes | |
| 47 | 146 | } | |
| 48 | 147 | ||
| @@ -51,7 +150,12 @@ func currentModelProviderScopes() map[string]string { | |||
| 51 | 150 | // marker so that a failed refresh falls back to the baked-in catalog instead | |
| 52 | 151 | // of treating another account's empty catalog as authoritative. | |
| 53 | 152 | 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 { | |
| 54 | 157 | out := provider.ModelCache{ | |
| 158 | + | Version: c.Version, | |
| 55 | 159 | FetchedAt: c.FetchedAt, | |
| 56 | 160 | Models: append([]provider.Model(nil), c.Models...), | |
| 57 | 161 | AuthoritativeProviders: append([]string(nil), c.AuthoritativeProviders...), | |
| @@ -61,7 +165,10 @@ func filterCacheByProviderScopes(c provider.ModelCache, scopes map[string]string | |||
| 61 | 165 | out.ProviderScopes[name] = scope | |
| 62 | 166 | } | |
| 63 | 167 | ||
| 64 | - | scopedProviders := map[string]struct{}{"openai-codex": {}} | |
| 168 | + | scopedProviders := map[string]struct{}{ | |
| 169 | + | provider.ProviderOpenAICodex: {}, | |
| 170 | + | provider.ProviderOpenCodeGo: {}, | |
| 171 | + | } | |
| 65 | 172 | for name := range c.ProviderScopes { | |
| 66 | 173 | scopedProviders[name] = struct{}{} | |
| 67 | 174 | } | |
| @@ -69,6 +176,9 @@ func filterCacheByProviderScopes(c provider.ModelCache, scopes map[string]string | |||
| 69 | 176 | scopedProviders[name] = struct{}{} | |
| 70 | 177 | } | |
| 71 | 178 | for name := range scopedProviders { | |
| 179 | + | if name == keepProvider { | |
| 180 | + | continue | |
| 181 | + | } | |
| 72 | 182 | if cachedScope := c.ProviderScopes[name]; cachedScope != "" && cachedScope == scopes[name] { | |
| 73 | 183 | continue | |
| 74 | 184 | } | |
| @@ -116,12 +226,13 @@ func providerScopesEqual(a, b map[string]string) bool { | |||
| 116 | 226 | // Any validation issues (bad provider id, empty model id, malformed | |
| 117 | 227 | // JSON, negative widths) are surfaced as one warning per line on stderr; | |
| 118 | 228 | // the well-formed entries from the rest of the file are still loaded. | |
| 119 | - | func LoadUserModels() { | |
| 229 | + | func LoadUserModels() []provider.Model { | |
| 120 | 230 | models, warnings := provider.LoadUserModelsWithWarnings(UserModelsPath()) | |
| 121 | 231 | for _, w := range warnings { | |
| 122 | 232 | fmt.Fprintln(os.Stderr, "zut:", w) | |
| 123 | 233 | } | |
| 124 | 234 | provider.SetUserModels(models) | |
| 235 | + | return models | |
| 125 | 236 | } | |
| 126 | 237 | ||
| 127 | 238 | // isGatewayProvider returns true for providers whose OpenAI-compatible | |
| @@ -159,6 +270,8 @@ func isGatewayRoutedModelID(model string) bool { | |||
| 159 | 270 | // | |
| 160 | 271 | // Gateway providers are exempt from the cross-provider model check for routed | |
| 161 | 272 | // 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. | |
| 162 | 275 | // | |
| 163 | 276 | // Silent on success; one stderr line per repair. Errors loading or | |
| 164 | 277 | // saving the file are non-fatal — the caller continues with defaults. | |
| @@ -177,26 +290,28 @@ func ValidateAndRepairConfig() { | |||
| 177 | 290 | changed = true | |
| 178 | 291 | } | |
| 179 | 292 | ||
| 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) { | |
| 186 | 301 | // 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 { | |
| 188 | 303 | fix := defaultModelForProvider(cfg.Provider) | |
| 189 | 304 | fmt.Fprintf(os.Stderr, | |
| 190 | 305 | "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) | |
| 192 | 307 | cfg.Model = fix | |
| 193 | 308 | 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) { | |
| 195 | 310 | // Model id not in any catalog. Reset to provider's default. | |
| 196 | 311 | fix := defaultModelForProvider(cfg.Provider) | |
| 197 | 312 | fmt.Fprintf(os.Stderr, | |
| 198 | 313 | "zut: config.json: model %q not found in the active catalog; switched to %q\n", | |
| 199 | - | cfg.Model, fix) | |
| 314 | + | modelID, fix) | |
| 200 | 315 | cfg.Model = fix | |
| 201 | 316 | changed = true | |
| 202 | 317 | } | |
| @@ -216,8 +331,12 @@ func ValidateAndRepairConfig() { | |||
| 216 | 331 | // | |
| 217 | 332 | // Silent on error: discovery is a nice-to-have. Callers can still use | |
| 218 | 333 | // 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, "") | |
| 221 | 340 | go func() { | |
| 222 | 341 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 223 | 342 | defer cancel() | |
| @@ -225,6 +344,16 @@ func RefreshModelsAsync() { | |||
| 225 | 344 | }() | |
| 226 | 345 | } | |
| 227 | 346 | ||
| 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 | + | ||
| 228 | 357 | // RefreshLlamaCPPModels adds the router's currently loaded models to the | |
| 229 | 358 | // active catalog. Unloaded models remain in the management UI and cannot be | |
| 230 | 359 | // selected for inference until they are loaded. | |
| @@ -249,81 +378,260 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e | |||
| 249 | 378 | return nil | |
| 250 | 379 | } | |
| 251 | 380 | ||
| 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 | + | } | |
| 257 | 397 | } | |
| 398 | + | return true | |
| 399 | + | } | |
| 258 | 400 | ||
| 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 | + | } | |
| 261 | 410 | ||
| 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 | |
| 271 | 415 | } | |
| 272 | 416 | } | |
| 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 | + | } | |
| 273 | 462 | ||
| 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 | + | } | |
| 280 | 499 | } | |
| 281 | 500 | } | |
| 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 | + | } | |
| 285 | 507 | } | |
| 286 | 508 | } | |
| 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)) | |
| 296 | 514 | } | |
| 297 | 515 | } | |
| 298 | 516 | } | |
| 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, "") | |
| 304 | 526 | } | |
| 305 | - | all = append(all, live...) | |
| 306 | 527 | } | |
| 307 | 528 | } | |
| 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 | + | } | |
| 311 | 535 | } | |
| 312 | 536 | } | |
| 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 | + | } | |
| 318 | 545 | } | |
| 319 | 546 | } | |
| 320 | 547 | ||
| 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 | + | } | |
| 321 | 608 | if len(all) == 0 && len(authoritativeProviders) == 0 { | |
| 322 | 609 | return | |
| 323 | 610 | } | |
| 611 | + | if ctx.Err() != nil { | |
| 612 | + | return | |
| 613 | + | } | |
| 324 | 614 | 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 | + | } | |
| 325 | 632 | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{ | |
| 326 | - | FetchedAt: time.Now().UTC(), | |
| 633 | + | Version: provider.ModelCacheVersion, | |
| 634 | + | FetchedAt: fetchedAt, | |
| 327 | 635 | Models: all, | |
| 328 | 636 | AuthoritativeProviders: authoritativeProviders, | |
| 329 | 637 | ProviderScopes: providerScopes, | |
M packages/agent/modelsync_test.go +926 -0
| @@ -3,14 +3,18 @@ package agent | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "net/http/httptest" | |
| 9 | 10 | "os" | |
| 10 | 11 | "path/filepath" | |
| 12 | + | "sync" | |
| 11 | 13 | "testing" | |
| 14 | + | "time" | |
| 12 | 15 | ||
| 13 | 16 | "github.com/bnema/zut/packages/provider" | |
| 17 | + | "github.com/bnema/zut/packages/provider/auth" | |
| 14 | 18 | ) | |
| 15 | 19 | ||
| 16 | 20 | func TestFilterCacheByProviderScopesRemovesMismatchedProvider(t *testing.T) { | |
| @@ -48,6 +52,81 @@ func TestFilterCacheByProviderScopesRetainsMatchingScopedProvider(t *testing.T) | |||
| 48 | 52 | } | |
| 49 | 53 | } | |
| 50 | 54 | ||
| 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 | + | ||
| 51 | 130 | func TestFilterCacheByProviderScopesDropsLegacyAuthoritativeCodex(t *testing.T) { | |
| 52 | 131 | cache := provider.ModelCache{ | |
| 53 | 132 | Models: []provider.Model{{Provider: "openai-codex", ID: "legacy"}}, | |
| @@ -169,6 +248,776 @@ func TestValidateAndRepairConfig_UnknownModel(t *testing.T) { | |||
| 169 | 248 | } | |
| 170 | 249 | } | |
| 171 | 250 | ||
| 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 | + | ||
| 172 | 1021 | func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *testing.T) { | |
| 173 | 1022 | home := t.TempDir() | |
| 174 | 1023 | t.Setenv("ZUT_HOME", home) | |
| @@ -242,3 +1091,80 @@ func TestValidateAndRepairConfig_HappyPath(t *testing.T) { | |||
| 242 | 1091 | t.Errorf("model mutated: %q", out.Model) | |
| 243 | 1092 | } | |
| 244 | 1093 | } | |
| 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() { | |||
| 492 | 492 | dialog = i.llamaDialog.Render(i.cfg.Theme, dialogWidth) | |
| 493 | 493 | case i.rescueDialog.Active(): | |
| 494 | 494 | 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()) | |
| 496 | 499 | case i.sessionDialog.Active(): | |
| 497 | 500 | dialogID = "sessions" | |
| 498 | 501 | i.sessionDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | |
| @@ -548,7 +551,10 @@ func (i *Interactive) redraw() { | |||
| 548 | 551 | dialogTitle := floatingOverlayTitle(dialogID) | |
| 549 | 552 | dialogRemovedTopRows := 0 | |
| 550 | 553 | 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 | + | } | |
| 552 | 558 | frameTitle, body, removedTopRows := floatingDialogBody(dialog) | |
| 553 | 559 | if frameTitle != "" { | |
| 554 | 560 | dialogTitle = frameTitle | |
M packages/agent/modes/rescue_dialog.go +59 -20
| @@ -22,8 +22,10 @@ type rescueDialog struct { | |||
| 22 | 22 | current string // currently active model id (excluded from view) | |
| 23 | 23 | query string | |
| 24 | 24 | 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 | |
| 26 | 26 | prompt string // the user prompt that should be retried on Select | |
| 27 | + | details bool | |
| 28 | + | scroll int | |
| 27 | 29 | } | |
| 28 | 30 | ||
| 29 | 31 | type rescueDialogAction struct { | |
| @@ -43,6 +45,8 @@ func (d *rescueDialog) Open(current string, loggedInProviders []string, failedPr | |||
| 43 | 45 | d.active = true | |
| 44 | 46 | d.current = current | |
| 45 | 47 | d.query = "" | |
| 48 | + | d.details = false | |
| 49 | + | d.scroll = 0 | |
| 46 | 50 | d.reason = reason | |
| 47 | 51 | d.prompt = prompt | |
| 48 | 52 | d.failedAt = strings.TrimSpace(failedProvider + "/" + failedModel) | |
| @@ -95,7 +99,7 @@ func (d *rescueDialog) refilter() { | |||
| 95 | 99 | ||
| 96 | 100 | // Render mirrors modelDialog so a rescue prompt feels identical to | |
| 97 | 101 | // 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 { | |
| 99 | 103 | if !d.Active() { | |
| 100 | 104 | return nil | |
| 101 | 105 | } | |
| @@ -104,10 +108,34 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string { | |||
| 104 | 108 | if d.failedAt != "" && d.failedAt != "/" { | |
| 105 | 109 | header = "rescue turn — " + d.failedAt + " failed" | |
| 106 | 110 | } | |
| 111 | + | if d.details { | |
| 112 | + | header = "provider error" | |
| 113 | + | } | |
| 107 | 114 | lines = append(lines, frameHeader(th, header, width)) | |
| 108 | 115 | ||
| 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 | + | } | |
| 109 | 132 | 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 | + | } | |
| 111 | 139 | } | |
| 112 | 140 | ||
| 113 | 141 | 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 { | |||
| 176 | 204 | } | |
| 177 | 205 | ||
| 178 | 206 | 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 | + | } | |
| 179 | 229 | switch k.Kind { | |
| 180 | 230 | case tui.KeyUp: | |
| 181 | 231 | if d.cursor > 0 { | |
| @@ -220,7 +270,7 @@ func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction { | |||
| 220 | 270 | ||
| 221 | 271 | // classifyRescueError inspects an agent error and decides whether | |
| 222 | 272 | // 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 | |
| 224 | 274 | // (false, "") for errors we should NOT auto-rescue (bad request, | |
| 225 | 275 | // context length, transcript serialization issues, etc.) so the | |
| 226 | 276 | // regular red status banner still surfaces them. | |
| @@ -245,18 +295,18 @@ func classifyRescueError(err error) (bool, string) { | |||
| 245 | 295 | strings.Contains(low, "no such host") || | |
| 246 | 296 | strings.Contains(low, "tls handshake") || | |
| 247 | 297 | strings.Contains(low, "eof") { | |
| 248 | - | return true, "network failure: " + shortError(msg) | |
| 298 | + | return true, "network failure: " + msg | |
| 249 | 299 | } | |
| 250 | 300 | ||
| 251 | 301 | switch { | |
| 252 | 302 | 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 | |
| 254 | 304 | case containsAny(low, "http 403", " 403:", "permission denied", "forbidden"): | |
| 255 | - | return true, "permission denied: " + shortError(msg) | |
| 305 | + | return true, "permission denied: " + msg | |
| 256 | 306 | 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 | |
| 258 | 308 | 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 | |
| 260 | 310 | } | |
| 261 | 311 | ||
| 262 | 312 | // Anything else (400 bad request, validation errors, etc.) is | |
| @@ -273,17 +323,6 @@ func containsAny(haystack string, needles ...string) bool { | |||
| 273 | 323 | return false | |
| 274 | 324 | } | |
| 275 | 325 | ||
| 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 | - | ||
| 287 | 326 | // extractFailedProvider tries to pull the failing provider name out | |
| 288 | 327 | // of an error string ("kimi: http 401: ...", "openai: http 503: ..."). | |
| 289 | 328 | // 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 { | |||
| 286 | 286 | return err | |
| 287 | 287 | } | |
| 288 | 288 | ||
| 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 | + | ||
| 289 | 300 | // dispatch routes a command. Long-running commands (prompt, compact) | |
| 290 | 301 | // run on their own goroutine so the read loop stays responsive. | |
| 291 | 302 | func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | |
| @@ -341,6 +352,12 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | |||
| 341 | 352 | s.writeResponse(id, cmd, nil) | |
| 342 | 353 | ||
| 343 | 354 | 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 | + | ||
| 344 | 361 | var req struct { | |
| 345 | 362 | Model string `json:"model"` | |
| 346 | 363 | } | |
| @@ -348,13 +365,28 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | |||
| 348 | 365 | s.writeError(id, cmd, err.Error()) | |
| 349 | 366 | return | |
| 350 | 367 | } | |
| 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 { | |
| 352 | 375 | s.writeError(id, cmd, err.Error()) | |
| 353 | 376 | return | |
| 354 | 377 | } | |
| 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}) | |
| 358 | 390 | ||
| 359 | 391 | case "set_reasoning": | |
| 360 | 392 | var req struct { | |
M packages/agent/rpc_reasoning_test.go +104 -0
| @@ -6,8 +6,112 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/zut/packages/core" | |
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 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 | + | ||
| 11 | 115 | func TestRPCSetReasoningMax(t *testing.T) { | |
| 12 | 116 | var out bytes.Buffer | |
| 13 | 117 | 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 @@ | |||
| 1 | 1 | package sdk | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/zut/packages/agent/tools" | |
| 7 | 9 | "github.com/bnema/zut/packages/core" | |
| 10 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 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 | + | ||
| 10 | 128 | func TestRuntimeSetReasoningMax(t *testing.T) { | |
| 11 | 129 | r := &Runtime{agent: &core.Agent{}} | |
| 12 | 130 | if err := r.SetReasoning("max"); err != nil { | |
M packages/agent/sdk/sdk.go +72 -10
| @@ -27,6 +27,7 @@ import ( | |||
| 27 | 27 | "context" | |
| 28 | 28 | "errors" | |
| 29 | 29 | "fmt" | |
| 30 | + | "strings" | |
| 30 | 31 | "sync" | |
| 31 | 32 | ||
| 32 | 33 | "github.com/bnema/zut/packages/agent" | |
| @@ -40,8 +41,8 @@ import ( | |||
| 40 | 41 | // defaults are read from $ZUT_HOME/config.json, env vars, and the | |
| 41 | 42 | // resolver chain (the same one the cli uses). | |
| 42 | 43 | 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. | |
| 45 | 46 | Provider string | |
| 46 | 47 | ||
| 47 | 48 | // Model is the model id. Empty = use the provider's default. | |
| @@ -98,6 +99,9 @@ type Runtime struct { | |||
| 98 | 99 | provider string | |
| 99 | 100 | model string | |
| 100 | 101 | cwd string | |
| 102 | + | // modelCatalog is a private metadata snapshot for dynamic providers. | |
| 103 | + | modelCatalog []provider.Model | |
| 104 | + | modelCatalogAuthoritative bool | |
| 101 | 105 | ||
| 102 | 106 | // activeCancel is set while a Prompt or Compact is running. | |
| 103 | 107 | activeCancel context.CancelFunc | |
| @@ -110,6 +114,16 @@ type Runtime struct { | |||
| 110 | 114 | // New constructs a Runtime from cfg. Returns an error if no | |
| 111 | 115 | // credential is available for the requested provider. | |
| 112 | 116 | 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 | + | } | |
| 113 | 127 | webSearchPolicy := subagents.WebSearchDeny | |
| 114 | 128 | for _, name := range cfg.Tools { | |
| 115 | 129 | if name == "web_search" { | |
| @@ -134,7 +148,7 @@ func New(cfg Config) (*Runtime, error) { | |||
| 134 | 148 | WebSearchPolicy: webSearchPolicy, | |
| 135 | 149 | NoSess: true, // SDK callers manage persistence themselves | |
| 136 | 150 | } | |
| 137 | - | r, err := agent.Resolve(args, true) | |
| 151 | + | r, err := agent.ResolveSDK(ctx, args) | |
| 138 | 152 | if err != nil { | |
| 139 | 153 | return nil, err | |
| 140 | 154 | } | |
| @@ -143,10 +157,12 @@ func New(cfg Config) (*Runtime, error) { | |||
| 143 | 157 | } | |
| 144 | 158 | ag := r.NewAgent() | |
| 145 | 159 | 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(), | |
| 150 | 166 | }, nil | |
| 151 | 167 | } | |
| 152 | 168 | ||
| @@ -309,8 +325,48 @@ func (r *Runtime) SetModel(model string) error { | |||
| 309 | 325 | if r.agent == nil { | |
| 310 | 326 | return fmt.Errorf("sdk: no agent") | |
| 311 | 327 | } | |
| 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 | + | } | |
| 314 | 370 | } | |
| 315 | 371 | r.agent.Model = model | |
| 316 | 372 | r.model = model | |
| @@ -379,7 +435,13 @@ func (r *Runtime) Close() error { | |||
| 379 | 435 | // ListModels returns every model known to the runtime for the | |
| 380 | 436 | // current provider (catalog + live discovery if cached). | |
| 381 | 437 | 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 | + | } | |
| 383 | 445 | out := make([]ModelInfo, 0, len(models)) | |
| 384 | 446 | for _, m := range models { | |
| 385 | 447 | out = append(out, ModelInfo{ | |
M packages/agent/sdk/sdk_defaults_test.go +19 -1
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package sdk | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | func TestRuntimeLeavesAgentStepsUnlimitedByDefault(t *testing.T) { | |
| 6 | 10 | runtime, err := New(Config{ | |
| @@ -17,3 +21,17 @@ func TestRuntimeLeavesAgentStepsUnlimitedByDefault(t *testing.T) { | |||
| 17 | 21 | t.Fatalf("max steps = %d, want unlimited", runtime.agent.MaxSteps) | |
| 18 | 22 | } | |
| 19 | 23 | } | |
| 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 ( | |||
| 4 | 4 | "archive/tar" | |
| 5 | 5 | "bytes" | |
| 6 | 6 | "compress/gzip" | |
| 7 | + | "context" | |
| 7 | 8 | "crypto/sha256" | |
| 8 | 9 | "encoding/hex" | |
| 9 | 10 | "encoding/json" | |
| @@ -127,7 +128,6 @@ func runZutfileCommand(rawArgs []string, version string) (bool, error) { | |||
| 127 | 128 | } | |
| 128 | 129 | ||
| 129 | 130 | func runLocalZutfile(ref string, args Args, version string) error { | |
| 130 | - | prepareRuntimeCatalog() | |
| 131 | 131 | zf, cleanup, err := loadZutfile(ref) | |
| 132 | 132 | if cleanup != nil { | |
| 133 | 133 | defer cleanup() | |
| @@ -157,6 +157,9 @@ func runLocalZutfile(ref string, args Args, version string) error { | |||
| 157 | 157 | if !allowed { | |
| 158 | 158 | return nil | |
| 159 | 159 | } | |
| 160 | + | userModels := LoadUserModels() | |
| 161 | + | catalogProvider, catalogModel := zutfileCatalogSelection(args, zf.Manifest, userModels) | |
| 162 | + | PrepareRuntimeCatalog(context.Background(), true, catalogProvider, args.APIKey, args.BaseURL, userModels, catalogModel) | |
| 160 | 163 | if err := applyZutfileModelRequirements(&args, zf.Manifest); err != nil { | |
| 161 | 164 | return err | |
| 162 | 165 | } | |
| @@ -220,6 +223,56 @@ func zutInspect(ref string) error { | |||
| 220 | 223 | }) | |
| 221 | 224 | } | |
| 222 | 225 | ||
| 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 | + | ||
| 223 | 276 | func applyZutfileModelRequirements(args *Args, m ZutfileManifest) error { | |
| 224 | 277 | minCtx := m.Model.MinContext | |
| 225 | 278 | requires := map[string]bool{} | |
| @@ -286,9 +339,83 @@ func applyZutfileModelRequirements(args *Args, m ZutfileManifest) error { | |||
| 286 | 339 | return fmt.Errorf("no catalog model satisfies the agent requirements") | |
| 287 | 340 | } | |
| 288 | 341 | ||
| 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)) | |
| 292 | 419 | if cps := provider.CustomProviders(); len(cps) > 0 { | |
| 293 | 420 | var names []string | |
| 294 | 421 | for name := range cps { | |
| @@ -299,7 +426,7 @@ func prepareRuntimeCatalog() { | |||
| 299 | 426 | auth.SetExtraAPIKeyProviders(names) | |
| 300 | 427 | } | |
| 301 | 428 | ValidateAndRepairConfig() | |
| 302 | - | RefreshModelsAsync() | |
| 429 | + | return explicitProvider, explicitBaseURL | |
| 303 | 430 | } | |
| 304 | 431 | ||
| 305 | 432 | func zutPack(dir, out string) error { | |
M packages/provider/anthropic.go +52 -19
| @@ -10,12 +10,25 @@ import ( | |||
| 10 | 10 | "net/http" | |
| 11 | 11 | "os" | |
| 12 | 12 | "strings" | |
| 13 | + | "sync" | |
| 13 | 14 | "time" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | const anthropicDefaultBaseURL = "https://api.anthropic.com" | |
| 17 | 18 | const anthropicAPIVersion = "2023-06-01" | |
| 18 | 19 | ||
| 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 | + | ||
| 19 | 32 | // Stealth identity used when talking to Anthropic via subscription OAuth. | |
| 20 | 33 | // These values mimic the official Claude Code CLI so Anthropic's edge | |
| 21 | 34 | // accepts the request; diverging from them causes 429 rate_limit_error | |
| @@ -69,6 +82,9 @@ type anthropicClient struct { | |||
| 69 | 82 | ||
| 70 | 83 | // headers carries extra request headers (e.g. Kimi Code's X-Msh-*). | |
| 71 | 84 | headers map[string]string | |
| 85 | + | ||
| 86 | + | modelOverridesMu sync.RWMutex | |
| 87 | + | modelOverrides map[string]Model | |
| 72 | 88 | } | |
| 73 | 89 | ||
| 74 | 90 | // NewAnthropic creates an Anthropic client using an API key. baseURL may be empty. | |
| @@ -102,6 +118,34 @@ func (c *anthropicClient) Name() string { | |||
| 102 | 118 | return "anthropic" | |
| 103 | 119 | } | |
| 104 | 120 | ||
| 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 | + | ||
| 105 | 149 | // ---- wire types ---- | |
| 106 | 150 | ||
| 107 | 151 | type anthTextBlock struct { | |
| @@ -210,18 +254,8 @@ func usesAdaptiveThinking(m Model) bool { | |||
| 210 | 254 | // ---- request building ---- | |
| 211 | 255 | ||
| 212 | 256 | 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) | |
| 225 | 259 | if err != nil { | |
| 226 | 260 | return nil, err | |
| 227 | 261 | } | |
| @@ -552,8 +586,9 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event | |||
| 552 | 586 | } | |
| 553 | 587 | } | |
| 554 | 588 | ||
| 589 | + | endpoint := anthropicMessagesURL(c.baseURL) | |
| 555 | 590 | 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)) | |
| 557 | 592 | if err != nil { | |
| 558 | 593 | return nil, err | |
| 559 | 594 | } | |
| @@ -580,6 +615,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event | |||
| 580 | 615 | for k, v := range c.headers { | |
| 581 | 616 | httpReq.Header.Set(k, v) | |
| 582 | 617 | } | |
| 618 | + | setOpenCodeGoHeaders(httpReq.Header, c.Name(), req.Context) | |
| 583 | 619 | return httpReq, nil | |
| 584 | 620 | } | |
| 585 | 621 | ||
| @@ -602,12 +638,9 @@ func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, re | |||
| 602 | 638 | defer close(out) | |
| 603 | 639 | defer resp.Body.Close() | |
| 604 | 640 | ||
| 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) | |
| 611 | 644 | out <- EventStart{Model: req.Model, Provider: c.Name()} | |
| 612 | 645 | ||
| 613 | 646 | raw := make(chan sseEvent, 16) | |
M packages/provider/astra_test.go +1 -1
| @@ -30,7 +30,7 @@ func TestAstraReleasedMetadata(t *testing.T) { | |||
| 30 | 30 | usage Usage | |
| 31 | 31 | want float64 | |
| 32 | 32 | }{ | |
| 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}, | |
| 34 | 34 | {"above_threshold", Usage{InputTokens: 100000, CacheReadTokens: 100001, CacheWriteTokens: 72000, OutputTokens: 1000}, 4.075002}, | |
| 35 | 35 | } { | |
| 36 | 36 | t.Run(tc.name, func(t *testing.T) { | |
M packages/provider/cache.go +15 -4
| @@ -10,14 +10,22 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | // ModelCache is the on-disk shape for discovered models. | |
| 12 | 12 | type ModelCache struct { | |
| 13 | + | Version int `json:"version,omitempty"` | |
| 13 | 14 | FetchedAt time.Time `json:"fetched_at"` | |
| 14 | 15 | Models []Model `json:"models"` | |
| 15 | 16 | AuthoritativeProviders []string `json:"authoritative_providers,omitempty"` | |
| 16 | 17 | ProviderScopes map[string]string `json:"provider_scopes,omitempty"` | |
| 17 | 18 | } | |
| 18 | 19 | ||
| 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 | |
| 21 | 29 | ||
| 22 | 30 | // LoadCache reads the model cache from path. Returns an empty ModelCache | |
| 23 | 31 | // (no error) if the file does not exist. | |
| @@ -43,6 +51,9 @@ func LoadCache(path string) (ModelCache, error) { | |||
| 43 | 51 | ||
| 44 | 52 | // SaveCache writes the cache atomically. | |
| 45 | 53 | func SaveCache(path string, c ModelCache) error { | |
| 54 | + | if c.Version == 0 { | |
| 55 | + | c.Version = ModelCacheVersion | |
| 56 | + | } | |
| 46 | 57 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 47 | 58 | return err | |
| 48 | 59 | } | |
| @@ -57,9 +68,9 @@ func SaveCache(path string, c ModelCache) error { | |||
| 57 | 68 | return os.Rename(tmp, path) | |
| 58 | 69 | } | |
| 59 | 70 | ||
| 60 | - | // IsFresh reports whether the cache was fetched within CacheTTL. | |
| 71 | + | // IsFresh reports whether the current-format cache was fetched within CacheTTL. | |
| 61 | 72 | func (c ModelCache) IsFresh() bool { | |
| 62 | - | if c.FetchedAt.IsZero() { | |
| 73 | + | if c.Version != ModelCacheVersion || c.FetchedAt.IsZero() { | |
| 63 | 74 | return false | |
| 64 | 75 | } | |
| 65 | 76 | return time.Since(c.FetchedAt) < CacheTTL | |
M packages/provider/cache_test.go +20 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "runtime" | |
| 7 | 7 | "testing" | |
| 8 | + | "time" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | func TestSaveCacheUsesRestrictivePermissions(t *testing.T) { | |
| @@ -22,4 +23,23 @@ func TestSaveCacheUsesRestrictivePermissions(t *testing.T) { | |||
| 22 | 23 | if got := info.Mode().Perm(); got != 0o600 { | |
| 23 | 24 | t.Fatalf("cache permissions = %04o, want 0600", got) | |
| 24 | 25 | } | |
| 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 | + | } | |
| 25 | 45 | } | |
M packages/provider/catalog_builtin.go +4 -22
| @@ -6,7 +6,9 @@ package provider | |||
| 6 | 6 | // | |
| 7 | 7 | // This file is data; the values are tracked verbatim from each | |
| 8 | 8 | // 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. | |
| 10 | 12 | // | |
| 11 | 13 | // Curated entries in models.go (anthropic / openai / openai-codex / | |
| 12 | 14 | // the small seed list for kimi / deepseek / google) are not duplicated | |
| @@ -302,28 +304,8 @@ var builtinCatalog = []Model{ | |||
| 302 | 304 | {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"}, | |
| 303 | 305 | {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"}, | |
| 304 | 306 | {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"}, | |
| 326 | 307 | // openrouter: discovered live via DiscoverOpenRouter, none baked in. | |
| 308 | + | // opencode-go: discovered live from models.dev and the provider API. | |
| 327 | 309 | // ----- together ----- | |
| 328 | 310 | {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"}, | |
| 329 | 311 | {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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | 8 | "net/http" | |
| 9 | + | "sort" | |
| 9 | 10 | "strconv" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "time" | |
| @@ -113,6 +114,306 @@ func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error | |||
| 113 | 114 | return out, nil | |
| 114 | 115 | } | |
| 115 | 116 | ||
| 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 | + | ||
| 116 | 417 | // DiscoverGoogle lists Gemini model ids visible to key on | |
| 117 | 418 | // generativelanguage.googleapis.com. The API paginates with | |
| 118 | 419 | // nextPageToken; we follow it until exhausted. | |
M packages/provider/extra_providers.go +26 -7
| @@ -124,14 +124,33 @@ func NewOpenCode(apiKey, baseURL string) Client { | |||
| 124 | 124 | return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1") | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 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. | |
| 128 | 131 | 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 | |
| 135 | 154 | } | |
| 136 | 155 | ||
| 137 | 156 | // 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 { | |||
| 366 | 366 | } | |
| 367 | 367 | ||
| 368 | 368 | func (r *renamedClient) Name() string { return r.name } | |
| 369 | + | func (r *renamedClient) SetModelMetadata(model Model) { | |
| 370 | + | setModelMetadata(r.inner, model) | |
| 371 | + | } | |
| 369 | 372 | func (r *renamedClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 370 | 373 | if err := ValidateFastMode(r.Name(), req.FastMode); err != nil { | |
| 371 | 374 | return nil, err | |
M packages/provider/labels.go +1 -1
| @@ -59,7 +59,7 @@ func ProviderLabel(id string) string { | |||
| 59 | 59 | return "Vercel AI Gateway" | |
| 60 | 60 | case "opencode": | |
| 61 | 61 | return "OpenCode" | |
| 62 | - | case "opencode-go": | |
| 62 | + | case ProviderOpenCodeGo: | |
| 63 | 63 | return "OpenCode Go" | |
| 64 | 64 | case "amazon-bedrock": | |
| 65 | 65 | return "Amazon Bedrock" | |
M packages/provider/llamacpp.go +3 -1
| @@ -19,7 +19,9 @@ import ( | |||
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | 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 | |
| 23 | 25 | defaultHuggingFaceURL = "https://huggingface.co" | |
| 24 | 26 | llamaCPPRequestTimeout = 15 * time.Second | |
| 25 | 27 | llamaCPPProgressPollTime = 250 * time.Millisecond | |
M packages/provider/merge_test.go +14 -10
| @@ -64,19 +64,23 @@ func TestSetLiveModelsForProvidersPrunesEmptyAuthoritativeCatalog(t *testing.T) | |||
| 64 | 64 | if _, err := FindModel("openai-codex", "gpt-5.6-luna"); err != nil { | |
| 65 | 65 | t.Fatalf("fixture drift: static Codex model missing: %v", err) | |
| 66 | 66 | } | |
| 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) | |
| 77 | 68 | ||
| 78 | 69 | SetLiveModelsForProviders(nil, []string{"openai-codex"}) | |
| 79 | 70 | if _, err := FindModel("openai-codex", "gpt-5.6-luna"); err == nil { | |
| 80 | 71 | t.Fatal("empty authoritative catalog retained a static Codex model") | |
| 81 | 72 | } | |
| 82 | 73 | } | |
| 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | const ( | |
| @@ -10,34 +12,116 @@ const ( | |||
| 10 | 12 | APICompletions = "openai-completions" | |
| 11 | 13 | // APIResponses identifies the OpenAI Responses wire API. | |
| 12 | 14 | APIResponses = "openai-responses" | |
| 15 | + | // APIAnthropicMessages identifies the Anthropic Messages wire API. | |
| 16 | + | APIAnthropicMessages = "anthropic-messages" | |
| 13 | 17 | ) | |
| 14 | 18 | ||
| 15 | 19 | // modelRouter dispatches each request according to the selected model's API. | |
| 16 | 20 | // This lets one provider expose models that use different wire protocols while | |
| 17 | 21 | // remaining reusable across model switches. | |
| 18 | 22 | 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) | |
| 22 | 36 | } | |
| 23 | 37 | ||
| 24 | 38 | // 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. | |
| 26 | 41 | func NewModelRouter(name string, fallback Client, byAPI map[string]Client) Client { | |
| 27 | 42 | return &modelRouter{name: name, fallback: fallback, byAPI: byAPI} | |
| 28 | 43 | } | |
| 29 | 44 | ||
| 30 | 45 | func (c *modelRouter) Name() string { return c.name } | |
| 31 | 46 | ||
| 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 | + | ||
| 32 | 78 | func (c *modelRouter) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 79 | + | req.Model = strings.TrimSpace(req.Model) | |
| 33 | 80 | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |
| 34 | 81 | return nil, err | |
| 35 | 82 | } | |
| 36 | 83 | 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 | + | } | |
| 39 | 123 | 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) | |
| 41 | 125 | } | |
| 42 | 126 | client = routed | |
| 43 | 127 | } | |
M packages/provider/model_router_test.go +183 -2
| @@ -2,6 +2,8 @@ package provider | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| @@ -18,6 +20,15 @@ func (c *routeCaptureClient) Stream(_ context.Context, req Request) (<-chan Even | |||
| 18 | 20 | return out, nil | |
| 19 | 21 | } | |
| 20 | 22 | ||
| 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 | + | ||
| 21 | 32 | func TestModelRouterDispatchesByModelAPI(t *testing.T) { | |
| 22 | 33 | completions := &routeCaptureClient{name: "xai"} | |
| 23 | 34 | responses := &routeCaptureClient{name: "xai"} | |
| @@ -43,6 +54,8 @@ func TestModelRouterDispatchesByModelAPI(t *testing.T) { | |||
| 43 | 54 | } | |
| 44 | 55 | ||
| 45 | 56 | func TestModelRouterRejectsMissingAPIClient(t *testing.T) { | |
| 57 | + | preserveActiveCatalog(t) | |
| 58 | + | SetLiveModels([]Model{{Provider: "xai", ID: "grok-4.5", API: APIResponses}}) | |
| 46 | 59 | router := NewModelRouter("xai", &routeCaptureClient{name: "xai"}, nil) | |
| 47 | 60 | if _, err := router.Stream(context.Background(), Request{Model: "grok-4.5"}); err == nil { | |
| 48 | 61 | t.Fatal("expected missing API client error") | |
| @@ -50,6 +63,13 @@ func TestModelRouterRejectsMissingAPIClient(t *testing.T) { | |||
| 50 | 63 | } | |
| 51 | 64 | ||
| 52 | 65 | 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 | + | ||
| 53 | 73 | router := NewOpenCodeGo("token", "https://example.com/go/v1").(*modelRouter) | |
| 54 | 74 | if got := router.fallback.(*openaiClient).baseURL; got != "https://example.com/go/v1" { | |
| 55 | 75 | t.Fatalf("Completions base URL = %q", got) | |
| @@ -63,7 +83,7 @@ func TestOpenCodeGoRoutesLunaToResponses(t *testing.T) { | |||
| 63 | 83 | responsesCapture := &routeCaptureClient{name: "opencode-go"} | |
| 64 | 84 | router.fallback = completionsCapture | |
| 65 | 85 | 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"} { | |
| 67 | 87 | stream, err := router.Stream(context.Background(), Request{Model: model}) | |
| 68 | 88 | if err != nil { | |
| 69 | 89 | t.Fatal(err) | |
| @@ -75,7 +95,168 @@ func TestOpenCodeGoRoutesLunaToResponses(t *testing.T) { | |||
| 75 | 95 | if len(responsesCapture.models) != 1 || responsesCapture.models[0] != "gpt-5.6-luna" { | |
| 76 | 96 | t.Fatalf("Responses models = %v", responsesCapture.models) | |
| 77 | 97 | } | |
| 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" { | |
| 79 | 99 | t.Fatalf("Completions models = %v", completionsCapture.models) | |
| 80 | 100 | } | |
| 81 | 101 | } | |
| 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 ( | |||
| 9 | 9 | // Deliberate working budget, not the models' advertised maximum context. | |
| 10 | 10 | const openAIContextWindowTarget = 500_000 | |
| 11 | 11 | ||
| 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 | + | ||
| 12 | 23 | // Model describes a single LLM we know about. | |
| 13 | 24 | type Model struct { | |
| 14 | 25 | Provider string // "anthropic" | "openai" | |
| @@ -19,6 +30,10 @@ type Model struct { | |||
| 19 | 30 | MaxOutput int | |
| 20 | 31 | Reasoning bool // supports reasoning | |
| 21 | 32 | 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 | |
| 22 | 37 | ||
| 23 | 38 | // AdaptiveThinking marks Anthropic models that only support the | |
| 24 | 39 | // adaptive thinking mode (Opus 4.7+). These reject explicit | |
| @@ -34,11 +49,15 @@ type Model struct { | |||
| 34 | 49 | AdaptiveThinkingCompat bool | |
| 35 | 50 | ||
| 36 | 51 | // 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 | |
| 42 | 61 | PriceTierInputTokens int | |
| 43 | 62 | PriceInputAbove float64 | |
| 44 | 63 | PriceOutputAbove float64 | |
| @@ -64,9 +83,10 @@ type Model struct { | |||
| 64 | 83 | ||
| 65 | 84 | // Catalog is the hardcoded, read-only list of supported models. | |
| 66 | 85 | // 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. | |
| 70 | 90 | var Catalog = []Model{ | |
| 71 | 91 | // ---- Anthropic / Claude 4.x ---- | |
| 72 | 92 | { | |
| @@ -424,12 +444,157 @@ var DefaultModel = Catalog[0] // claude-sonnet-4-5 | |||
| 424 | 444 | // models loaded via SetLiveModels. | |
| 425 | 445 | ||
| 426 | 446 | 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 | |
| 431 | 453 | ) | |
| 432 | 454 | ||
| 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 | + | ||
| 433 | 598 | // SetLiveModels replaces the "live" overlay used by the active catalog. | |
| 434 | 599 | // Typically called after a successful /v1/models discovery or on load | |
| 435 | 600 | // from the on-disk cache. | |
| @@ -444,6 +609,10 @@ func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) { | |||
| 444 | 609 | activeMu.Lock() | |
| 445 | 610 | defer activeMu.Unlock() | |
| 446 | 611 | activeSet = true | |
| 612 | + | authoritativeProviderSet = make(map[string]struct{}, len(authoritativeProviders)) | |
| 613 | + | for _, name := range authoritativeProviders { | |
| 614 | + | authoritativeProviderSet[name] = struct{}{} | |
| 615 | + | } | |
| 447 | 616 | if len(live) == 0 && len(authoritativeProviders) == 0 { | |
| 448 | 617 | active = nil | |
| 449 | 618 | return | |
| @@ -451,6 +620,25 @@ func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) { | |||
| 451 | 620 | active = MergeCatalogForProviders(live, authoritativeProviders) | |
| 452 | 621 | } | |
| 453 | 622 | ||
| 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 | + | ||
| 454 | 642 | // Active returns the current merged catalog. | |
| 455 | 643 | // | |
| 456 | 644 | // When no live overlay has been set it returns the fully-assembled | |
| @@ -468,29 +656,27 @@ func Active() []Model { | |||
| 468 | 656 | if !activeSet || src == nil { | |
| 469 | 657 | src = Catalog | |
| 470 | 658 | } | |
| 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) | |
| 489 | 674 | } | |
| 490 | - | index[key] = len(out) | |
| 491 | - | out = append(out, model) | |
| 492 | 675 | } | |
| 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) | |
| 494 | 680 | } | |
| 495 | 681 | ||
| 496 | 682 | // SetManagedModels replaces the ephemeral catalog entries supplied by local | |
| @@ -514,6 +700,22 @@ func FindModel(provider, id string) (Model, error) { | |||
| 514 | 700 | return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider) | |
| 515 | 701 | } | |
| 516 | 702 | ||
| 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 | + | ||
| 517 | 719 | // ModelsForProvider returns all models for the given provider, from the | |
| 518 | 720 | // merged active catalog. | |
| 519 | 721 | func ModelsForProvider(provider string) []Model { | |
| @@ -533,7 +735,22 @@ func ComputeCost(m Model, u Usage) float64 { | |||
| 533 | 735 | cacheReadPrice := m.PriceCacheRead | |
| 534 | 736 | cacheWritePrice := m.PriceCacheWrite | |
| 535 | 737 | 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. | |
| 537 | 754 | inputPrice = m.PriceInputAbove | |
| 538 | 755 | outputPrice = m.PriceOutputAbove | |
| 539 | 756 | cacheReadPrice = m.PriceCacheReadAbove | |
M packages/provider/openai.go +39 -10
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "net/http" | |
| 11 | 11 | "regexp" | |
| 12 | 12 | "strings" | |
| 13 | + | "sync" | |
| 13 | 14 | "time" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| @@ -42,6 +43,8 @@ type openaiClient struct { | |||
| 42 | 43 | oauth bool // when true, apiKey actually holds an OAuth access token | |
| 43 | 44 | headers map[string]string | |
| 44 | 45 | http *http.Client | |
| 46 | + | modelOverridesMu sync.RWMutex | |
| 47 | + | modelOverrides map[string]Model | |
| 45 | 48 | } | |
| 46 | 49 | ||
| 47 | 50 | // NewOpenAI creates an OpenAI client using an API key. baseURL may be empty. | |
| @@ -113,6 +116,16 @@ func (c *openaiClient) Name() string { | |||
| 113 | 116 | return "openai" | |
| 114 | 117 | } | |
| 115 | 118 | ||
| 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 | + | ||
| 116 | 129 | // ---- wire types ---- | |
| 117 | 130 | ||
| 118 | 131 | type oaiContentText struct { | |
| @@ -181,16 +194,32 @@ type oaiRequest struct { | |||
| 181 | 194 | ||
| 182 | 195 | // ---- request building ---- | |
| 183 | 196 | ||
| 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 | + | ||
| 184 | 215 | func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |
| 216 | + | req.Model = strings.TrimSpace(req.Model) | |
| 185 | 217 | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |
| 186 | 218 | return nil, err | |
| 187 | 219 | } | |
| 188 | 220 | // The OpenAI wire client is shared by many providers, so prefer its own | |
| 189 | 221 | // 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) | |
| 194 | 223 | if err != nil { | |
| 195 | 224 | // Unknown model: use sensible defaults so local/custom | |
| 196 | 225 | // models still work without a catalog entry. | |
| @@ -264,11 +293,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 264 | 293 | // don't clamp zut's "maximum" to "high" for those models. | |
| 265 | 294 | effort = OpenAICompatAnthropicEffort(reasoning) | |
| 266 | 295 | } | |
| 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) | |
| 272 | 299 | if effort != "" { | |
| 273 | 300 | out.ReasoningEffort = effort | |
| 274 | 301 | } | |
| @@ -496,6 +523,7 @@ func (c *openaiClient) chatCompletionsURL() string { | |||
| 496 | 523 | } | |
| 497 | 524 | ||
| 498 | 525 | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 526 | + | req.Model = strings.TrimSpace(req.Model) | |
| 499 | 527 | endpoint := c.chatCompletionsURL() | |
| 500 | 528 | wire, err := c.buildRequest(req) | |
| 501 | 529 | if err != nil { | |
| @@ -516,6 +544,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||
| 516 | 544 | for k, v := range c.headers { | |
| 517 | 545 | httpReq.Header.Set(k, v) | |
| 518 | 546 | } | |
| 547 | + | setOpenCodeGoHeaders(httpReq.Header, c.Name(), req.Context) | |
| 519 | 548 | return httpReq, nil | |
| 520 | 549 | } | |
| 521 | 550 | ||
| @@ -538,7 +567,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 538 | 567 | defer close(out) | |
| 539 | 568 | defer resp.Body.Close() | |
| 540 | 569 | ||
| 541 | - | model, _ := FindModel("", req.Model) | |
| 570 | + | model, _ := c.modelForRequest(req.Model) | |
| 542 | 571 | out <- EventStart{Model: req.Model, Provider: c.Name()} | |
| 543 | 572 | ||
| 544 | 573 | raw := make(chan sseEvent, 16) | |
M packages/provider/openai_clamp_test.go +1 -12
| @@ -8,19 +8,8 @@ import "testing" | |||
| 8 | 8 | // the real catalog. | |
| 9 | 9 | func withLiveModels(t *testing.T, models []Model) { | |
| 10 | 10 | t.Helper() | |
| 11 | - | activeMu.Lock() | |
| 12 | - | prevActive := active | |
| 13 | - | prevSet := activeSet | |
| 14 | - | activeMu.Unlock() | |
| 15 | - | ||
| 11 | + | preserveActiveCatalog(t) | |
| 16 | 12 | SetLiveModels(models) | |
| 17 | - | ||
| 18 | - | t.Cleanup(func() { | |
| 19 | - | activeMu.Lock() | |
| 20 | - | active = prevActive | |
| 21 | - | activeSet = prevSet | |
| 22 | - | activeMu.Unlock() | |
| 23 | - | }) | |
| 24 | 13 | } | |
| 25 | 14 | ||
| 26 | 15 | // outputBudget pulls whichever max-output field buildRequest populated | |
M packages/provider/openai_codex.go +50 -9
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "net/http" | |
| 11 | 11 | "runtime" | |
| 12 | 12 | "strings" | |
| 13 | + | "sync" | |
| 13 | 14 | "time" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| @@ -46,6 +47,8 @@ type codexClient struct { | |||
| 46 | 47 | cliRoutingAll bool | |
| 47 | 48 | capabilities responsesCapabilities | |
| 48 | 49 | http *http.Client | |
| 50 | + | modelOverridesMu sync.RWMutex | |
| 51 | + | modelOverrides map[string]Model | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 51 | 54 | // responsesCapabilities records extensions verified for the exact provider | |
| @@ -93,7 +96,17 @@ func newOpenAICodexClient(token, accountID, baseURL string) *codexClient { | |||
| 93 | 96 | } | |
| 94 | 97 | } | |
| 95 | 98 | ||
| 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 | + | } | |
| 97 | 110 | ||
| 98 | 111 | // ---- Responses API wire types (subset needed for zut's surface) ---- | |
| 99 | 112 | ||
| @@ -203,15 +216,41 @@ type codexRequest struct { | |||
| 203 | 216 | // ---- Request building ---- | |
| 204 | 217 | ||
| 205 | 218 | 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 { | |
| 207 | 230 | if m, err := FindModel(c.providerName, id); err == nil { | |
| 208 | 231 | return m, nil | |
| 209 | 232 | } | |
| 210 | 233 | } | |
| 211 | - | if m, err := FindModel("openai-codex", id); err == nil { | |
| 234 | + | if m, err := FindModel(ProviderOpenAICodex, id); err == nil { | |
| 212 | 235 | return m, nil | |
| 213 | 236 | } | |
| 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 | |
| 215 | 254 | } | |
| 216 | 255 | ||
| 217 | 256 | func supportsOpenAIExplicitPromptCache(model string) bool { | |
| @@ -220,9 +259,10 @@ func supportsOpenAIExplicitPromptCache(model string) bool { | |||
| 220 | 259 | } | |
| 221 | 260 | ||
| 222 | 261 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |
| 262 | + | req.Model = strings.TrimSpace(req.Model) | |
| 223 | 263 | providerName := c.providerName | |
| 224 | 264 | if providerName == "" { | |
| 225 | - | providerName = "openai-codex" | |
| 265 | + | providerName = ProviderOpenAICodex | |
| 226 | 266 | } | |
| 227 | 267 | if err := ValidateFastMode(providerName, req.FastMode); err != nil { | |
| 228 | 268 | return nil, err | |
| @@ -259,9 +299,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 259 | 299 | } | |
| 260 | 300 | if m.Reasoning { | |
| 261 | 301 | effort := OpenAICodexReasoningEffort(reasoning, req.Model) | |
| 262 | - | if hasReasoningLevelOverride(m, req.Reasoning) { | |
| 263 | - | effort = reasoning | |
| 264 | - | } | |
| 302 | + | effort = reasoningEffortForModel(m, req.Reasoning, reasoning, effort) | |
| 265 | 303 | if effort != "" { | |
| 266 | 304 | body.Reasoning = &codexReasoningConfig{Effort: effort} | |
| 267 | 305 | } | |
| @@ -443,6 +481,7 @@ func usesCodexCLIRouting(model string) bool { | |||
| 443 | 481 | // ---- Streaming ---- | |
| 444 | 482 | ||
| 445 | 483 | func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 484 | + | req.Model = strings.TrimSpace(req.Model) | |
| 446 | 485 | wire, err := c.buildRequest(req) | |
| 447 | 486 | if err != nil { | |
| 448 | 487 | return nil, err | |
| @@ -514,6 +553,7 @@ func (c *codexClient) responsesHeaders(req Request, useCodexCLIRouting, websocke | |||
| 514 | 553 | headers.Set("x-client-request-id", threadID) | |
| 515 | 554 | } | |
| 516 | 555 | headers.Set("user-agent", "codex_cli_rs/0.0.0") | |
| 556 | + | setOpenCodeGoHeaders(headers, c.providerName, req.Context) | |
| 517 | 557 | return headers | |
| 518 | 558 | } | |
| 519 | 559 | ||
| @@ -523,6 +563,7 @@ func (c *codexClient) responsesHeaders(req Request, useCodexCLIRouting, websocke | |||
| 523 | 563 | headers.Set("originator", "zot") | |
| 524 | 564 | headers.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH)) | |
| 525 | 565 | } | |
| 566 | + | setOpenCodeGoHeaders(headers, c.providerName, req.Context) | |
| 526 | 567 | return headers | |
| 527 | 568 | } | |
| 528 | 569 | ||
| @@ -551,7 +592,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||
| 551 | 592 | model, _ := c.findModel(req.Model) | |
| 552 | 593 | providerName := c.providerName | |
| 553 | 594 | if providerName == "" { | |
| 554 | - | providerName = "openai-codex" | |
| 595 | + | providerName = ProviderOpenAICodex | |
| 555 | 596 | } | |
| 556 | 597 | out <- EventStart{Model: req.Model, Provider: providerName} | |
| 557 | 598 | ||
M packages/provider/openai_codex_test.go +4 -1
| @@ -137,10 +137,13 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) { | |||
| 137 | 137 | ||
| 138 | 138 | func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) { | |
| 139 | 139 | 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"}) | |
| 141 | 141 | if err != nil { | |
| 142 | 142 | t.Fatal(err) | |
| 143 | 143 | } | |
| 144 | + | if wire.Model != "gpt-5.6-sol" { | |
| 145 | + | t.Fatalf("model = %q, want trimmed id", wire.Model) | |
| 146 | + | } | |
| 144 | 147 | if wire.Reasoning == nil || wire.Reasoning.Effort != "max" { | |
| 145 | 148 | t.Fatalf("reasoning = %+v", wire.Reasoning) | |
| 146 | 149 | } | |
M packages/provider/openai_responses.go +5 -2
| @@ -5,7 +5,8 @@ package provider | |||
| 5 | 5 | // Strategy: reuse codexClient, which already speaks the Responses wire | |
| 6 | 6 | // format, via a header-rewriting RoundTripper. The public Responses API on | |
| 7 | 7 | // 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. | |
| 9 | 10 | // | |
| 10 | 11 | // This is a separate provider from `openai` (which is Chat Completions); | |
| 11 | 12 | // 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, | |||
| 49 | 50 | clone.Header.Del("chatgpt-account-id") | |
| 50 | 51 | clone.Header.Del("openai-beta") | |
| 51 | 52 | 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 | + | } | |
| 53 | 56 | // Keep Authorization: Bearer <key> as set by the codex client. | |
| 54 | 57 | return t.inner.RoundTrip(clone) | |
| 55 | 58 | } | |
M packages/provider/openai_responses_websocket.go +4 -0
| @@ -56,6 +56,9 @@ func newResponsesWebSocketClient(httpClient *codexClient) Client { | |||
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | func (c *responsesWebSocketClient) Name() string { return c.http.Name() } | |
| 59 | + | func (c *responsesWebSocketClient) SetModelMetadata(model Model) { | |
| 60 | + | c.http.SetModelMetadata(model) | |
| 61 | + | } | |
| 59 | 62 | ||
| 60 | 63 | // Close releases all persistent sockets held by this client. Callers that | |
| 61 | 64 | // create a client for a bounded runtime (such as a resident child) must call | |
| @@ -186,6 +189,7 @@ func responsesWebSocketURL(endpoint string) (string, error) { | |||
| 186 | 189 | } | |
| 187 | 190 | ||
| 188 | 191 | func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 192 | + | req.Model = strings.TrimSpace(req.Model) | |
| 189 | 193 | // WebSocket continuation state is isolated per conversation thread. Cache | |
| 190 | 194 | // affinity may be shared by root and resident children, but their server | |
| 191 | 195 | // 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) { | |||
| 205 | 205 | PriceTierInputTokens: 100, | |
| 206 | 206 | PriceInputAbove: 3, PriceOutputAbove: 4, PriceCacheReadAbove: 0.2, | |
| 207 | 207 | } | |
| 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 | |
| 210 | 210 | if math.Abs(below-wantBelow) > 1e-12 { | |
| 211 | 211 | t.Fatalf("below-tier cost=%v want=%v", below, wantBelow) | |
| 212 | 212 | } | |
| 213 | 213 | ||
| 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 | + | }) | |
| 218 | 247 | } | |
| 219 | 248 | } | |
| 220 | 249 | ||
| @@ -687,49 +716,6 @@ func TestGPT56AndGPT6CatalogEntries(t *testing.T) { | |||
| 687 | 716 | } | |
| 688 | 717 | } | |
| 689 | 718 | ||
| 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 | - | ||
| 733 | 719 | // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for | |
| 734 | 720 | // discovered models that don't advertise an output cap (MaxOutput == 0). | |
| 735 | 721 | 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 { | |||
| 92 | 92 | return ok | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 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 | + | ||
| 95 | 108 | // ClampReasoningForModel maps a configured level to the nearest level exposed | |
| 96 | 109 | // for the active model. Ties prefer the higher level. | |
| 97 | 110 | func ClampReasoningForModel(model Model, level string) string { | |
| @@ -146,7 +159,7 @@ func usesReasoningBudget(model Model) bool { | |||
| 146 | 159 | case "anthropic", "fireworks", "kimi", "minimax", "minimax-cn", "vercel-ai-gateway": | |
| 147 | 160 | return true | |
| 148 | 161 | } | |
| 149 | - | return model.API == "anthropic" | |
| 162 | + | return model.API == APIAnthropicMessages || model.API == "anthropic" // "anthropic" is the legacy user-model API value | |
| 150 | 163 | } | |
| 151 | 164 | ||
| 152 | 165 | // 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) { | |||
| 22 | 22 | {name: "gemini 2.5 budget", model: Model{Provider: "google", ID: "gemini-2.5-flash", Reasoning: true}, want: []string{"", "minimum", "low", "medium", "high", "xhigh"}}, | |
| 23 | 23 | {name: "gemini alias without control", model: Model{Provider: "google", ID: "gemini-flash-latest", Reasoning: true}, want: []string{""}}, | |
| 24 | 24 | {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"}}, | |
| 25 | 26 | {name: "bedrock without effort control", model: Model{Provider: "amazon-bedrock", Reasoning: true}, want: []string{""}}, | |
| 26 | 27 | { | |
| 27 | 28 | name: "per-model overrides", | |
| @@ -112,6 +113,60 @@ func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) { | |||
| 112 | 113 | } | |
| 113 | 114 | } | |
| 114 | 115 | ||
| 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 | + | ||
| 115 | 170 | func TestReasoningEffortMappings(t *testing.T) { | |
| 116 | 171 | cases := []struct { | |
| 117 | 172 | level string | |
M packages/provider/usermodels.go +18 -72
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "maps" | |
| 7 | 7 | "os" | |
| 8 | 8 | "strings" | |
| 9 | + | "sync" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | // UserModelsFile is the JSON format for user-defined models. | |
| @@ -48,12 +49,18 @@ type CustomProviderConfig struct { | |||
| 48 | 49 | API string // "openai", "openai-responses", or "anthropic" | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 51 | - | var customProviders = map[string]CustomProviderConfig{} | |
| 52 | + | var ( | |
| 53 | + | customProvidersMu sync.RWMutex | |
| 54 | + | customProviders = map[string]CustomProviderConfig{} | |
| 55 | + | ) | |
| 52 | 56 | ||
| 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 | + | } | |
| 57 | 64 | ||
| 58 | 65 | // UserModel is a single model entry in the user's models.json. | |
| 59 | 66 | type UserModel struct { | |
| @@ -127,6 +134,8 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 127 | 134 | } | |
| 128 | 135 | ||
| 129 | 136 | var out []Model | |
| 137 | + | customProvidersMu.Lock() | |
| 138 | + | defer customProvidersMu.Unlock() | |
| 130 | 139 | // Reset custom providers on each load so removed entries don't linger. | |
| 131 | 140 | customProviders = map[string]CustomProviderConfig{} | |
| 132 | 141 | for providerName, prov := range file.Providers { | |
| @@ -236,74 +245,11 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 236 | 245 | return out, warnings | |
| 237 | 246 | } | |
| 238 | 247 | ||
| 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. | |
| 242 | 251 | func SetUserModels(models []Model) { | |
| 243 | - | if len(models) == 0 { | |
| 244 | - | return | |
| 245 | - | } | |
| 246 | 252 | activeMu.Lock() | |
| 247 | 253 | 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) | |
| 309 | 255 | } | |
M packages/provider/usermodels_test.go +87 -1
| @@ -9,7 +9,11 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | func TestSetUserModelsPreservesCatalogWithoutLiveOverlay(t *testing.T) { | |
| 11 | 11 | SetLiveModels(nil) | |
| 12 | - | t.Cleanup(func() { SetLiveModels(nil) }) | |
| 12 | + | SetUserModels(nil) | |
| 13 | + | t.Cleanup(func() { | |
| 14 | + | SetLiveModels(nil) | |
| 15 | + | SetUserModels(nil) | |
| 16 | + | }) | |
| 13 | 17 | ||
| 14 | 18 | SetUserModels([]Model{{ | |
| 15 | 19 | Provider: "custom-test", | |
| @@ -26,6 +30,51 @@ func TestSetUserModelsPreservesCatalogWithoutLiveOverlay(t *testing.T) { | |||
| 26 | 30 | } | |
| 27 | 31 | } | |
| 28 | 32 | ||
| 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 | + | ||
| 29 | 78 | func TestLoadUserModelsRegistersModelLevelBaseURLCustomProvider(t *testing.T) { | |
| 30 | 79 | dir := t.TempDir() | |
| 31 | 80 | path := filepath.Join(dir, "models.json") | |
| @@ -168,3 +217,40 @@ func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) { | |||
| 168 | 217 | t.Fatalf("api = %q, want openai", cfg.API) | |
| 169 | 218 | } | |
| 170 | 219 | } | |
| 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 | + | } | |