Narrow gateway model preservation
- commit
- 15312347ac5cc7f004c4709bc4d1628340014871
- author
- patriceckhart · · 2026-07-05 08:36 UTC
- parent
- 8e8979a
- signature
- unsigned
4 files · +110 -46
M packages/agent/build.go +8 -32
| @@ -437,30 +437,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 437 | 437 | err = nil | |
| 438 | 438 | } | |
| 439 | 439 | } | |
| 440 | - | // Gateway/router providers (openrouter, vercel-ai-gateway, etc.) are | |
| 441 | - | // open-catalogue too: they can route any model id from any provider. | |
| 442 | - | // When the model isn't found under the gateway provider, try the | |
| 443 | - | // global catalog and keep the model id as-is. | |
| 444 | - | if err != nil && isGatewayProvider(provName) { | |
| 445 | - | if m, findErr := provider.FindModel("", model); findErr == nil { | |
| 446 | - | resolvedModel = provider.Model{ | |
| 447 | - | Provider: provName, | |
| 448 | - | ID: m.ID, | |
| 449 | - | DisplayName: m.DisplayName, | |
| 450 | - | ContextWindow: m.ContextWindow, | |
| 451 | - | MaxOutput: m.MaxOutput, | |
| 452 | - | Reasoning: m.Reasoning, | |
| 453 | - | AdaptiveThinking: m.AdaptiveThinking, | |
| 454 | - | PriceInput: m.PriceInput, | |
| 455 | - | PriceOutput: m.PriceOutput, | |
| 456 | - | PriceCacheRead: m.PriceCacheRead, | |
| 457 | - | PriceCacheWrite: m.PriceCacheWrite, | |
| 458 | - | BaseURL: "", // let the gateway client use its default | |
| 459 | - | Source: "catalog", | |
| 460 | - | } | |
| 461 | - | err = nil | |
| 462 | - | } | |
| 463 | - | } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" { | |
| 440 | + | if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" { | |
| 464 | 441 | // Fall back to the provider-level base URL when the model does | |
| 465 | 442 | // not define its own endpoint. | |
| 466 | 443 | resolvedModel.BaseURL = cfg.BaseURL | |
| @@ -476,23 +453,22 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 476 | 453 | // explicit --model flag), repair the config so the warning | |
| 477 | 454 | // doesn't repeat on every launch. | |
| 478 | 455 | ||
| 479 | - | // Gateway providers (openrouter, vercel-ai-gateway, etc.) are | |
| 480 | - | // open-catalogue: even model ids not in any catalog are valid | |
| 481 | - | // as long as the upstream gateway recognises them. Create a | |
| 482 | - | // synthetic model entry instead of overwriting the user's choice. | |
| 483 | - | if isGatewayProvider(provName) { | |
| 456 | + | // Gateway providers can accept route-qualified ids that are not in | |
| 457 | + | // zot's local catalog yet, for example OpenRouter's | |
| 458 | + | // "deepseek/deepseek-v4-flash". Preserve only route-qualified ids; | |
| 459 | + | // plain unknown values are likely typos and should still fall back. | |
| 460 | + | if isGatewayProvider(provName) && isGatewayRoutedModelID(model) { | |
| 484 | 461 | resolvedModel = provider.Model{ | |
| 485 | 462 | Provider: provName, | |
| 486 | 463 | ID: model, | |
| 487 | 464 | DisplayName: model, | |
| 488 | 465 | ContextWindow: 1000000, | |
| 489 | 466 | MaxOutput: 64000, | |
| 490 | - | Reasoning: true, | |
| 491 | 467 | BaseURL: "", | |
| 492 | - | Source: "user", | |
| 468 | + | Source: "gateway", | |
| 493 | 469 | } | |
| 494 | 470 | err = nil | |
| 495 | - | // Don't repair config — the model id is valid for this gateway. | |
| 471 | + | // Don't repair config — the routed model id may be valid upstream. | |
| 496 | 472 | } else { | |
| 497 | 473 | fallback := defaultModelForProvider(provName) | |
| 498 | 474 | fm, ferr := provider.FindModel(provName, fallback) | |
| @@ -437,30 +437,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 437 | err = nil | 437 | err = nil | ||
| 438 | } | 438 | } | ||
| 439 | } | 439 | } | ||
| 440 | - | // Gateway/router providers (openrouter, vercel-ai-gateway, etc.) are | 440 | + | if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" { |
| 441 | - | // open-catalogue too: they can route any model id from any provider. | |||
| 442 | - | // When the model isn't found under the gateway provider, try the | |||
| 443 | - | // global catalog and keep the model id as-is. | |||
| 444 | - | if err != nil && isGatewayProvider(provName) { | |||
| 445 | - | if m, findErr := provider.FindModel("", model); findErr == nil { | |||
| 446 | - | resolvedModel = provider.Model{ | |||
| 447 | - | Provider: provName, | |||
| 448 | - | ID: m.ID, | |||
| 449 | - | DisplayName: m.DisplayName, | |||
| 450 | - | ContextWindow: m.ContextWindow, | |||
| 451 | - | MaxOutput: m.MaxOutput, | |||
| 452 | - | Reasoning: m.Reasoning, | |||
| 453 | - | AdaptiveThinking: m.AdaptiveThinking, | |||
| 454 | - | PriceInput: m.PriceInput, | |||
| 455 | - | PriceOutput: m.PriceOutput, | |||
| 456 | - | PriceCacheRead: m.PriceCacheRead, | |||
| 457 | - | PriceCacheWrite: m.PriceCacheWrite, | |||
| 458 | - | BaseURL: "", // let the gateway client use its default | |||
| 459 | - | Source: "catalog", | |||
| 460 | - | } | |||
| 461 | - | err = nil | |||
| 462 | - | } | |||
| 463 | - | } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" { | |||
| 464 | // Fall back to the provider-level base URL when the model does | 441 | // Fall back to the provider-level base URL when the model does | ||
| 465 | // not define its own endpoint. | 442 | // not define its own endpoint. | ||
| 466 | resolvedModel.BaseURL = cfg.BaseURL | 443 | resolvedModel.BaseURL = cfg.BaseURL | ||
| @@ -476,23 +453,22 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 476 | // explicit --model flag), repair the config so the warning | 453 | // explicit --model flag), repair the config so the warning | ||
| 477 | // doesn't repeat on every launch. | 454 | // doesn't repeat on every launch. | ||
| 478 | 455 | ||||
| 479 | - | // Gateway providers (openrouter, vercel-ai-gateway, etc.) are | 456 | + | // Gateway providers can accept route-qualified ids that are not in |
| 480 | - | // open-catalogue: even model ids not in any catalog are valid | 457 | + | // zot's local catalog yet, for example OpenRouter's |
| 481 | - | // as long as the upstream gateway recognises them. Create a | 458 | + | // "deepseek/deepseek-v4-flash". Preserve only route-qualified ids; |
| 482 | - | // synthetic model entry instead of overwriting the user's choice. | 459 | + | // plain unknown values are likely typos and should still fall back. |
| 483 | - | if isGatewayProvider(provName) { | 460 | + | if isGatewayProvider(provName) && isGatewayRoutedModelID(model) { |
| 484 | resolvedModel = provider.Model{ | 461 | resolvedModel = provider.Model{ | ||
| 485 | Provider: provName, | 462 | Provider: provName, | ||
| 486 | ID: model, | 463 | ID: model, | ||
| 487 | DisplayName: model, | 464 | DisplayName: model, | ||
| 488 | ContextWindow: 1000000, | 465 | ContextWindow: 1000000, | ||
| 489 | MaxOutput: 64000, | 466 | MaxOutput: 64000, | ||
| 490 | - | Reasoning: true, | |||
| 491 | BaseURL: "", | 467 | BaseURL: "", | ||
| 492 | - | Source: "user", | 468 | + | Source: "gateway", |
| 493 | } | 469 | } | ||
| 494 | err = nil | 470 | err = nil | ||
| 495 | - | // Don't repair config — the model id is valid for this gateway. | 471 | + | // Don't repair config — the routed model id may be valid upstream. |
| 496 | } else { | 472 | } else { | ||
| 497 | fallback := defaultModelForProvider(provName) | 473 | fallback := defaultModelForProvider(provName) | ||
| 498 | fm, ferr := provider.FindModel(provName, fallback) | 474 | fm, ferr := provider.FindModel(provName, fallback) | ||
M packages/agent/build_test.go +43 -0
| @@ -112,6 +112,49 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) { | |||
| 112 | 112 | } | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | + | func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) { | |
| 116 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 117 | + | t.Setenv("OPENROUTER_API_KEY", "test-key") | |
| 118 | + | want := "deepseek/deepseek-v4-flash" | |
| 119 | + | if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil { | |
| 120 | + | t.Fatal(err) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | r, err := Resolve(Args{}, true) | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatalf("Resolve failed: %v", err) | |
| 126 | + | } | |
| 127 | + | if r.Provider != "openrouter" { | |
| 128 | + | t.Fatalf("provider = %q, want openrouter", r.Provider) | |
| 129 | + | } | |
| 130 | + | if r.Model != want { | |
| 131 | + | t.Fatalf("model = %q, want %q", r.Model, want) | |
| 132 | + | } | |
| 133 | + | if r.MaxOutput != 64000 { | |
| 134 | + | t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) { | |
| 139 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 140 | + | t.Setenv("OPENROUTER_API_KEY", "test-key") | |
| 141 | + | stale := "not-a-routed-model" | |
| 142 | + | if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil { | |
| 143 | + | t.Fatal(err) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | r, err := Resolve(Args{}, true) | |
| 147 | + | if err != nil { | |
| 148 | + | t.Fatalf("Resolve failed: %v", err) | |
| 149 | + | } | |
| 150 | + | if r.Provider != "openrouter" { | |
| 151 | + | t.Fatalf("provider = %q, want openrouter", r.Provider) | |
| 152 | + | } | |
| 153 | + | if r.Model == stale || r.Model == "" { | |
| 154 | + | t.Fatalf("model = %q, want repaired gateway default", r.Model) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 115 | 158 | // TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue | |
| 116 | 159 | // #15: pointing ZOT_HOME at a fresh dir drops the persisted | |
| 117 | 160 | // config.json (which pinned provider=amazon-bedrock). Resolve must | |
| @@ -112,6 +112,49 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) { | |||||
| 112 | } | 112 | } | ||
| 113 | } | 113 | } | ||
| 114 | 114 | ||||
| 115 | + | func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) { | |||
| 116 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 117 | + | t.Setenv("OPENROUTER_API_KEY", "test-key") | |||
| 118 | + | want := "deepseek/deepseek-v4-flash" | |||
| 119 | + | if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil { | |||
| 120 | + | t.Fatal(err) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | r, err := Resolve(Args{}, true) | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 126 | + | } | |||
| 127 | + | if r.Provider != "openrouter" { | |||
| 128 | + | t.Fatalf("provider = %q, want openrouter", r.Provider) | |||
| 129 | + | } | |||
| 130 | + | if r.Model != want { | |||
| 131 | + | t.Fatalf("model = %q, want %q", r.Model, want) | |||
| 132 | + | } | |||
| 133 | + | if r.MaxOutput != 64000 { | |||
| 134 | + | t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) { | |||
| 139 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 140 | + | t.Setenv("OPENROUTER_API_KEY", "test-key") | |||
| 141 | + | stale := "not-a-routed-model" | |||
| 142 | + | if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil { | |||
| 143 | + | t.Fatal(err) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | r, err := Resolve(Args{}, true) | |||
| 147 | + | if err != nil { | |||
| 148 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 149 | + | } | |||
| 150 | + | if r.Provider != "openrouter" { | |||
| 151 | + | t.Fatalf("provider = %q, want openrouter", r.Provider) | |||
| 152 | + | } | |||
| 153 | + | if r.Model == stale || r.Model == "" { | |||
| 154 | + | t.Fatalf("model = %q, want repaired gateway default", r.Model) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 115 | // TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue | 158 | // TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue | ||
| 116 | // #15: pointing ZOT_HOME at a fresh dir drops the persisted | 159 | // #15: pointing ZOT_HOME at a fresh dir drops the persisted | ||
| 117 | // config.json (which pinned provider=amazon-bedrock). Resolve must | 160 | // config.json (which pinned provider=amazon-bedrock). Resolve must | ||
M packages/agent/modelsync.go +22 -14
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "strings" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/patriceckhart/zot/packages/provider" | |
| @@ -46,20 +47,28 @@ func LoadUserModels() { | |||
| 46 | 47 | provider.SetUserModels(models) | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | - | // isGatewayProvider returns true for providers that act as routers or | |
| 50 | - | // gateways serving models from any provider in the catalog (OpenRouter, | |
| 51 | - | // Vercel AI Gateway, Cloudflare AI Gateway, etc.). For these providers | |
| 52 | - | // the model id is never repaired — a model that lives under "deepseek" | |
| 53 | - | // in the catalog is perfectly valid when served through OpenRouter. | |
| 50 | + | // isGatewayProvider returns true for providers whose OpenAI-compatible | |
| 51 | + | // endpoint can accept routed model IDs that are not present in zot's local | |
| 52 | + | // catalog. Vercel AI Gateway is intentionally not listed here: zot currently | |
| 53 | + | // talks to it through the Anthropic-compatible client, which still requires | |
| 54 | + | // catalog metadata for request shaping. | |
| 54 | 55 | func isGatewayProvider(prov string) bool { | |
| 55 | 56 | switch prov { | |
| 56 | - | case "openrouter", "vercel-ai-gateway", "cloudflare-ai-gateway": | |
| 57 | + | case "openrouter", "cloudflare-ai-gateway": | |
| 57 | 58 | return true | |
| 58 | 59 | default: | |
| 59 | 60 | return false | |
| 60 | 61 | } | |
| 61 | 62 | } | |
| 62 | 63 | ||
| 64 | + | // isGatewayRoutedModelID reports whether a model looks like the routed IDs | |
| 65 | + | // used by gateway providers, for example "deepseek/deepseek-v4-flash". | |
| 66 | + | // Non-routed typos like "deepseek-v4-flashh" should still be repaired to a | |
| 67 | + | // known default instead of being silently accepted. | |
| 68 | + | func isGatewayRoutedModelID(model string) bool { | |
| 69 | + | return strings.Contains(strings.TrimSpace(model), "/") | |
| 70 | + | } | |
| 71 | + | ||
| 63 | 72 | // ValidateAndRepairConfig checks the persisted config.json's | |
| 64 | 73 | // (Provider, Model) pair against the active catalog and repairs any | |
| 65 | 74 | // mismatch in-place (and on disk) before any UI renders. Three failure | |
| @@ -71,9 +80,8 @@ func isGatewayProvider(prov string) bool { | |||
| 71 | 80 | // (e.g. provider=anthropic + model=kimi-for-coding from a stale | |
| 72 | 81 | // half-applied switch) -> reset model to the provider's default. | |
| 73 | 82 | // | |
| 74 | - | // Gateway providers (openrouter, vercel-ai-gateway, cloudflare-ai-gateway) | |
| 75 | - | // are exempt from the cross-provider model check because they can serve | |
| 76 | - | // any model from the catalog. | |
| 83 | + | // Gateway providers are exempt from the cross-provider model check for routed | |
| 84 | + | // model IDs because those IDs can be valid even when absent from zot's catalog. | |
| 77 | 85 | // | |
| 78 | 86 | // Silent on success; one stderr line per repair. Errors loading or | |
| 79 | 87 | // saving the file are non-fatal — the caller continues with defaults. | |
| @@ -94,11 +102,11 @@ func ValidateAndRepairConfig() { | |||
| 94 | 102 | ||
| 95 | 103 | if cfg.Provider != "" && cfg.Model != "" { | |
| 96 | 104 | if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil { | |
| 97 | - | // Gateway providers (openrouter, vercel-ai-gateway, etc.) can | |
| 98 | - | // serve any model from the catalog. A model id that belongs to | |
| 99 | - | // "deepseek" is perfectly valid when served through OpenRouter. | |
| 100 | - | if isGatewayProvider(cfg.Provider) { | |
| 101 | - | // Provider is a router; skip the cross-provider model repair. | |
| 105 | + | // Gateway providers can serve routed model ids like | |
| 106 | + | // "deepseek/deepseek-v4-flash" even when the local catalog does not | |
| 107 | + | // know them. Preserve only routed ids; plain typos are still repaired. | |
| 108 | + | if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model) { | |
| 109 | + | // Provider is a router and the id is route-qualified; preserve it. | |
| 102 | 110 | } else if m, err := provider.FindModel("", cfg.Model); err == nil { | |
| 103 | 111 | fix := defaultModelForProvider(cfg.Provider) | |
| 104 | 112 | fmt.Fprintf(os.Stderr, | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "strings" | |||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| 10 | "github.com/patriceckhart/zot/packages/provider" | 11 | "github.com/patriceckhart/zot/packages/provider" | ||
| @@ -46,20 +47,28 @@ func LoadUserModels() { | |||||
| 46 | provider.SetUserModels(models) | 47 | provider.SetUserModels(models) | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | - | // isGatewayProvider returns true for providers that act as routers or | 50 | + | // isGatewayProvider returns true for providers whose OpenAI-compatible |
| 50 | - | // gateways serving models from any provider in the catalog (OpenRouter, | 51 | + | // endpoint can accept routed model IDs that are not present in zot's local |
| 51 | - | // Vercel AI Gateway, Cloudflare AI Gateway, etc.). For these providers | 52 | + | // catalog. Vercel AI Gateway is intentionally not listed here: zot currently |
| 52 | - | // the model id is never repaired — a model that lives under "deepseek" | 53 | + | // talks to it through the Anthropic-compatible client, which still requires |
| 53 | - | // in the catalog is perfectly valid when served through OpenRouter. | 54 | + | // catalog metadata for request shaping. |
| 54 | func isGatewayProvider(prov string) bool { | 55 | func isGatewayProvider(prov string) bool { | ||
| 55 | switch prov { | 56 | switch prov { | ||
| 56 | - | case "openrouter", "vercel-ai-gateway", "cloudflare-ai-gateway": | 57 | + | case "openrouter", "cloudflare-ai-gateway": |
| 57 | return true | 58 | return true | ||
| 58 | default: | 59 | default: | ||
| 59 | return false | 60 | return false | ||
| 60 | } | 61 | } | ||
| 61 | } | 62 | } | ||
| 62 | 63 | ||||
| 64 | + | // isGatewayRoutedModelID reports whether a model looks like the routed IDs | |||
| 65 | + | // used by gateway providers, for example "deepseek/deepseek-v4-flash". | |||
| 66 | + | // Non-routed typos like "deepseek-v4-flashh" should still be repaired to a | |||
| 67 | + | // known default instead of being silently accepted. | |||
| 68 | + | func isGatewayRoutedModelID(model string) bool { | |||
| 69 | + | return strings.Contains(strings.TrimSpace(model), "/") | |||
| 70 | + | } | |||
| 71 | + | ||||
| 63 | // ValidateAndRepairConfig checks the persisted config.json's | 72 | // ValidateAndRepairConfig checks the persisted config.json's | ||
| 64 | // (Provider, Model) pair against the active catalog and repairs any | 73 | // (Provider, Model) pair against the active catalog and repairs any | ||
| 65 | // mismatch in-place (and on disk) before any UI renders. Three failure | 74 | // mismatch in-place (and on disk) before any UI renders. Three failure | ||
| @@ -71,9 +80,8 @@ func isGatewayProvider(prov string) bool { | |||||
| 71 | // (e.g. provider=anthropic + model=kimi-for-coding from a stale | 80 | // (e.g. provider=anthropic + model=kimi-for-coding from a stale | ||
| 72 | // half-applied switch) -> reset model to the provider's default. | 81 | // half-applied switch) -> reset model to the provider's default. | ||
| 73 | // | 82 | // | ||
| 74 | - | // Gateway providers (openrouter, vercel-ai-gateway, cloudflare-ai-gateway) | 83 | + | // Gateway providers are exempt from the cross-provider model check for routed |
| 75 | - | // are exempt from the cross-provider model check because they can serve | 84 | + | // model IDs because those IDs can be valid even when absent from zot's catalog. |
| 76 | - | // any model from the catalog. | |||
| 77 | // | 85 | // | ||
| 78 | // Silent on success; one stderr line per repair. Errors loading or | 86 | // Silent on success; one stderr line per repair. Errors loading or | ||
| 79 | // saving the file are non-fatal — the caller continues with defaults. | 87 | // saving the file are non-fatal — the caller continues with defaults. | ||
| @@ -94,11 +102,11 @@ func ValidateAndRepairConfig() { | |||||
| 94 | 102 | ||||
| 95 | if cfg.Provider != "" && cfg.Model != "" { | 103 | if cfg.Provider != "" && cfg.Model != "" { | ||
| 96 | if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil { | 104 | if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil { | ||
| 97 | - | // Gateway providers (openrouter, vercel-ai-gateway, etc.) can | 105 | + | // Gateway providers can serve routed model ids like |
| 98 | - | // serve any model from the catalog. A model id that belongs to | 106 | + | // "deepseek/deepseek-v4-flash" even when the local catalog does not |
| 99 | - | // "deepseek" is perfectly valid when served through OpenRouter. | 107 | + | // know them. Preserve only routed ids; plain typos are still repaired. |
| 100 | - | if isGatewayProvider(cfg.Provider) { | 108 | + | if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model) { |
| 101 | - | // Provider is a router; skip the cross-provider model repair. | 109 | + | // Provider is a router and the id is route-qualified; preserve it. |
| 102 | } else if m, err := provider.FindModel("", cfg.Model); err == nil { | 110 | } else if m, err := provider.FindModel("", cfg.Model); err == nil { | ||
| 103 | fix := defaultModelForProvider(cfg.Provider) | 111 | fix := defaultModelForProvider(cfg.Provider) | ||
| 104 | fmt.Fprintf(os.Stderr, | 112 | fmt.Fprintf(os.Stderr, | ||
M packages/agent/modelsync_test.go +37 -0
| @@ -101,6 +101,43 @@ func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *t | |||
| 101 | 101 | } | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | + | func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) { | |
| 105 | + | home := t.TempDir() | |
| 106 | + | t.Setenv("ZOT_HOME", home) | |
| 107 | + | ||
| 108 | + | want := "deepseek/deepseek-v4-flash" | |
| 109 | + | b, _ := json.Marshal(Config{Provider: "openrouter", Model: want}) | |
| 110 | + | _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644) | |
| 111 | + | ||
| 112 | + | ValidateAndRepairConfig() | |
| 113 | + | ||
| 114 | + | out, _ := LoadConfig() | |
| 115 | + | if out.Provider != "openrouter" { | |
| 116 | + | t.Errorf("provider mutated: %q", out.Provider) | |
| 117 | + | } | |
| 118 | + | if out.Model != want { | |
| 119 | + | t.Errorf("routed model id mutated: got %q, want %q", out.Model, want) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestValidateAndRepairConfig_GatewayPlainUnknownModelStillRepairs(t *testing.T) { | |
| 124 | + | home := t.TempDir() | |
| 125 | + | t.Setenv("ZOT_HOME", home) | |
| 126 | + | ||
| 127 | + | b, _ := json.Marshal(Config{Provider: "openrouter", Model: "not-a-routed-model"}) | |
| 128 | + | _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644) | |
| 129 | + | ||
| 130 | + | ValidateAndRepairConfig() | |
| 131 | + | ||
| 132 | + | out, _ := LoadConfig() | |
| 133 | + | if out.Provider != "openrouter" { | |
| 134 | + | t.Errorf("provider mutated: %q", out.Provider) | |
| 135 | + | } | |
| 136 | + | if out.Model == "not-a-routed-model" || out.Model == "" { | |
| 137 | + | t.Errorf("plain unknown gateway model was not repaired: %q", out.Model) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 104 | 141 | // TestValidateAndRepairConfig_HappyPath leaves a valid config alone. | |
| 105 | 142 | func TestValidateAndRepairConfig_HappyPath(t *testing.T) { | |
| 106 | 143 | home := t.TempDir() | |
| @@ -101,6 +101,43 @@ func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *t | |||||
| 101 | } | 101 | } | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | + | func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) { | |||
| 105 | + | home := t.TempDir() | |||
| 106 | + | t.Setenv("ZOT_HOME", home) | |||
| 107 | + | ||||
| 108 | + | want := "deepseek/deepseek-v4-flash" | |||
| 109 | + | b, _ := json.Marshal(Config{Provider: "openrouter", Model: want}) | |||
| 110 | + | _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644) | |||
| 111 | + | ||||
| 112 | + | ValidateAndRepairConfig() | |||
| 113 | + | ||||
| 114 | + | out, _ := LoadConfig() | |||
| 115 | + | if out.Provider != "openrouter" { | |||
| 116 | + | t.Errorf("provider mutated: %q", out.Provider) | |||
| 117 | + | } | |||
| 118 | + | if out.Model != want { | |||
| 119 | + | t.Errorf("routed model id mutated: got %q, want %q", out.Model, want) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestValidateAndRepairConfig_GatewayPlainUnknownModelStillRepairs(t *testing.T) { | |||
| 124 | + | home := t.TempDir() | |||
| 125 | + | t.Setenv("ZOT_HOME", home) | |||
| 126 | + | ||||
| 127 | + | b, _ := json.Marshal(Config{Provider: "openrouter", Model: "not-a-routed-model"}) | |||
| 128 | + | _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644) | |||
| 129 | + | ||||
| 130 | + | ValidateAndRepairConfig() | |||
| 131 | + | ||||
| 132 | + | out, _ := LoadConfig() | |||
| 133 | + | if out.Provider != "openrouter" { | |||
| 134 | + | t.Errorf("provider mutated: %q", out.Provider) | |||
| 135 | + | } | |||
| 136 | + | if out.Model == "not-a-routed-model" || out.Model == "" { | |||
| 137 | + | t.Errorf("plain unknown gateway model was not repaired: %q", out.Model) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 104 | // TestValidateAndRepairConfig_HappyPath leaves a valid config alone. | 141 | // TestValidateAndRepairConfig_HappyPath leaves a valid config alone. | ||
| 105 | func TestValidateAndRepairConfig_HappyPath(t *testing.T) { | 142 | func TestValidateAndRepairConfig_HappyPath(t *testing.T) { | ||
| 106 | home := t.TempDir() | 143 | home := t.TempDir() | ||