Add gateway provider support for flexible model routing bug #58
- commit
- 8e8979a9b1deccd65ca4989c22edc8f2e53e6c1b
- author
- DaviSM · · 2026-07-05 01:55 UTC
- parent
- 482b666
- signature
- unsigned
2 files · +116 -29
M packages/agent/build.go +92 -28
| @@ -348,12 +348,28 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 348 | 348 | } | |
| 349 | 349 | } | |
| 350 | 350 | ||
| 351 | - | // If the user did NOT explicitly pick a provider and the default one | |
| 352 | - | // has no credentials, auto-fall-back to whichever provider is actually | |
| 353 | - | // logged in. That way running plain `zot` after `/login` (any provider) | |
| 354 | - | // never shows a "not logged in" banner. | |
| 355 | - | userPickedProvider := args.Provider != "" | |
| 351 | + | // If the user did NOT explicitly pick a provider (neither via --provider | |
| 352 | + | // nor by saving one in config.json) and the default one has no | |
| 353 | + | // credentials, auto-fall-back to whichever provider is actually logged | |
| 354 | + | // in. That way running plain `zot` after `/login` (any provider) never | |
| 355 | + | // shows a "not logged in" banner. | |
| 356 | + | // | |
| 357 | + | // Critical: when the user HAS saved a provider in config.json (e.g. | |
| 358 | + | // "deepseek" with "deepseek-v4-flash"), that choice is respected even | |
| 359 | + | // if no credential is found — the tui will show the login dialog or | |
| 360 | + | // surface a clear credential error rather than silently switching to | |
| 361 | + | // a completely different provider and model. | |
| 362 | + | // autoFellBack tracks whether the provider was changed by the | |
| 363 | + | // auto-fallback loop (no explicit provider from CLI or config, | |
| 364 | + | // and the default had no credentials). When false, the provider | |
| 365 | + | // came from the user's explicit choice (--provider or config.json) | |
| 366 | + | // and must be respected, including any model id that may belong | |
| 367 | + | // to a different provider in the catalog — for example when using | |
| 368 | + | // a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash". | |
| 369 | + | var autoFellBack bool | |
| 370 | + | userPickedProvider := args.Provider != "" || cfg.Provider != "" | |
| 356 | 371 | if credErr != nil && !userPickedProvider && provName != "ollama" { | |
| 372 | + | autoFellBack = true | |
| 357 | 373 | // Scan every known provider (not a hardcoded subset) so any | |
| 358 | 374 | // env-based credential is discovered, e.g. an env-only | |
| 359 | 375 | // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE / | |
| @@ -381,8 +397,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 381 | 397 | model = defaultModelForProvider(provName) | |
| 382 | 398 | } | |
| 383 | 399 | // If the resolved model belongs to a different provider (e.g. config | |
| 384 | - | // says gpt-5 but we fell back to anthropic), pick that provider's default. | |
| 385 | - | if provName != "ollama" { | |
| 400 | + | // says gpt-5 but we auto-fell back to anthropic), pick that provider's | |
| 401 | + | // default. This override only fires when the provider was auto-fallback- | |
| 402 | + | // changed, NOT when the user explicitly configured the pair — gateway/ | |
| 403 | + | // router providers (openrouter, vercel-ai-gateway, etc.) can serve | |
| 404 | + | // models from any provider in the catalog. | |
| 405 | + | if autoFellBack && provName != "ollama" { | |
| 386 | 406 | if m, err := provider.FindModel("", model); err == nil && m.Provider != provName { | |
| 387 | 407 | model = defaultModelForProvider(provName) | |
| 388 | 408 | } | |
| @@ -416,6 +436,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 416 | 436 | } | |
| 417 | 437 | err = nil | |
| 418 | 438 | } | |
| 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 | + | } | |
| 419 | 463 | } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" { | |
| 420 | 464 | // Fall back to the provider-level base URL when the model does | |
| 421 | 465 | // not define its own endpoint. | |
| @@ -431,29 +475,49 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 431 | 475 | // when the stale id came from the persisted config (not an | |
| 432 | 476 | // explicit --model flag), repair the config so the warning | |
| 433 | 477 | // doesn't repeat on every launch. | |
| 434 | - | fallback := defaultModelForProvider(provName) | |
| 435 | - | fm, ferr := provider.FindModel(provName, fallback) | |
| 436 | - | if ferr != nil { | |
| 437 | - | // Even the provider default is gone (catastrophic | |
| 438 | - | // catalogue trim). Last resort: any model on this | |
| 439 | - | // provider, then the global DefaultModel. | |
| 440 | - | if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 { | |
| 441 | - | fm = candidates[0] | |
| 442 | - | ferr = nil | |
| 443 | - | } else { | |
| 444 | - | fm = provider.DefaultModel | |
| 445 | - | ferr = nil | |
| 478 | + | ||
| 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) { | |
| 484 | + | resolvedModel = provider.Model{ | |
| 485 | + | Provider: provName, | |
| 486 | + | ID: model, | |
| 487 | + | DisplayName: model, | |
| 488 | + | ContextWindow: 1000000, | |
| 489 | + | MaxOutput: 64000, | |
| 490 | + | Reasoning: true, | |
| 491 | + | BaseURL: "", | |
| 492 | + | Source: "user", | |
| 446 | 493 | } | |
| 494 | + | err = nil | |
| 495 | + | // Don't repair config — the model id is valid for this gateway. | |
| 496 | + | } else { | |
| 497 | + | fallback := defaultModelForProvider(provName) | |
| 498 | + | fm, ferr := provider.FindModel(provName, fallback) | |
| 499 | + | if ferr != nil { | |
| 500 | + | // Even the provider default is gone (catastrophic | |
| 501 | + | // catalogue trim). Last resort: any model on this | |
| 502 | + | // provider, then the global DefaultModel. | |
| 503 | + | if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 { | |
| 504 | + | fm = candidates[0] | |
| 505 | + | ferr = nil | |
| 506 | + | } else { | |
| 507 | + | fm = provider.DefaultModel | |
| 508 | + | ferr = nil | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | fmt.Fprintf(os.Stderr, | |
| 512 | + | "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n", | |
| 513 | + | model, fm.ID) | |
| 514 | + | if args.Model == "" && cfg.Model == model { | |
| 515 | + | cfg.Model = fm.ID | |
| 516 | + | _ = SaveConfig(cfg) | |
| 517 | + | } | |
| 518 | + | resolvedModel = fm | |
| 519 | + | model = fm.ID | |
| 447 | 520 | } | |
| 448 | - | fmt.Fprintf(os.Stderr, | |
| 449 | - | "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n", | |
| 450 | - | model, fm.ID) | |
| 451 | - | if args.Model == "" && cfg.Model == model { | |
| 452 | - | cfg.Model = fm.ID | |
| 453 | - | _ = SaveConfig(cfg) | |
| 454 | - | } | |
| 455 | - | resolvedModel = fm | |
| 456 | - | model = fm.ID | |
| 457 | 521 | } | |
| 458 | 522 | ||
| 459 | 523 | explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "") | |
| @@ -348,12 +348,28 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 348 | } | 348 | } | ||
| 349 | } | 349 | } | ||
| 350 | 350 | ||||
| 351 | - | // If the user did NOT explicitly pick a provider and the default one | 351 | + | // If the user did NOT explicitly pick a provider (neither via --provider |
| 352 | - | // has no credentials, auto-fall-back to whichever provider is actually | 352 | + | // nor by saving one in config.json) and the default one has no |
| 353 | - | // logged in. That way running plain `zot` after `/login` (any provider) | 353 | + | // credentials, auto-fall-back to whichever provider is actually logged |
| 354 | - | // never shows a "not logged in" banner. | 354 | + | // in. That way running plain `zot` after `/login` (any provider) never |
| 355 | - | userPickedProvider := args.Provider != "" | 355 | + | // shows a "not logged in" banner. |
| 356 | + | // | |||
| 357 | + | // Critical: when the user HAS saved a provider in config.json (e.g. | |||
| 358 | + | // "deepseek" with "deepseek-v4-flash"), that choice is respected even | |||
| 359 | + | // if no credential is found — the tui will show the login dialog or | |||
| 360 | + | // surface a clear credential error rather than silently switching to | |||
| 361 | + | // a completely different provider and model. | |||
| 362 | + | // autoFellBack tracks whether the provider was changed by the | |||
| 363 | + | // auto-fallback loop (no explicit provider from CLI or config, | |||
| 364 | + | // and the default had no credentials). When false, the provider | |||
| 365 | + | // came from the user's explicit choice (--provider or config.json) | |||
| 366 | + | // and must be respected, including any model id that may belong | |||
| 367 | + | // to a different provider in the catalog — for example when using | |||
| 368 | + | // a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash". | |||
| 369 | + | var autoFellBack bool | |||
| 370 | + | userPickedProvider := args.Provider != "" || cfg.Provider != "" | |||
| 356 | if credErr != nil && !userPickedProvider && provName != "ollama" { | 371 | if credErr != nil && !userPickedProvider && provName != "ollama" { | ||
| 372 | + | autoFellBack = true | |||
| 357 | // Scan every known provider (not a hardcoded subset) so any | 373 | // Scan every known provider (not a hardcoded subset) so any | ||
| 358 | // env-based credential is discovered, e.g. an env-only | 374 | // env-based credential is discovered, e.g. an env-only | ||
| 359 | // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE / | 375 | // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE / | ||
| @@ -381,8 +397,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 381 | model = defaultModelForProvider(provName) | 397 | model = defaultModelForProvider(provName) | ||
| 382 | } | 398 | } | ||
| 383 | // If the resolved model belongs to a different provider (e.g. config | 399 | // If the resolved model belongs to a different provider (e.g. config | ||
| 384 | - | // says gpt-5 but we fell back to anthropic), pick that provider's default. | 400 | + | // says gpt-5 but we auto-fell back to anthropic), pick that provider's |
| 385 | - | if provName != "ollama" { | 401 | + | // default. This override only fires when the provider was auto-fallback- |
| 402 | + | // changed, NOT when the user explicitly configured the pair — gateway/ | |||
| 403 | + | // router providers (openrouter, vercel-ai-gateway, etc.) can serve | |||
| 404 | + | // models from any provider in the catalog. | |||
| 405 | + | if autoFellBack && provName != "ollama" { | |||
| 386 | if m, err := provider.FindModel("", model); err == nil && m.Provider != provName { | 406 | if m, err := provider.FindModel("", model); err == nil && m.Provider != provName { | ||
| 387 | model = defaultModelForProvider(provName) | 407 | model = defaultModelForProvider(provName) | ||
| 388 | } | 408 | } | ||
| @@ -416,6 +436,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 416 | } | 436 | } | ||
| 417 | err = nil | 437 | err = nil | ||
| 418 | } | 438 | } | ||
| 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 | + | } | |||
| 419 | } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" { | 463 | } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" { | ||
| 420 | // Fall back to the provider-level base URL when the model does | 464 | // Fall back to the provider-level base URL when the model does | ||
| 421 | // not define its own endpoint. | 465 | // not define its own endpoint. | ||
| @@ -431,29 +475,49 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 431 | // when the stale id came from the persisted config (not an | 475 | // when the stale id came from the persisted config (not an | ||
| 432 | // explicit --model flag), repair the config so the warning | 476 | // explicit --model flag), repair the config so the warning | ||
| 433 | // doesn't repeat on every launch. | 477 | // doesn't repeat on every launch. | ||
| 434 | - | fallback := defaultModelForProvider(provName) | 478 | + | |
| 435 | - | fm, ferr := provider.FindModel(provName, fallback) | 479 | + | // Gateway providers (openrouter, vercel-ai-gateway, etc.) are |
| 436 | - | if ferr != nil { | 480 | + | // open-catalogue: even model ids not in any catalog are valid |
| 437 | - | // Even the provider default is gone (catastrophic | 481 | + | // as long as the upstream gateway recognises them. Create a |
| 438 | - | // catalogue trim). Last resort: any model on this | 482 | + | // synthetic model entry instead of overwriting the user's choice. |
| 439 | - | // provider, then the global DefaultModel. | 483 | + | if isGatewayProvider(provName) { |
| 440 | - | if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 { | 484 | + | resolvedModel = provider.Model{ |
| 441 | - | fm = candidates[0] | 485 | + | Provider: provName, |
| 442 | - | ferr = nil | 486 | + | ID: model, |
| 443 | - | } else { | 487 | + | DisplayName: model, |
| 444 | - | fm = provider.DefaultModel | 488 | + | ContextWindow: 1000000, |
| 445 | - | ferr = nil | 489 | + | MaxOutput: 64000, |
| 490 | + | Reasoning: true, | |||
| 491 | + | BaseURL: "", | |||
| 492 | + | Source: "user", | |||
| 446 | } | 493 | } | ||
| 494 | + | err = nil | |||
| 495 | + | // Don't repair config — the model id is valid for this gateway. | |||
| 496 | + | } else { | |||
| 497 | + | fallback := defaultModelForProvider(provName) | |||
| 498 | + | fm, ferr := provider.FindModel(provName, fallback) | |||
| 499 | + | if ferr != nil { | |||
| 500 | + | // Even the provider default is gone (catastrophic | |||
| 501 | + | // catalogue trim). Last resort: any model on this | |||
| 502 | + | // provider, then the global DefaultModel. | |||
| 503 | + | if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 { | |||
| 504 | + | fm = candidates[0] | |||
| 505 | + | ferr = nil | |||
| 506 | + | } else { | |||
| 507 | + | fm = provider.DefaultModel | |||
| 508 | + | ferr = nil | |||
| 509 | + | } | |||
| 510 | + | } | |||
| 511 | + | fmt.Fprintf(os.Stderr, | |||
| 512 | + | "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n", | |||
| 513 | + | model, fm.ID) | |||
| 514 | + | if args.Model == "" && cfg.Model == model { | |||
| 515 | + | cfg.Model = fm.ID | |||
| 516 | + | _ = SaveConfig(cfg) | |||
| 517 | + | } | |||
| 518 | + | resolvedModel = fm | |||
| 519 | + | model = fm.ID | |||
| 447 | } | 520 | } | ||
| 448 | - | fmt.Fprintf(os.Stderr, | |||
| 449 | - | "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n", | |||
| 450 | - | model, fm.ID) | |||
| 451 | - | if args.Model == "" && cfg.Model == model { | |||
| 452 | - | cfg.Model = fm.ID | |||
| 453 | - | _ = SaveConfig(cfg) | |||
| 454 | - | } | |||
| 455 | - | resolvedModel = fm | |||
| 456 | - | model = fm.ID | |||
| 457 | } | 521 | } | ||
| 458 | 522 | ||||
| 459 | explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "") | 523 | explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "") | ||
M packages/agent/modelsync.go +24 -1
| @@ -46,6 +46,20 @@ func LoadUserModels() { | |||
| 46 | 46 | provider.SetUserModels(models) | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 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. | |
| 54 | + | func isGatewayProvider(prov string) bool { | |
| 55 | + | switch prov { | |
| 56 | + | case "openrouter", "vercel-ai-gateway", "cloudflare-ai-gateway": | |
| 57 | + | return true | |
| 58 | + | default: | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 49 | 63 | // ValidateAndRepairConfig checks the persisted config.json's | |
| 50 | 64 | // (Provider, Model) pair against the active catalog and repairs any | |
| 51 | 65 | // mismatch in-place (and on disk) before any UI renders. Three failure | |
| @@ -57,6 +71,10 @@ func LoadUserModels() { | |||
| 57 | 71 | // (e.g. provider=anthropic + model=kimi-for-coding from a stale | |
| 58 | 72 | // half-applied switch) -> reset model to the provider's default. | |
| 59 | 73 | // | |
| 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. | |
| 77 | + | // | |
| 60 | 78 | // Silent on success; one stderr line per repair. Errors loading or | |
| 61 | 79 | // saving the file are non-fatal — the caller continues with defaults. | |
| 62 | 80 | func ValidateAndRepairConfig() { | |
| @@ -76,7 +94,12 @@ func ValidateAndRepairConfig() { | |||
| 76 | 94 | ||
| 77 | 95 | if cfg.Provider != "" && cfg.Model != "" { | |
| 78 | 96 | if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil { | |
| 79 | - | if m, err := provider.FindModel("", 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. | |
| 102 | + | } else if m, err := provider.FindModel("", cfg.Model); err == nil { | |
| 80 | 103 | fix := defaultModelForProvider(cfg.Provider) | |
| 81 | 104 | fmt.Fprintf(os.Stderr, | |
| 82 | 105 | "zot: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n", | |
| @@ -46,6 +46,20 @@ func LoadUserModels() { | |||||
| 46 | provider.SetUserModels(models) | 46 | provider.SetUserModels(models) | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 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. | |||
| 54 | + | func isGatewayProvider(prov string) bool { | |||
| 55 | + | switch prov { | |||
| 56 | + | case "openrouter", "vercel-ai-gateway", "cloudflare-ai-gateway": | |||
| 57 | + | return true | |||
| 58 | + | default: | |||
| 59 | + | return false | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 49 | // ValidateAndRepairConfig checks the persisted config.json's | 63 | // ValidateAndRepairConfig checks the persisted config.json's | ||
| 50 | // (Provider, Model) pair against the active catalog and repairs any | 64 | // (Provider, Model) pair against the active catalog and repairs any | ||
| 51 | // mismatch in-place (and on disk) before any UI renders. Three failure | 65 | // mismatch in-place (and on disk) before any UI renders. Three failure | ||
| @@ -57,6 +71,10 @@ func LoadUserModels() { | |||||
| 57 | // (e.g. provider=anthropic + model=kimi-for-coding from a stale | 71 | // (e.g. provider=anthropic + model=kimi-for-coding from a stale | ||
| 58 | // half-applied switch) -> reset model to the provider's default. | 72 | // half-applied switch) -> reset model to the provider's default. | ||
| 59 | // | 73 | // | ||
| 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. | |||
| 77 | + | // | |||
| 60 | // Silent on success; one stderr line per repair. Errors loading or | 78 | // Silent on success; one stderr line per repair. Errors loading or | ||
| 61 | // saving the file are non-fatal — the caller continues with defaults. | 79 | // saving the file are non-fatal — the caller continues with defaults. | ||
| 62 | func ValidateAndRepairConfig() { | 80 | func ValidateAndRepairConfig() { | ||
| @@ -76,7 +94,12 @@ func ValidateAndRepairConfig() { | |||||
| 76 | 94 | ||||
| 77 | if cfg.Provider != "" && cfg.Model != "" { | 95 | if cfg.Provider != "" && cfg.Model != "" { | ||
| 78 | if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil { | 96 | if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil { | ||
| 79 | - | if m, err := provider.FindModel("", 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. | |||
| 102 | + | } else if m, err := provider.FindModel("", cfg.Model); err == nil { | |||
| 80 | fix := defaultModelForProvider(cfg.Provider) | 103 | fix := defaultModelForProvider(cfg.Provider) | ||
| 81 | fmt.Fprintf(os.Stderr, | 104 | fmt.Fprintf(os.Stderr, | ||
| 82 | "zot: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n", | 105 | "zot: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n", | ||