detached dc2d337

feat(provider): support custom providers defined in models.json

Custom provider metadata now lives entirely in $ZOT_HOME/models.json
instead of a separate provider-config.json or auth.json base_url entry.

- Extend UserProvider to carry baseUrl and api format (openai/anthropic).
- Recognize custom providers in Resolve, the login picker, and the model
  list when credentials exist.
- Persist only API keys in auth.json; base URLs are read from models.json.
- Normalize custom provider env vars so my-company uses MY_COMPANY_API_KEY.
- Reuse NewOpenAICompat/NewAnthropicCompat for user-defined endpoints.
- Drop the checked-in provider-config.json example and modelListEndpoint.
commit
dc2d3372392608ba78ef709e91e20856cf746b5c
author
Gopinath · · 2026-06-16 16:53 UTC
parent
94ece7d
signature
unsigned
7 files · +201 -40
M packages/agent/build.go +55 -0
@@ -191,6 +191,11 @@ func defaultModelForProvider(prov string) string {
191191 case "github-copilot":
192192 return "claude-sonnet-4.5"
193193 default:
194+ // Custom providers: pick the first model from the catalog for
195+ // that provider, or fall back to the global default.
196+ if models := provider.ModelsForProvider(prov); len(models) > 0 {
197+ return models[0].ID
198+ }
194199 return provider.DefaultModel.ID
195200 }
196201 }
@@ -213,6 +218,18 @@ var knownProviders = []string{
213218 }
214219
215220 func isKnownProvider(name string) bool {
221+ for _, p := range knownProviders {
222+ if p == name {
223+ return true
224+ }
225+ }
226+ _, ok := provider.CustomProviders()[name]
227+ return ok
228+}
229+
230+// isBuiltinProvider reports whether name is in the hardcoded
231+// knownProviders list (not a user-defined custom provider).
232+func isBuiltinProvider(name string) bool {
216233 for _, p := range knownProviders {
217234 if p == name {
218235 return true
@@ -284,6 +301,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
284301 if !isKnownProvider(provName) {
285302 // Unknown provider (maybe removed or renamed). Fall back to
286303 // the first provider that has credentials, or anthropic.
304+ // Custom providers (from models.json) are already accepted
305+ // by isKnownProvider, so we only reach here for truly unknown
306+ // names.
287307 provName = "anthropic"
288308 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
289309 provName = "openai"
@@ -319,6 +339,14 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
319339 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
320340 }
321341
342+ // Persist --api-key for custom providers so subsequent runs don't
343+ // need to pass it again.
344+ if !isBuiltinProvider(provName) && args.APIKey != "" {
345+ if store := AuthStoreFor(); store != nil {
346+ _ = store.SetAPIKey(provName, args.APIKey)
347+ }
348+ }
349+
322350 // If the user did NOT explicitly pick a provider and the default one
323351 // has no credentials, auto-fall-back to whichever provider is actually
324352 // logged in. That way running plain `zot` after `/login` (any provider)
@@ -374,6 +402,24 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
374402 }
375403 err = nil
376404 }
405+ // Custom providers are open-catalogue like ollama: any model id the
406+ // endpoint understands is valid. Use the provider-level base URL.
407+ if err != nil {
408+ if cfg, ok := provider.CustomProviders()[provName]; ok {
409+ resolvedModel = provider.Model{
410+ Provider: provName,
411+ ID: model,
412+ DisplayName: model,
413+ BaseURL: cfg.BaseURL,
414+ Source: "user",
415+ }
416+ err = nil
417+ }
418+ } else if cfg, ok := provider.CustomProviders()[provName]; ok && cfg.BaseURL != "" {
419+ // Prefer the base URL configured in models.json over the
420+ // model base URL (which may be stale from live discovery).
421+ resolvedModel.BaseURL = cfg.BaseURL
422+ }
377423 if err != nil {
378424 // The model the user (or persisted config) asked for is no
379425 // longer in the active catalogue — they probably removed it
@@ -712,6 +758,15 @@ func (r Resolved) NewClient() provider.Client {
712758 case "cloudflare-ai-gateway":
713759 return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
714760 default:
761+ // Custom providers: choose wire format from the models.json api field.
762+ if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
763+ switch cfg.API {
764+ case "anthropic":
765+ return provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL)
766+ default: // "openai"
767+ return provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, "")
768+ }
769+ }
715770 if r.AuthMethod == "oauth" {
716771 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
717772 return r.wrapWithRefresh(inner)
@@ -191,6 +191,11 @@ func defaultModelForProvider(prov string) string {
191 case "github-copilot":191 case "github-copilot":
192 return "claude-sonnet-4.5"192 return "claude-sonnet-4.5"
193 default:193 default:
194+ // Custom providers: pick the first model from the catalog for
195+ // that provider, or fall back to the global default.
196+ if models := provider.ModelsForProvider(prov); len(models) > 0 {
197+ return models[0].ID
198+ }
194 return provider.DefaultModel.ID199 return provider.DefaultModel.ID
195 }200 }
196 }201 }
@@ -213,6 +218,18 @@ var knownProviders = []string{
213 }218 }
214 219
215 func isKnownProvider(name string) bool {220 func isKnownProvider(name string) bool {
221+ for _, p := range knownProviders {
222+ if p == name {
223+ return true
224+ }
225+ }
226+ _, ok := provider.CustomProviders()[name]
227+ return ok
228+}
229+
230+// isBuiltinProvider reports whether name is in the hardcoded
231+// knownProviders list (not a user-defined custom provider).
232+func isBuiltinProvider(name string) bool {
216 for _, p := range knownProviders {233 for _, p := range knownProviders {
217 if p == name {234 if p == name {
218 return true235 return true
@@ -284,6 +301,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
284 if !isKnownProvider(provName) {301 if !isKnownProvider(provName) {
285 // Unknown provider (maybe removed or renamed). Fall back to302 // Unknown provider (maybe removed or renamed). Fall back to
286 // the first provider that has credentials, or anthropic.303 // the first provider that has credentials, or anthropic.
304+ // Custom providers (from models.json) are already accepted
305+ // by isKnownProvider, so we only reach here for truly unknown
306+ // names.
287 provName = "anthropic"307 provName = "anthropic"
288 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {308 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
289 provName = "openai"309 provName = "openai"
@@ -319,6 +339,14 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
319 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)339 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
320 }340 }
321 341
342+ // Persist --api-key for custom providers so subsequent runs don't
343+ // need to pass it again.
344+ if !isBuiltinProvider(provName) && args.APIKey != "" {
345+ if store := AuthStoreFor(); store != nil {
346+ _ = store.SetAPIKey(provName, args.APIKey)
347+ }
348+ }
349+
322 // If the user did NOT explicitly pick a provider and the default one350 // If the user did NOT explicitly pick a provider and the default one
323 // has no credentials, auto-fall-back to whichever provider is actually351 // has no credentials, auto-fall-back to whichever provider is actually
324 // logged in. That way running plain `zot` after `/login` (any provider)352 // logged in. That way running plain `zot` after `/login` (any provider)
@@ -374,6 +402,24 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
374 }402 }
375 err = nil403 err = nil
376 }404 }
405+ // Custom providers are open-catalogue like ollama: any model id the
406+ // endpoint understands is valid. Use the provider-level base URL.
407+ if err != nil {
408+ if cfg, ok := provider.CustomProviders()[provName]; ok {
409+ resolvedModel = provider.Model{
410+ Provider: provName,
411+ ID: model,
412+ DisplayName: model,
413+ BaseURL: cfg.BaseURL,
414+ Source: "user",
415+ }
416+ err = nil
417+ }
418+ } else if cfg, ok := provider.CustomProviders()[provName]; ok && cfg.BaseURL != "" {
419+ // Prefer the base URL configured in models.json over the
420+ // model base URL (which may be stale from live discovery).
421+ resolvedModel.BaseURL = cfg.BaseURL
422+ }
377 if err != nil {423 if err != nil {
378 // The model the user (or persisted config) asked for is no424 // The model the user (or persisted config) asked for is no
379 // longer in the active catalogue — they probably removed it425 // longer in the active catalogue — they probably removed it
@@ -712,6 +758,15 @@ func (r Resolved) NewClient() provider.Client {
712 case "cloudflare-ai-gateway":758 case "cloudflare-ai-gateway":
713 return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)759 return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
714 default:760 default:
761+ // Custom providers: choose wire format from the models.json api field.
762+ if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
763+ switch cfg.API {
764+ case "anthropic":
765+ return provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL)
766+ default: // "openai"
767+ return provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, "")
768+ }
769+ }
715 if r.AuthMethod == "oauth" {770 if r.AuthMethod == "oauth" {
716 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)771 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
717 return r.wrapWithRefresh(inner)772 return r.wrapWithRefresh(inner)
M packages/agent/cli.go +17 -0
@@ -214,6 +214,16 @@ func Run(rawArgs []string, version string) error {
214214 LoadCachedModels()
215215 LoadUserModels()
216216
217+ // Register custom provider names with the auth package so they
218+ // participate in /login API-key flow and provider pickers.
219+ if cps := provider.CustomProviders(); len(cps) > 0 {
220+ names := make([]string, 0, len(cps))
221+ for name := range cps {
222+ names = append(names, name)
223+ }
224+ auth.SetExtraAPIKeyProviders(names)
225+ }
226+
217227 // Repair config.json so a stale (provider, model) pair from an
218228 // interrupted /model switch can't strand the user with an
219229 // "unknown model" error on the first turn. Runs before any UI
@@ -979,6 +989,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
979989 seen[p] = true
980990 }
981991 }
992+ // Include custom providers that have credentials stored.
993+ for p := range provider.CustomProviders() {
994+ if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
995+ out = append(out, p)
996+ seen[p] = true
997+ }
998+ }
982999 // Ollama models are always available (no auth needed).
9831000 if !seen["ollama"] {
9841001 out = append(out, "ollama")
@@ -214,6 +214,16 @@ func Run(rawArgs []string, version string) error {
214 LoadCachedModels()214 LoadCachedModels()
215 LoadUserModels()215 LoadUserModels()
216 216
217+ // Register custom provider names with the auth package so they
218+ // participate in /login API-key flow and provider pickers.
219+ if cps := provider.CustomProviders(); len(cps) > 0 {
220+ names := make([]string, 0, len(cps))
221+ for name := range cps {
222+ names = append(names, name)
223+ }
224+ auth.SetExtraAPIKeyProviders(names)
225+ }
226+
217 // Repair config.json so a stale (provider, model) pair from an227 // Repair config.json so a stale (provider, model) pair from an
218 // interrupted /model switch can't strand the user with an228 // interrupted /model switch can't strand the user with an
219 // "unknown model" error on the first turn. Runs before any UI229 // "unknown model" error on the first turn. Runs before any UI
@@ -979,6 +989,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
979 seen[p] = true989 seen[p] = true
980 }990 }
981 }991 }
992+ // Include custom providers that have credentials stored.
993+ for p := range provider.CustomProviders() {
994+ if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
995+ out = append(out, p)
996+ seen[p] = true
997+ }
998+ }
982 // Ollama models are always available (no auth needed).999 // Ollama models are always available (no auth needed).
983 if !seen["ollama"] {1000 if !seen["ollama"] {
984 out = append(out, "ollama")1001 out = append(out, "ollama")
M packages/agent/config.go +16 -0
@@ -9,6 +9,7 @@ import (
99 "os"
1010 "path/filepath"
1111 "runtime"
12+ "strings"
1213 "time"
1314
1415 "github.com/patriceckhart/zot/packages/provider/auth"
@@ -299,6 +300,12 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
299300 return v, "apikey", "", nil
300301 }
301302 }
303+ // Generic env var fallback for custom providers: normalize the
304+ // provider id to a shell-friendly env var name (hyphens to
305+ // underscores) and check {NAME}_API_KEY before auth.json.
306+ if v := os.Getenv(normalizeCustomProviderEnvVar(provider) + "_API_KEY"); v != "" {
307+ return v, "apikey", "", nil
308+ }
302309 c, err := AuthStoreFor().Load()
303310 if err != nil {
304311 return "", "", "", err
@@ -380,6 +387,15 @@ func SetKimiCLIFallbackDisabled(disabled bool) error {
380387 return os.WriteFile(path, []byte("disabled\n"), 0o600)
381388 }
382389
390+// normalizeCustomProviderEnvVar converts a provider id such as
391+// "my-company" to "MY_COMPANY", matching the common convention for
392+// shell environment variables.
393+func normalizeCustomProviderEnvVar(provider string) string {
394+ provider = strings.ToUpper(provider)
395+ provider = strings.ReplaceAll(provider, "-", "_")
396+ return provider
397+}
398+
383399 func loadKimiCodeCLIToken() *auth.OAuthToken {
384400 home, err := os.UserHomeDir()
385401 if err != nil {
@@ -9,6 +9,7 @@ import (
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11 "runtime"11 "runtime"
12+ "strings"
12 "time"13 "time"
13 14
14 "github.com/patriceckhart/zot/packages/provider/auth"15 "github.com/patriceckhart/zot/packages/provider/auth"
@@ -299,6 +300,12 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
299 return v, "apikey", "", nil300 return v, "apikey", "", nil
300 }301 }
301 }302 }
303+ // Generic env var fallback for custom providers: normalize the
304+ // provider id to a shell-friendly env var name (hyphens to
305+ // underscores) and check {NAME}_API_KEY before auth.json.
306+ if v := os.Getenv(normalizeCustomProviderEnvVar(provider) + "_API_KEY"); v != "" {
307+ return v, "apikey", "", nil
308+ }
302 c, err := AuthStoreFor().Load()309 c, err := AuthStoreFor().Load()
303 if err != nil {310 if err != nil {
304 return "", "", "", err311 return "", "", "", err
@@ -380,6 +387,15 @@ func SetKimiCLIFallbackDisabled(disabled bool) error {
380 return os.WriteFile(path, []byte("disabled\n"), 0o600)387 return os.WriteFile(path, []byte("disabled\n"), 0o600)
381 }388 }
382 389
390+// normalizeCustomProviderEnvVar converts a provider id such as
391+// "my-company" to "MY_COMPANY", matching the common convention for
392+// shell environment variables.
393+func normalizeCustomProviderEnvVar(provider string) string {
394+ provider = strings.ToUpper(provider)
395+ provider = strings.ReplaceAll(provider, "-", "_")
396+ return provider
397+}
398+
383 func loadKimiCodeCLIToken() *auth.OAuthToken {399 func loadKimiCodeCLIToken() *auth.OAuthToken {
384 home, err := os.UserHomeDir()400 home, err := os.UserHomeDir()
385 if err != nil {401 if err != nil {
M packages/agent/modes/login_dialog.go +10 -5
@@ -233,17 +233,22 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
233233 }
234234
235235 // providersForMethod returns the providers offered for a given login
236-// method. API-key is the universal path so it lists every provider;
237-// subscription/OAuth only lists providers that actually issue tokens
238-// usable against the same API the model picker drives (Google's
239-// consumer Gemini Advanced login does not, and DeepSeek has no
240-// subscription product at all).
236+// method. API-key is the universal path so it lists every provider
237+// (including custom providers from models.json); subscription/OAuth
238+// only lists providers that actually issue tokens usable against the
239+// same API the model picker drives (Google's consumer Gemini Advanced
240+// login does not, and DeepSeek has no subscription product at all).
241241 func providersForMethod(method string) []string {
242242 var providers []string
243243 if method == "oauth" {
244244 providers = []string{"anthropic", "openai-codex", "kimi", "github-copilot"}
245245 } else {
246246 providers = auth.APIKeyProviders()
247+ // Append custom providers from models.json so they appear
248+ // in the /login picker alongside built-in providers.
249+ for name := range provider.CustomProviders() {
250+ providers = append(providers, name)
251+ }
247252 }
248253 sort.Slice(providers, func(a, b int) bool {
249254 return providerLabel(providers[a]) < providerLabel(providers[b])
@@ -233,17 +233,22 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
233 }233 }
234 234
235 // providersForMethod returns the providers offered for a given login235 // providersForMethod returns the providers offered for a given login
236-// method. API-key is the universal path so it lists every provider;236+// method. API-key is the universal path so it lists every provider
237-// subscription/OAuth only lists providers that actually issue tokens237+// (including custom providers from models.json); subscription/OAuth
238-// usable against the same API the model picker drives (Google's238+// only lists providers that actually issue tokens usable against the
239-// consumer Gemini Advanced login does not, and DeepSeek has no239+// same API the model picker drives (Google's consumer Gemini Advanced
240-// subscription product at all).240+// login does not, and DeepSeek has no subscription product at all).
241 func providersForMethod(method string) []string {241 func providersForMethod(method string) []string {
242 var providers []string242 var providers []string
243 if method == "oauth" {243 if method == "oauth" {
244 providers = []string{"anthropic", "openai-codex", "kimi", "github-copilot"}244 providers = []string{"anthropic", "openai-codex", "kimi", "github-copilot"}
245 } else {245 } else {
246 providers = auth.APIKeyProviders()246 providers = auth.APIKeyProviders()
247+ // Append custom providers from models.json so they appear
248+ // in the /login picker alongside built-in providers.
249+ for name := range provider.CustomProviders() {
250+ providers = append(providers, name)
251+ }
247 }252 }
248 sort.Slice(providers, func(a, b int) bool {253 sort.Slice(providers, func(a, b int) bool {
249 return providerLabel(providers[a]) < providerLabel(providers[b])254 return providerLabel(providers[a]) < providerLabel(providers[b])
M packages/provider/auth/server.go +17 -0
@@ -95,6 +95,18 @@ func (s *Server) Shutdown(ctx context.Context) error {
9595 return s.srv.Shutdown(ctx)
9696 }
9797
98+// extraAPIKeyProviders holds custom provider names registered at
99+// startup from models.json. These are accepted by the login flow
100+// alongside the built-in APIKeyProviders list.
101+var extraAPIKeyProviders []string
102+
103+// SetExtraAPIKeyProviders registers additional provider names that
104+// should be accepted by the API-key login flow. Call after loading user
105+// models so custom providers appear in the /login picker.
106+func SetExtraAPIKeyProviders(names []string) {
107+ extraAPIKeyProviders = names
108+}
109+
98110 // isKnownAPIKeyProvider reports whether the given provider supports
99111 // API-key login through the loopback flow. OAuth-only paths are handled
100112 // elsewhere (manager.StartOAuth).
@@ -104,6 +116,11 @@ func isKnownAPIKeyProvider(p string) bool {
104116 return true
105117 }
106118 }
119+ for _, name := range extraAPIKeyProviders {
120+ if p == name {
121+ return true
122+ }
123+ }
107124 return false
108125 }
109126
@@ -95,6 +95,18 @@ func (s *Server) Shutdown(ctx context.Context) error {
95 return s.srv.Shutdown(ctx)95 return s.srv.Shutdown(ctx)
96 }96 }
97 97
98+// extraAPIKeyProviders holds custom provider names registered at
99+// startup from models.json. These are accepted by the login flow
100+// alongside the built-in APIKeyProviders list.
101+var extraAPIKeyProviders []string
102+
103+// SetExtraAPIKeyProviders registers additional provider names that
104+// should be accepted by the API-key login flow. Call after loading user
105+// models so custom providers appear in the /login picker.
106+func SetExtraAPIKeyProviders(names []string) {
107+ extraAPIKeyProviders = names
108+}
109+
98 // isKnownAPIKeyProvider reports whether the given provider supports110 // isKnownAPIKeyProvider reports whether the given provider supports
99 // API-key login through the loopback flow. OAuth-only paths are handled111 // API-key login through the loopback flow. OAuth-only paths are handled
100 // elsewhere (manager.StartOAuth).112 // elsewhere (manager.StartOAuth).
@@ -104,6 +116,11 @@ func isKnownAPIKeyProvider(p string) bool {
104 return true116 return true
105 }117 }
106 }118 }
119+ for _, name := range extraAPIKeyProviders {
120+ if p == name {
121+ return true
122+ }
123+ }
107 return false124 return false
108 }125 }
109 126
M packages/provider/extra_providers.go +22 -21
@@ -27,8 +27,9 @@ import (
2727 // every one of them.
2828 // ----------------------------------------------------------------------
2929
30-// newOpenAICompat is the shared constructor for OpenAI-completions clones.
31-func newOpenAICompat(name, apiKey, baseURL, fallbackBaseURL string) Client {
30+// NewOpenAICompat is the shared constructor for OpenAI-completions clones.
31+// Exported so build.go can instantiate clients for user-defined providers.
32+func NewOpenAICompat(name, apiKey, baseURL, fallbackBaseURL string) Client {
3233 if baseURL == "" {
3334 baseURL = fallbackBaseURL
3435 }
@@ -43,48 +44,48 @@ func newOpenAICompat(name, apiKey, baseURL, fallbackBaseURL string) Client {
4344 // NewMoonshot is the global Moonshot AI endpoint (Kimi-K2 family by id).
4445 // Provider id is `moonshotai`.
4546 func NewMoonshot(apiKey, baseURL string) Client {
46- return newOpenAICompat("moonshotai", apiKey, baseURL, "https://api.moonshot.ai/v1")
47+ return NewOpenAICompat("moonshotai", apiKey, baseURL, "https://api.moonshot.ai/v1")
4748 }
4849
4950 // NewMoonshotCN is the China-region Moonshot endpoint. Same model ids as
5051 // the global flavor, different base URL.
5152 func NewMoonshotCN(apiKey, baseURL string) Client {
52- return newOpenAICompat("moonshotai-cn", apiKey, baseURL, "https://api.moonshot.cn/v1")
53+ return NewOpenAICompat("moonshotai-cn", apiKey, baseURL, "https://api.moonshot.cn/v1")
5354 }
5455
5556 // NewCerebras: ultra-fast inference (Llama/Qwen/GPT-OSS/GLM).
5657 func NewCerebras(apiKey, baseURL string) Client {
57- return newOpenAICompat("cerebras", apiKey, baseURL, "https://api.cerebras.ai/v1")
58+ return NewOpenAICompat("cerebras", apiKey, baseURL, "https://api.cerebras.ai/v1")
5859 }
5960
6061 // NewGroq: LPU inference (Llama/Kimi/Qwen/GPT-OSS).
6162 func NewGroq(apiKey, baseURL string) Client {
62- return newOpenAICompat("groq", apiKey, baseURL, "https://api.groq.com/openai/v1")
63+ return NewOpenAICompat("groq", apiKey, baseURL, "https://api.groq.com/openai/v1")
6364 }
6465
6566 // NewXAI: xAI Grok.
6667 func NewXAI(apiKey, baseURL string) Client {
67- return newOpenAICompat("xai", apiKey, baseURL, "https://api.x.ai/v1")
68+ return NewOpenAICompat("xai", apiKey, baseURL, "https://api.x.ai/v1")
6869 }
6970
7071 // NewTogether: Together.ai aggregator.
7172 func NewTogether(apiKey, baseURL string) Client {
72- return newOpenAICompat("together", apiKey, baseURL, "https://api.together.ai/v1")
73+ return NewOpenAICompat("together", apiKey, baseURL, "https://api.together.ai/v1")
7374 }
7475
7576 // NewHuggingFace: HF inference router.
7677 func NewHuggingFace(apiKey, baseURL string) Client {
77- return newOpenAICompat("huggingface", apiKey, baseURL, "https://router.huggingface.co/v1")
78+ return NewOpenAICompat("huggingface", apiKey, baseURL, "https://router.huggingface.co/v1")
7879 }
7980
8081 // NewZAI: Z.AI GLM family.
8182 func NewZAI(apiKey, baseURL string) Client {
82- return newOpenAICompat("zai", apiKey, baseURL, "https://api.z.ai/api/coding/paas/v4")
83+ return NewOpenAICompat("zai", apiKey, baseURL, "https://api.z.ai/api/coding/paas/v4")
8384 }
8485
8586 // NewXiaomi: Xiaomi MiMo family (default endpoint).
8687 func NewXiaomi(apiKey, baseURL string) Client {
87- return newOpenAICompat("xiaomi", apiKey, baseURL, "https://api.xiaomimimo.com/v1")
88+ return NewOpenAICompat("xiaomi", apiKey, baseURL, "https://api.xiaomimimo.com/v1")
8889 }
8990
9091 // NewXiaomiTokenPlan creates a regional Xiaomi token-plan client.
@@ -106,13 +107,13 @@ func NewXiaomiTokenPlan(region, apiKey, baseURL string) Client {
106107 default:
107108 panic(fmt.Sprintf("xiaomi token-plan: unknown region %q", region))
108109 }
109- return newOpenAICompat(name, apiKey, baseURL, fallback)
110+ return NewOpenAICompat(name, apiKey, baseURL, fallback)
110111 }
111112
112113 // NewOpenRouter: OpenRouter aggregator. Unlocks dozens of upstream
113114 // models with one key.
114115 func NewOpenRouter(apiKey, baseURL string) Client {
115- return newOpenAICompat("openrouter", apiKey, baseURL, openrouterDefaultBaseURL)
116+ return NewOpenAICompat("openrouter", apiKey, baseURL, openrouterDefaultBaseURL)
116117 }
117118
118119 // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this
@@ -120,37 +121,37 @@ func NewOpenRouter(apiKey, baseURL string) Client {
120121 // the anthropic-messages flavor under the same provider should be built
121122 // with NewAnthropicCompat against the same base URL.
122123 func NewOpenCode(apiKey, baseURL string) Client {
123- return newOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1")
124+ return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1")
124125 }
125126
126127 // NewOpenCodeGo is the opencode-go variant.
127128 func NewOpenCodeGo(apiKey, baseURL string) Client {
128- return newOpenAICompat("opencode-go", apiKey, baseURL, "https://opencode.ai/zen/go/v1")
129+ return NewOpenAICompat("opencode-go", apiKey, baseURL, "https://opencode.ai/zen/go/v1")
129130 }
130131
131132 // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case
132133 // downstream models switch from anthropic-messages. The main MiniMax route
133134 // uses anthropic-messages; see NewMinimaxAnthropic below.
134135 func NewMinimaxOpenAI(apiKey, baseURL string) Client {
135- return newOpenAICompat("minimax", apiKey, baseURL, "https://api.minimax.io/v1")
136+ return NewOpenAICompat("minimax", apiKey, baseURL, "https://api.minimax.io/v1")
136137 }
137138
138139 // NewMinimaxCNOpenAI is the CN-region MiniMax (openai-completions).
139140 func NewMinimaxCNOpenAI(apiKey, baseURL string) Client {
140- return newOpenAICompat("minimax-cn", apiKey, baseURL, "https://api.minimaxi.com/v1")
141+ return NewOpenAICompat("minimax-cn", apiKey, baseURL, "https://api.minimaxi.com/v1")
141142 }
142143
143144 // NewFireworksOpenAI is the OpenAI-completions flavor of Fireworks.
144145 // The main route uses the anthropic-messages variant on
145146 // api.fireworks.ai/inference; see NewFireworksAnthropic.
146147 func NewFireworksOpenAI(apiKey, baseURL string) Client {
147- return newOpenAICompat("fireworks", apiKey, baseURL, "https://api.fireworks.ai/inference/v1")
148+ return NewOpenAICompat("fireworks", apiKey, baseURL, "https://api.fireworks.ai/inference/v1")
148149 }
149150
150151 // NewVercelGatewayOpenAI is Vercel AI Gateway's OpenAI-compat shim. The
151152 // main route uses anthropic-messages; see NewVercelGatewayAnthropic.
152153 func NewVercelGatewayOpenAI(apiKey, baseURL string) Client {
153- return newOpenAICompat("vercel-ai-gateway", apiKey, baseURL, "https://ai-gateway.vercel.sh/v1")
154+ return NewOpenAICompat("vercel-ai-gateway", apiKey, baseURL, "https://ai-gateway.vercel.sh/v1")
154155 }
155156
156157 // ----------------------------------------------------------------------
@@ -268,7 +269,7 @@ func NewAzureOpenAIResponses(apiKey, baseURL string) Client {
268269 // the same models with tool calling and streaming, so we use that for
269270 // simplicity (no extra wire format to maintain).
270271 func NewMistral(apiKey, baseURL string) Client {
271- return newOpenAICompat("mistral", apiKey, baseURL, "https://api.mistral.ai/v1")
272+ return NewOpenAICompat("mistral", apiKey, baseURL, "https://api.mistral.ai/v1")
272273 }
273274
274275 // Cloudflare endpoints carry `{CLOUDFLARE_ACCOUNT_ID}` and (for the AI
@@ -306,7 +307,7 @@ func NewCloudflareWorkersAI(apiKey, baseURL string) Client {
306307 if err != nil {
307308 return &unimplementedClient{name: "cloudflare-workers-ai", hint: err.Error()}
308309 }
309- return newOpenAICompat("cloudflare-workers-ai", apiKey, resolved, "")
310+ return NewOpenAICompat("cloudflare-workers-ai", apiKey, resolved, "")
310311 }
311312
312313 // NewCloudflareAIGateway returns the AI Gateway client (OpenAI compat
@@ -27,8 +27,9 @@ import (
27 // every one of them.27 // every one of them.
28 // ----------------------------------------------------------------------28 // ----------------------------------------------------------------------
29 29
30-// newOpenAICompat is the shared constructor for OpenAI-completions clones.30+// NewOpenAICompat is the shared constructor for OpenAI-completions clones.
31-func newOpenAICompat(name, apiKey, baseURL, fallbackBaseURL string) Client {31+// Exported so build.go can instantiate clients for user-defined providers.
32+func NewOpenAICompat(name, apiKey, baseURL, fallbackBaseURL string) Client {
32 if baseURL == "" {33 if baseURL == "" {
33 baseURL = fallbackBaseURL34 baseURL = fallbackBaseURL
34 }35 }
@@ -43,48 +44,48 @@ func newOpenAICompat(name, apiKey, baseURL, fallbackBaseURL string) Client {
43 // NewMoonshot is the global Moonshot AI endpoint (Kimi-K2 family by id).44 // NewMoonshot is the global Moonshot AI endpoint (Kimi-K2 family by id).
44 // Provider id is `moonshotai`.45 // Provider id is `moonshotai`.
45 func NewMoonshot(apiKey, baseURL string) Client {46 func NewMoonshot(apiKey, baseURL string) Client {
46- return newOpenAICompat("moonshotai", apiKey, baseURL, "https://api.moonshot.ai/v1")47+ return NewOpenAICompat("moonshotai", apiKey, baseURL, "https://api.moonshot.ai/v1")
47 }48 }
48 49
49 // NewMoonshotCN is the China-region Moonshot endpoint. Same model ids as50 // NewMoonshotCN is the China-region Moonshot endpoint. Same model ids as
50 // the global flavor, different base URL.51 // the global flavor, different base URL.
51 func NewMoonshotCN(apiKey, baseURL string) Client {52 func NewMoonshotCN(apiKey, baseURL string) Client {
52- return newOpenAICompat("moonshotai-cn", apiKey, baseURL, "https://api.moonshot.cn/v1")53+ return NewOpenAICompat("moonshotai-cn", apiKey, baseURL, "https://api.moonshot.cn/v1")
53 }54 }
54 55
55 // NewCerebras: ultra-fast inference (Llama/Qwen/GPT-OSS/GLM).56 // NewCerebras: ultra-fast inference (Llama/Qwen/GPT-OSS/GLM).
56 func NewCerebras(apiKey, baseURL string) Client {57 func NewCerebras(apiKey, baseURL string) Client {
57- return newOpenAICompat("cerebras", apiKey, baseURL, "https://api.cerebras.ai/v1")58+ return NewOpenAICompat("cerebras", apiKey, baseURL, "https://api.cerebras.ai/v1")
58 }59 }
59 60
60 // NewGroq: LPU inference (Llama/Kimi/Qwen/GPT-OSS).61 // NewGroq: LPU inference (Llama/Kimi/Qwen/GPT-OSS).
61 func NewGroq(apiKey, baseURL string) Client {62 func NewGroq(apiKey, baseURL string) Client {
62- return newOpenAICompat("groq", apiKey, baseURL, "https://api.groq.com/openai/v1")63+ return NewOpenAICompat("groq", apiKey, baseURL, "https://api.groq.com/openai/v1")
63 }64 }
64 65
65 // NewXAI: xAI Grok.66 // NewXAI: xAI Grok.
66 func NewXAI(apiKey, baseURL string) Client {67 func NewXAI(apiKey, baseURL string) Client {
67- return newOpenAICompat("xai", apiKey, baseURL, "https://api.x.ai/v1")68+ return NewOpenAICompat("xai", apiKey, baseURL, "https://api.x.ai/v1")
68 }69 }
69 70
70 // NewTogether: Together.ai aggregator.71 // NewTogether: Together.ai aggregator.
71 func NewTogether(apiKey, baseURL string) Client {72 func NewTogether(apiKey, baseURL string) Client {
72- return newOpenAICompat("together", apiKey, baseURL, "https://api.together.ai/v1")73+ return NewOpenAICompat("together", apiKey, baseURL, "https://api.together.ai/v1")
73 }74 }
74 75
75 // NewHuggingFace: HF inference router.76 // NewHuggingFace: HF inference router.
76 func NewHuggingFace(apiKey, baseURL string) Client {77 func NewHuggingFace(apiKey, baseURL string) Client {
77- return newOpenAICompat("huggingface", apiKey, baseURL, "https://router.huggingface.co/v1")78+ return NewOpenAICompat("huggingface", apiKey, baseURL, "https://router.huggingface.co/v1")
78 }79 }
79 80
80 // NewZAI: Z.AI GLM family.81 // NewZAI: Z.AI GLM family.
81 func NewZAI(apiKey, baseURL string) Client {82 func NewZAI(apiKey, baseURL string) Client {
82- return newOpenAICompat("zai", apiKey, baseURL, "https://api.z.ai/api/coding/paas/v4")83+ return NewOpenAICompat("zai", apiKey, baseURL, "https://api.z.ai/api/coding/paas/v4")
83 }84 }
84 85
85 // NewXiaomi: Xiaomi MiMo family (default endpoint).86 // NewXiaomi: Xiaomi MiMo family (default endpoint).
86 func NewXiaomi(apiKey, baseURL string) Client {87 func NewXiaomi(apiKey, baseURL string) Client {
87- return newOpenAICompat("xiaomi", apiKey, baseURL, "https://api.xiaomimimo.com/v1")88+ return NewOpenAICompat("xiaomi", apiKey, baseURL, "https://api.xiaomimimo.com/v1")
88 }89 }
89 90
90 // NewXiaomiTokenPlan creates a regional Xiaomi token-plan client.91 // NewXiaomiTokenPlan creates a regional Xiaomi token-plan client.
@@ -106,13 +107,13 @@ func NewXiaomiTokenPlan(region, apiKey, baseURL string) Client {
106 default:107 default:
107 panic(fmt.Sprintf("xiaomi token-plan: unknown region %q", region))108 panic(fmt.Sprintf("xiaomi token-plan: unknown region %q", region))
108 }109 }
109- return newOpenAICompat(name, apiKey, baseURL, fallback)110+ return NewOpenAICompat(name, apiKey, baseURL, fallback)
110 }111 }
111 112
112 // NewOpenRouter: OpenRouter aggregator. Unlocks dozens of upstream113 // NewOpenRouter: OpenRouter aggregator. Unlocks dozens of upstream
113 // models with one key.114 // models with one key.
114 func NewOpenRouter(apiKey, baseURL string) Client {115 func NewOpenRouter(apiKey, baseURL string) Client {
115- return newOpenAICompat("openrouter", apiKey, baseURL, openrouterDefaultBaseURL)116+ return NewOpenAICompat("openrouter", apiKey, baseURL, openrouterDefaultBaseURL)
116 }117 }
117 118
118 // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this119 // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this
@@ -120,37 +121,37 @@ func NewOpenRouter(apiKey, baseURL string) Client {
120 // the anthropic-messages flavor under the same provider should be built121 // the anthropic-messages flavor under the same provider should be built
121 // with NewAnthropicCompat against the same base URL.122 // with NewAnthropicCompat against the same base URL.
122 func NewOpenCode(apiKey, baseURL string) Client {123 func NewOpenCode(apiKey, baseURL string) Client {
123- return newOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1")124+ return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1")
124 }125 }
125 126
126 // NewOpenCodeGo is the opencode-go variant.127 // NewOpenCodeGo is the opencode-go variant.
127 func NewOpenCodeGo(apiKey, baseURL string) Client {128 func NewOpenCodeGo(apiKey, baseURL string) Client {
128- return newOpenAICompat("opencode-go", apiKey, baseURL, "https://opencode.ai/zen/go/v1")129+ return NewOpenAICompat("opencode-go", apiKey, baseURL, "https://opencode.ai/zen/go/v1")
129 }130 }
130 131
131 // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case132 // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case
132 // downstream models switch from anthropic-messages. The main MiniMax route133 // downstream models switch from anthropic-messages. The main MiniMax route
133 // uses anthropic-messages; see NewMinimaxAnthropic below.134 // uses anthropic-messages; see NewMinimaxAnthropic below.
134 func NewMinimaxOpenAI(apiKey, baseURL string) Client {135 func NewMinimaxOpenAI(apiKey, baseURL string) Client {
135- return newOpenAICompat("minimax", apiKey, baseURL, "https://api.minimax.io/v1")136+ return NewOpenAICompat("minimax", apiKey, baseURL, "https://api.minimax.io/v1")
136 }137 }
137 138
138 // NewMinimaxCNOpenAI is the CN-region MiniMax (openai-completions).139 // NewMinimaxCNOpenAI is the CN-region MiniMax (openai-completions).
139 func NewMinimaxCNOpenAI(apiKey, baseURL string) Client {140 func NewMinimaxCNOpenAI(apiKey, baseURL string) Client {
140- return newOpenAICompat("minimax-cn", apiKey, baseURL, "https://api.minimaxi.com/v1")141+ return NewOpenAICompat("minimax-cn", apiKey, baseURL, "https://api.minimaxi.com/v1")
141 }142 }
142 143
143 // NewFireworksOpenAI is the OpenAI-completions flavor of Fireworks.144 // NewFireworksOpenAI is the OpenAI-completions flavor of Fireworks.
144 // The main route uses the anthropic-messages variant on145 // The main route uses the anthropic-messages variant on
145 // api.fireworks.ai/inference; see NewFireworksAnthropic.146 // api.fireworks.ai/inference; see NewFireworksAnthropic.
146 func NewFireworksOpenAI(apiKey, baseURL string) Client {147 func NewFireworksOpenAI(apiKey, baseURL string) Client {
147- return newOpenAICompat("fireworks", apiKey, baseURL, "https://api.fireworks.ai/inference/v1")148+ return NewOpenAICompat("fireworks", apiKey, baseURL, "https://api.fireworks.ai/inference/v1")
148 }149 }
149 150
150 // NewVercelGatewayOpenAI is Vercel AI Gateway's OpenAI-compat shim. The151 // NewVercelGatewayOpenAI is Vercel AI Gateway's OpenAI-compat shim. The
151 // main route uses anthropic-messages; see NewVercelGatewayAnthropic.152 // main route uses anthropic-messages; see NewVercelGatewayAnthropic.
152 func NewVercelGatewayOpenAI(apiKey, baseURL string) Client {153 func NewVercelGatewayOpenAI(apiKey, baseURL string) Client {
153- return newOpenAICompat("vercel-ai-gateway", apiKey, baseURL, "https://ai-gateway.vercel.sh/v1")154+ return NewOpenAICompat("vercel-ai-gateway", apiKey, baseURL, "https://ai-gateway.vercel.sh/v1")
154 }155 }
155 156
156 // ----------------------------------------------------------------------157 // ----------------------------------------------------------------------
@@ -268,7 +269,7 @@ func NewAzureOpenAIResponses(apiKey, baseURL string) Client {
268 // the same models with tool calling and streaming, so we use that for269 // the same models with tool calling and streaming, so we use that for
269 // simplicity (no extra wire format to maintain).270 // simplicity (no extra wire format to maintain).
270 func NewMistral(apiKey, baseURL string) Client {271 func NewMistral(apiKey, baseURL string) Client {
271- return newOpenAICompat("mistral", apiKey, baseURL, "https://api.mistral.ai/v1")272+ return NewOpenAICompat("mistral", apiKey, baseURL, "https://api.mistral.ai/v1")
272 }273 }
273 274
274 // Cloudflare endpoints carry `{CLOUDFLARE_ACCOUNT_ID}` and (for the AI275 // Cloudflare endpoints carry `{CLOUDFLARE_ACCOUNT_ID}` and (for the AI
@@ -306,7 +307,7 @@ func NewCloudflareWorkersAI(apiKey, baseURL string) Client {
306 if err != nil {307 if err != nil {
307 return &unimplementedClient{name: "cloudflare-workers-ai", hint: err.Error()}308 return &unimplementedClient{name: "cloudflare-workers-ai", hint: err.Error()}
308 }309 }
309- return newOpenAICompat("cloudflare-workers-ai", apiKey, resolved, "")310+ return NewOpenAICompat("cloudflare-workers-ai", apiKey, resolved, "")
310 }311 }
311 312
312 // NewCloudflareAIGateway returns the AI Gateway client (OpenAI compat313 // NewCloudflareAIGateway returns the AI Gateway client (OpenAI compat
M packages/provider/usermodels.go +64 -14
@@ -8,23 +8,21 @@ import (
88
99 // UserModelsFile is the JSON format for user-defined models.
1010 // Place a models.json in $ZOT_HOME to add models that aren't in the
11-// baked-in catalog or to override catalog entries.
12-//
13-// Example:
11+// baked-in catalog or to override catalog entries. Custom providers
12+// (not in the built-in set) may specify a baseUrl and api format at
13+// the provider level:
1414 //
1515 // {
1616 // "providers": {
17-// "openai": {
17+// "my-company": {
18+// "baseUrl": "https://llm.mycompany.com/v1",
19+// "api": "openai",
1820 // "models": [
1921 // {
20-// "id": "gpt-5.5",
21-// "name": "GPT-5.5",
22-// "reasoning": true,
23-// "contextWindow": 400000,
24-// "maxTokens": 128000,
25-// "priceInput": 2.50,
26-// "priceOutput": 15.00,
27-// "priceCacheRead": 0.25
22+// "id": "company-llm-v2",
23+// "name": "Company LLM v2",
24+// "contextWindow": 128000,
25+// "maxTokens": 32000
2826 // }
2927 // ]
3028 // }
@@ -36,9 +34,25 @@ type UserModelsFile struct {
3634
3735 // UserProvider groups models under a provider key.
3836 type UserProvider struct {
39- Models []UserModel `json:"models"`
37+ BaseURL string `json:"baseUrl,omitempty"`
38+ API string `json:"api,omitempty"` // "openai" (default) or "anthropic"
39+ Models []UserModel `json:"models"`
40+}
41+
42+// CustomProviderConfig holds runtime config for a user-defined provider
43+// that isn't part of the built-in catalog.
44+type CustomProviderConfig struct {
45+ BaseURL string
46+ API string // "openai" or "anthropic"
4047 }
4148
49+var customProviders = map[string]CustomProviderConfig{}
50+
51+// CustomProviders returns the set of user-defined providers loaded from
52+// models.json. Keys are provider names; values carry the base URL and
53+// wire-format hint.
54+func CustomProviders() map[string]CustomProviderConfig { return customProviders }
55+
4256 // UserModel is a single model entry in the user's models.json.
4357 type UserModel struct {
4458 ID string `json:"id"`
@@ -83,6 +97,8 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
8397 }
8498
8599 var out []Model
100+ // Reset custom providers on each load so removed entries don't linger.
101+ customProviders = map[string]CustomProviderConfig{}
86102 for providerName, prov := range file.Providers {
87103 if providerName == "" {
88104 warnings = append(warnings, "models.json: empty provider key skipped")
@@ -101,6 +117,29 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
101117 normalized = "deepseek"
102118 }
103119
120+ // Register custom providers: any provider with a baseUrl or
121+ // an api format that isn't the built-in "openai"/"anthropic"
122+ // alias is treated as user-defined. Also catch providers whose
123+ // name doesn't match a known provider (detected later in
124+ // build.go's isKnownProvider check).
125+ if prov.BaseURL != "" || prov.API != "" {
126+ api := prov.API
127+ if api == "" {
128+ api = "openai"
129+ }
130+ // Normalize common aliases for the wire format.
131+ switch api {
132+ case "openai-completions", "openai-chat", "chat", "openai":
133+ api = "openai"
134+ case "anthropic-messages", "messages", "anthropic":
135+ api = "anthropic"
136+ }
137+ customProviders[normalized] = CustomProviderConfig{
138+ BaseURL: prov.BaseURL,
139+ API: api,
140+ }
141+ }
142+
104143 for i, um := range prov.Models {
105144 if um.ID == "" {
106145 warnings = append(warnings, fmt.Sprintf("models.json: provider %q entry #%d has empty id; skipped", providerName, i))
@@ -115,6 +154,11 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
115154 um.MaxTokens = 0
116155 }
117156 }
157+ // Propagate provider-level BaseURL to models without their own.
158+ modelBaseURL := um.BaseURL
159+ if modelBaseURL == "" {
160+ modelBaseURL = prov.BaseURL
161+ }
118162 m := Model{
119163 Provider: normalized,
120164 ID: um.ID,
@@ -126,7 +170,7 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
126170 PriceOutput: um.PriceOutput,
127171 PriceCacheRead: um.PriceCacheRead,
128172 PriceCacheWrite: um.PriceCacheWrite,
129- BaseURL: um.BaseURL,
173+ BaseURL: modelBaseURL,
130174 Source: "user",
131175 }
132176 if m.DisplayName == "" {
@@ -148,6 +192,12 @@ func SetUserModels(models []Model) {
148192 activeMu.Lock()
149193 defer activeMu.Unlock()
150194
195+ // Ensure the active overlay is initialized so Active() reads from
196+ // it instead of falling back to the static Catalog.
197+ if !activeSet {
198+ activeSet = true
199+ }
200+
151201 // Build index of current active models.
152202 byKey := func(p, id string) string { return p + "/" + id }
153203 index := make(map[string]int, len(active))
@@ -8,23 +8,21 @@ import (
8 8
9 // UserModelsFile is the JSON format for user-defined models.9 // UserModelsFile is the JSON format for user-defined models.
10 // Place a models.json in $ZOT_HOME to add models that aren't in the10 // Place a models.json in $ZOT_HOME to add models that aren't in the
11-// baked-in catalog or to override catalog entries.11+// baked-in catalog or to override catalog entries. Custom providers
12-//12+// (not in the built-in set) may specify a baseUrl and api format at
13-// Example:13+// the provider level:
14 //14 //
15 // {15 // {
16 // "providers": {16 // "providers": {
17-// "openai": {17+// "my-company": {
18+// "baseUrl": "https://llm.mycompany.com/v1",
19+// "api": "openai",
18 // "models": [20 // "models": [
19 // {21 // {
20-// "id": "gpt-5.5",22+// "id": "company-llm-v2",
21-// "name": "GPT-5.5",23+// "name": "Company LLM v2",
22-// "reasoning": true,24+// "contextWindow": 128000,
23-// "contextWindow": 400000,25+// "maxTokens": 32000
24-// "maxTokens": 128000,
25-// "priceInput": 2.50,
26-// "priceOutput": 15.00,
27-// "priceCacheRead": 0.25
28 // }26 // }
29 // ]27 // ]
30 // }28 // }
@@ -36,9 +34,25 @@ type UserModelsFile struct {
36 34
37 // UserProvider groups models under a provider key.35 // UserProvider groups models under a provider key.
38 type UserProvider struct {36 type UserProvider struct {
39- Models []UserModel `json:"models"`37+ BaseURL string `json:"baseUrl,omitempty"`
38+ API string `json:"api,omitempty"` // "openai" (default) or "anthropic"
39+ Models []UserModel `json:"models"`
40+}
41+
42+// CustomProviderConfig holds runtime config for a user-defined provider
43+// that isn't part of the built-in catalog.
44+type CustomProviderConfig struct {
45+ BaseURL string
46+ API string // "openai" or "anthropic"
40 }47 }
41 48
49+var customProviders = map[string]CustomProviderConfig{}
50+
51+// CustomProviders returns the set of user-defined providers loaded from
52+// models.json. Keys are provider names; values carry the base URL and
53+// wire-format hint.
54+func CustomProviders() map[string]CustomProviderConfig { return customProviders }
55+
42 // UserModel is a single model entry in the user's models.json.56 // UserModel is a single model entry in the user's models.json.
43 type UserModel struct {57 type UserModel struct {
44 ID string `json:"id"`58 ID string `json:"id"`
@@ -83,6 +97,8 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
83 }97 }
84 98
85 var out []Model99 var out []Model
100+ // Reset custom providers on each load so removed entries don't linger.
101+ customProviders = map[string]CustomProviderConfig{}
86 for providerName, prov := range file.Providers {102 for providerName, prov := range file.Providers {
87 if providerName == "" {103 if providerName == "" {
88 warnings = append(warnings, "models.json: empty provider key skipped")104 warnings = append(warnings, "models.json: empty provider key skipped")
@@ -101,6 +117,29 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
101 normalized = "deepseek"117 normalized = "deepseek"
102 }118 }
103 119
120+ // Register custom providers: any provider with a baseUrl or
121+ // an api format that isn't the built-in "openai"/"anthropic"
122+ // alias is treated as user-defined. Also catch providers whose
123+ // name doesn't match a known provider (detected later in
124+ // build.go's isKnownProvider check).
125+ if prov.BaseURL != "" || prov.API != "" {
126+ api := prov.API
127+ if api == "" {
128+ api = "openai"
129+ }
130+ // Normalize common aliases for the wire format.
131+ switch api {
132+ case "openai-completions", "openai-chat", "chat", "openai":
133+ api = "openai"
134+ case "anthropic-messages", "messages", "anthropic":
135+ api = "anthropic"
136+ }
137+ customProviders[normalized] = CustomProviderConfig{
138+ BaseURL: prov.BaseURL,
139+ API: api,
140+ }
141+ }
142+
104 for i, um := range prov.Models {143 for i, um := range prov.Models {
105 if um.ID == "" {144 if um.ID == "" {
106 warnings = append(warnings, fmt.Sprintf("models.json: provider %q entry #%d has empty id; skipped", providerName, i))145 warnings = append(warnings, fmt.Sprintf("models.json: provider %q entry #%d has empty id; skipped", providerName, i))
@@ -115,6 +154,11 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
115 um.MaxTokens = 0154 um.MaxTokens = 0
116 }155 }
117 }156 }
157+ // Propagate provider-level BaseURL to models without their own.
158+ modelBaseURL := um.BaseURL
159+ if modelBaseURL == "" {
160+ modelBaseURL = prov.BaseURL
161+ }
118 m := Model{162 m := Model{
119 Provider: normalized,163 Provider: normalized,
120 ID: um.ID,164 ID: um.ID,
@@ -126,7 +170,7 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
126 PriceOutput: um.PriceOutput,170 PriceOutput: um.PriceOutput,
127 PriceCacheRead: um.PriceCacheRead,171 PriceCacheRead: um.PriceCacheRead,
128 PriceCacheWrite: um.PriceCacheWrite,172 PriceCacheWrite: um.PriceCacheWrite,
129- BaseURL: um.BaseURL,173+ BaseURL: modelBaseURL,
130 Source: "user",174 Source: "user",
131 }175 }
132 if m.DisplayName == "" {176 if m.DisplayName == "" {
@@ -148,6 +192,12 @@ func SetUserModels(models []Model) {
148 activeMu.Lock()192 activeMu.Lock()
149 defer activeMu.Unlock()193 defer activeMu.Unlock()
150 194
195+ // Ensure the active overlay is initialized so Active() reads from
196+ // it instead of falling back to the static Catalog.
197+ if !activeSet {
198+ activeSet = true
199+ }
200+
151 // Build index of current active models.201 // Build index of current active models.
152 byKey := func(p, id string) string { return p + "/" + id }202 byKey := func(p, id string) string { return p + "/" + id }
153 index := make(map[string]int, len(active))203 index := make(map[string]int, len(active))

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