detached 902f459

Merge pull request #59 from frkr/fix/providerfell

Add gateway provider support for flexible model routing bug #58
commit
902f4594c59be87586b844c07368d45c23cd5c02
author
Patric Eckhart · · 2026-07-06 05:40 UTC
committer
GitHub · · 2026-07-06 05:40 UTC
merge of
482b666 + 1531234 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.59
4 files · +181 -30
M packages/agent/build.go +69 -29
@@ -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,7 +436,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
416436 }
417437 err = nil
418438 }
419- } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
439+ }
440+ if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
420441 // Fall back to the provider-level base URL when the model does
421442 // not define its own endpoint.
422443 resolvedModel.BaseURL = cfg.BaseURL
@@ -431,29 +452,48 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
431452 // when the stale id came from the persisted config (not an
432453 // explicit --model flag), repair the config so the warning
433454 // 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
455+
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) {
461+ resolvedModel = provider.Model{
462+ Provider: provName,
463+ ID: model,
464+ DisplayName: model,
465+ ContextWindow: 1000000,
466+ MaxOutput: 64000,
467+ BaseURL: "",
468+ Source: "gateway",
446469 }
470+ err = nil
471+ // Don't repair config — the routed model id may be valid upstream.
472+ } else {
473+ fallback := defaultModelForProvider(provName)
474+ fm, ferr := provider.FindModel(provName, fallback)
475+ if ferr != nil {
476+ // Even the provider default is gone (catastrophic
477+ // catalogue trim). Last resort: any model on this
478+ // provider, then the global DefaultModel.
479+ if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
480+ fm = candidates[0]
481+ ferr = nil
482+ } else {
483+ fm = provider.DefaultModel
484+ ferr = nil
485+ }
486+ }
487+ fmt.Fprintf(os.Stderr,
488+ "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
489+ model, fm.ID)
490+ if args.Model == "" && cfg.Model == model {
491+ cfg.Model = fm.ID
492+ _ = SaveConfig(cfg)
493+ }
494+ resolvedModel = fm
495+ model = fm.ID
447496 }
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
457497 }
458498
459499 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,7 +436,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
416 }436 }
417 err = nil437 err = nil
418 }438 }
419- } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {439+ }
440+ if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
420 // Fall back to the provider-level base URL when the model does441 // Fall back to the provider-level base URL when the model does
421 // not define its own endpoint.442 // not define its own endpoint.
422 resolvedModel.BaseURL = cfg.BaseURL443 resolvedModel.BaseURL = cfg.BaseURL
@@ -431,29 +452,48 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
431 // when the stale id came from the persisted config (not an452 // when the stale id came from the persisted config (not an
432 // explicit --model flag), repair the config so the warning453 // explicit --model flag), repair the config so the warning
433 // doesn't repeat on every launch.454 // doesn't repeat on every launch.
434- fallback := defaultModelForProvider(provName)455+
435- fm, ferr := provider.FindModel(provName, fallback)456+ // Gateway providers can accept route-qualified ids that are not in
436- if ferr != nil {457+ // zot's local catalog yet, for example OpenRouter's
437- // Even the provider default is gone (catastrophic458+ // "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
438- // catalogue trim). Last resort: any model on this459+ // plain unknown values are likely typos and should still fall back.
439- // provider, then the global DefaultModel.460+ if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
440- if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {461+ resolvedModel = provider.Model{
441- fm = candidates[0]462+ Provider: provName,
442- ferr = nil463+ ID: model,
443- } else {464+ DisplayName: model,
444- fm = provider.DefaultModel465+ ContextWindow: 1000000,
445- ferr = nil466+ MaxOutput: 64000,
467+ BaseURL: "",
468+ Source: "gateway",
446 }469 }
470+ err = nil
471+ // Don't repair config — the routed model id may be valid upstream.
472+ } else {
473+ fallback := defaultModelForProvider(provName)
474+ fm, ferr := provider.FindModel(provName, fallback)
475+ if ferr != nil {
476+ // Even the provider default is gone (catastrophic
477+ // catalogue trim). Last resort: any model on this
478+ // provider, then the global DefaultModel.
479+ if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
480+ fm = candidates[0]
481+ ferr = nil
482+ } else {
483+ fm = provider.DefaultModel
484+ ferr = nil
485+ }
486+ }
487+ fmt.Fprintf(os.Stderr,
488+ "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
489+ model, fm.ID)
490+ if args.Model == "" && cfg.Model == model {
491+ cfg.Model = fm.ID
492+ _ = SaveConfig(cfg)
493+ }
494+ resolvedModel = fm
495+ model = fm.ID
447 }496 }
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 }497 }
458 498
459 explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")499 explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
M packages/agent/build_test.go +43 -0
@@ -112,6 +112,49 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
112112 }
113113 }
114114
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+
115158 // TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
116159 // #15: pointing ZOT_HOME at a fresh dir drops the persisted
117160 // 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 issue158 // TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
116 // #15: pointing ZOT_HOME at a fresh dir drops the persisted159 // #15: pointing ZOT_HOME at a fresh dir drops the persisted
117 // config.json (which pinned provider=amazon-bedrock). Resolve must160 // config.json (which pinned provider=amazon-bedrock). Resolve must
M packages/agent/modelsync.go +32 -1
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8+ "strings"
89 "time"
910
1011 "github.com/patriceckhart/zot/packages/provider"
@@ -46,6 +47,28 @@ func LoadUserModels() {
4647 provider.SetUserModels(models)
4748 }
4849
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.
55+func isGatewayProvider(prov string) bool {
56+ switch prov {
57+ case "openrouter", "cloudflare-ai-gateway":
58+ return true
59+ default:
60+ return false
61+ }
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+
4972 // ValidateAndRepairConfig checks the persisted config.json's
5073 // (Provider, Model) pair against the active catalog and repairs any
5174 // mismatch in-place (and on disk) before any UI renders. Three failure
@@ -57,6 +80,9 @@ func LoadUserModels() {
5780 // (e.g. provider=anthropic + model=kimi-for-coding from a stale
5881 // half-applied switch) -> reset model to the provider's default.
5982 //
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.
85+//
6086 // Silent on success; one stderr line per repair. Errors loading or
6187 // saving the file are non-fatal — the caller continues with defaults.
6288 func ValidateAndRepairConfig() {
@@ -76,7 +102,12 @@ func ValidateAndRepairConfig() {
76102
77103 if cfg.Provider != "" && cfg.Model != "" {
78104 if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil {
79- if m, err := provider.FindModel("", cfg.Model); err == nil {
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.
110+ } else if m, err := provider.FindModel("", cfg.Model); err == nil {
80111 fix := defaultModelForProvider(cfg.Provider)
81112 fmt.Fprintf(os.Stderr,
82113 "zot: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n",
@@ -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,6 +47,28 @@ func LoadUserModels() {
46 provider.SetUserModels(models)47 provider.SetUserModels(models)
47 }48 }
48 49
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.
55+func isGatewayProvider(prov string) bool {
56+ switch prov {
57+ case "openrouter", "cloudflare-ai-gateway":
58+ return true
59+ default:
60+ return false
61+ }
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+
49 // ValidateAndRepairConfig checks the persisted config.json's72 // ValidateAndRepairConfig checks the persisted config.json's
50 // (Provider, Model) pair against the active catalog and repairs any73 // (Provider, Model) pair against the active catalog and repairs any
51 // mismatch in-place (and on disk) before any UI renders. Three failure74 // mismatch in-place (and on disk) before any UI renders. Three failure
@@ -57,6 +80,9 @@ func LoadUserModels() {
57 // (e.g. provider=anthropic + model=kimi-for-coding from a stale80 // (e.g. provider=anthropic + model=kimi-for-coding from a stale
58 // half-applied switch) -> reset model to the provider's default.81 // half-applied switch) -> reset model to the provider's default.
59 //82 //
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.
85+//
60 // Silent on success; one stderr line per repair. Errors loading or86 // Silent on success; one stderr line per repair. Errors loading or
61 // saving the file are non-fatal — the caller continues with defaults.87 // saving the file are non-fatal — the caller continues with defaults.
62 func ValidateAndRepairConfig() {88 func ValidateAndRepairConfig() {
@@ -76,7 +102,12 @@ func ValidateAndRepairConfig() {
76 102
77 if cfg.Provider != "" && cfg.Model != "" {103 if cfg.Provider != "" && cfg.Model != "" {
78 if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil {104 if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil {
79- if m, err := provider.FindModel("", cfg.Model); err == nil {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.
110+ } else if m, err := provider.FindModel("", cfg.Model); err == nil {
80 fix := defaultModelForProvider(cfg.Provider)111 fix := defaultModelForProvider(cfg.Provider)
81 fmt.Fprintf(os.Stderr,112 fmt.Fprintf(os.Stderr,
82 "zot: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n",113 "zot: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n",
M packages/agent/modelsync_test.go +37 -0
@@ -101,6 +101,43 @@ func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *t
101101 }
102102 }
103103
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+
104141 // TestValidateAndRepairConfig_HappyPath leaves a valid config alone.
105142 func TestValidateAndRepairConfig_HappyPath(t *testing.T) {
106143 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()

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