detached 8e8979a

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) {
348348 }
349349 }
350350
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 != ""
356371 if credErr != nil && !userPickedProvider && provName != "ollama" {
372+ autoFellBack = true
357373 // Scan every known provider (not a hardcoded subset) so any
358374 // env-based credential is discovered, e.g. an env-only
359375 // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
@@ -381,8 +397,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
381397 model = defaultModelForProvider(provName)
382398 }
383399 // 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" {
386406 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
387407 model = defaultModelForProvider(provName)
388408 }
@@ -416,6 +436,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
416436 }
417437 err = nil
418438 }
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+ }
419463 } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
420464 // Fall back to the provider-level base URL when the model does
421465 // not define its own endpoint.
@@ -431,29 +475,49 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
431475 // when the stale id came from the persisted config (not an
432476 // explicit --model flag), repair the config so the warning
433477 // 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",
446493 }
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
447520 }
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
457521 }
458522
459523 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 one351+ // If the user did NOT explicitly pick a provider (neither via --provider
352- // has no credentials, auto-fall-back to whichever provider is actually352+ // 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 any373 // Scan every known provider (not a hardcoded subset) so any
358 // env-based credential is discovered, e.g. an env-only374 // 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. config399 // 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 = nil437 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 does464 // 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 an475 // when the stale id came from the persisted config (not an
432 // explicit --model flag), repair the config so the warning476 // 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 (catastrophic481+ // as long as the upstream gateway recognises them. Create a
438- // catalogue trim). Last resort: any model on this482+ // 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 = nil486+ ID: model,
443- } else {487+ DisplayName: model,
444- fm = provider.DefaultModel488+ ContextWindow: 1000000,
445- ferr = nil489+ 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() {
4646 provider.SetUserModels(models)
4747 }
4848
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+
4963 // ValidateAndRepairConfig checks the persisted config.json's
5064 // (Provider, Model) pair against the active catalog and repairs any
5165 // mismatch in-place (and on disk) before any UI renders. Three failure
@@ -57,6 +71,10 @@ func LoadUserModels() {
5771 // (e.g. provider=anthropic + model=kimi-for-coding from a stale
5872 // half-applied switch) -> reset model to the provider's default.
5973 //
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+//
6078 // Silent on success; one stderr line per repair. Errors loading or
6179 // saving the file are non-fatal — the caller continues with defaults.
6280 func ValidateAndRepairConfig() {
@@ -76,7 +94,12 @@ func ValidateAndRepairConfig() {
7694
7795 if cfg.Provider != "" && cfg.Model != "" {
7896 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 {
80103 fix := defaultModelForProvider(cfg.Provider)
81104 fmt.Fprintf(os.Stderr,
82105 "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's63 // ValidateAndRepairConfig checks the persisted config.json's
50 // (Provider, Model) pair against the active catalog and repairs any64 // (Provider, Model) pair against the active catalog and repairs any
51 // mismatch in-place (and on disk) before any UI renders. Three failure65 // 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 stale71 // (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 or78 // 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",

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close