detached 1dc884a

Merge pull request #36 from pulyankote/feat/custom-llm-providers

Feature: implement pluggable custom LLM providers via JSON config
commit
1dc884a676dbc44726a9a3a4cf3a855a361ce049
author
Patric Eckhart · · 2026-06-16 18:20 UTC
committer
GitHub · · 2026-06-16 18:20 UTC
merge of
1cc654e + ac7da16 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.35
11 files · +393 -42
M packages/agent/build.go +56 -1
@@ -192,6 +192,11 @@ func defaultModelForProvider(prov string) string {
192192 case "github-copilot":
193193 return "claude-sonnet-4.5"
194194 default:
195+ // Custom providers: pick the first model from the catalog for
196+ // that provider, or fall back to the global default.
197+ if models := provider.ModelsForProvider(prov); len(models) > 0 {
198+ return models[0].ID
199+ }
195200 return provider.DefaultModel.ID
196201 }
197202 }
@@ -214,6 +219,18 @@ var knownProviders = []string{
214219 }
215220
216221 func isKnownProvider(name string) bool {
222+ for _, p := range knownProviders {
223+ if p == name {
224+ return true
225+ }
226+ }
227+ _, ok := provider.CustomProviders()[name]
228+ return ok
229+}
230+
231+// isBuiltinProvider reports whether name is in the hardcoded
232+// knownProviders list (not a user-defined custom provider).
233+func isBuiltinProvider(name string) bool {
217234 for _, p := range knownProviders {
218235 if p == name {
219236 return true
@@ -285,6 +302,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
285302 if !isKnownProvider(provName) {
286303 // Unknown provider (maybe removed or renamed). Fall back to
287304 // the first provider that has credentials, or anthropic.
305+ // Custom providers (from models.json) are already accepted
306+ // by isKnownProvider, so we only reach here for truly unknown
307+ // names.
288308 provName = "anthropic"
289309 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
290310 provName = "openai"
@@ -320,6 +340,14 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
320340 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
321341 }
322342
343+ // Persist --api-key for custom providers so subsequent runs don't
344+ // need to pass it again.
345+ if !isBuiltinProvider(provName) && args.APIKey != "" {
346+ if store := AuthStoreFor(); store != nil {
347+ _ = store.SetAPIKey(provName, args.APIKey)
348+ }
349+ }
350+
323351 // If the user did NOT explicitly pick a provider and the default one
324352 // has no credentials, auto-fall-back to whichever provider is actually
325353 // logged in. That way running plain `zot` after `/login` (any provider)
@@ -375,6 +403,24 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
375403 }
376404 err = nil
377405 }
406+ // Custom providers are open-catalogue like ollama: any model id the
407+ // endpoint understands is valid. Use the provider-level base URL.
408+ if err != nil {
409+ if cfg, ok := provider.CustomProviders()[provName]; ok {
410+ resolvedModel = provider.Model{
411+ Provider: provName,
412+ ID: model,
413+ DisplayName: model,
414+ BaseURL: cfg.BaseURL,
415+ Source: "user",
416+ }
417+ err = nil
418+ }
419+ } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
420+ // Fall back to the provider-level base URL when the model does
421+ // not define its own endpoint.
422+ resolvedModel.BaseURL = cfg.BaseURL
423+ }
378424 if err != nil {
379425 // The model the user (or persisted config) asked for is no
380426 // longer in the active catalogue — they probably removed it
@@ -410,7 +456,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
410456 model = fm.ID
411457 }
412458
413- explicitBaseURL := args.BaseURL != ""
459+ explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
414460
415461 // If the model defines a base URL (e.g. local ollama) and the
416462 // user didn't pass --base-url, use the model's URL. For ollama,
@@ -722,6 +768,15 @@ func (r Resolved) NewClient() provider.Client {
722768 case "cloudflare-ai-gateway":
723769 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
724770 default:
771+ // Custom providers: choose wire format from the models.json api field.
772+ if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
773+ switch cfg.API {
774+ case "anthropic":
775+ return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
776+ default: // "openai"
777+ return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
778+ }
779+ }
725780 if r.AuthMethod == "oauth" {
726781 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
727782 return r.wrapWithRefresh(inner)
@@ -192,6 +192,11 @@ func defaultModelForProvider(prov string) string {
192 case "github-copilot":192 case "github-copilot":
193 return "claude-sonnet-4.5"193 return "claude-sonnet-4.5"
194 default:194 default:
195+ // Custom providers: pick the first model from the catalog for
196+ // that provider, or fall back to the global default.
197+ if models := provider.ModelsForProvider(prov); len(models) > 0 {
198+ return models[0].ID
199+ }
195 return provider.DefaultModel.ID200 return provider.DefaultModel.ID
196 }201 }
197 }202 }
@@ -214,6 +219,18 @@ var knownProviders = []string{
214 }219 }
215 220
216 func isKnownProvider(name string) bool {221 func isKnownProvider(name string) bool {
222+ for _, p := range knownProviders {
223+ if p == name {
224+ return true
225+ }
226+ }
227+ _, ok := provider.CustomProviders()[name]
228+ return ok
229+}
230+
231+// isBuiltinProvider reports whether name is in the hardcoded
232+// knownProviders list (not a user-defined custom provider).
233+func isBuiltinProvider(name string) bool {
217 for _, p := range knownProviders {234 for _, p := range knownProviders {
218 if p == name {235 if p == name {
219 return true236 return true
@@ -285,6 +302,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
285 if !isKnownProvider(provName) {302 if !isKnownProvider(provName) {
286 // Unknown provider (maybe removed or renamed). Fall back to303 // Unknown provider (maybe removed or renamed). Fall back to
287 // the first provider that has credentials, or anthropic.304 // the first provider that has credentials, or anthropic.
305+ // Custom providers (from models.json) are already accepted
306+ // by isKnownProvider, so we only reach here for truly unknown
307+ // names.
288 provName = "anthropic"308 provName = "anthropic"
289 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {309 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
290 provName = "openai"310 provName = "openai"
@@ -320,6 +340,14 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
320 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)340 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
321 }341 }
322 342
343+ // Persist --api-key for custom providers so subsequent runs don't
344+ // need to pass it again.
345+ if !isBuiltinProvider(provName) && args.APIKey != "" {
346+ if store := AuthStoreFor(); store != nil {
347+ _ = store.SetAPIKey(provName, args.APIKey)
348+ }
349+ }
350+
323 // If the user did NOT explicitly pick a provider and the default one351 // If the user did NOT explicitly pick a provider and the default one
324 // has no credentials, auto-fall-back to whichever provider is actually352 // has no credentials, auto-fall-back to whichever provider is actually
325 // logged in. That way running plain `zot` after `/login` (any provider)353 // logged in. That way running plain `zot` after `/login` (any provider)
@@ -375,6 +403,24 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
375 }403 }
376 err = nil404 err = nil
377 }405 }
406+ // Custom providers are open-catalogue like ollama: any model id the
407+ // endpoint understands is valid. Use the provider-level base URL.
408+ if err != nil {
409+ if cfg, ok := provider.CustomProviders()[provName]; ok {
410+ resolvedModel = provider.Model{
411+ Provider: provName,
412+ ID: model,
413+ DisplayName: model,
414+ BaseURL: cfg.BaseURL,
415+ Source: "user",
416+ }
417+ err = nil
418+ }
419+ } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
420+ // Fall back to the provider-level base URL when the model does
421+ // not define its own endpoint.
422+ resolvedModel.BaseURL = cfg.BaseURL
423+ }
378 if err != nil {424 if err != nil {
379 // The model the user (or persisted config) asked for is no425 // The model the user (or persisted config) asked for is no
380 // longer in the active catalogue — they probably removed it426 // longer in the active catalogue — they probably removed it
@@ -410,7 +456,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
410 model = fm.ID456 model = fm.ID
411 }457 }
412 458
413- explicitBaseURL := args.BaseURL != ""459+ explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
414 460
415 // If the model defines a base URL (e.g. local ollama) and the461 // If the model defines a base URL (e.g. local ollama) and the
416 // user didn't pass --base-url, use the model's URL. For ollama,462 // user didn't pass --base-url, use the model's URL. For ollama,
@@ -722,6 +768,15 @@ func (r Resolved) NewClient() provider.Client {
722 case "cloudflare-ai-gateway":768 case "cloudflare-ai-gateway":
723 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))769 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
724 default:770 default:
771+ // Custom providers: choose wire format from the models.json api field.
772+ if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
773+ switch cfg.API {
774+ case "anthropic":
775+ return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
776+ default: // "openai"
777+ return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
778+ }
779+ }
725 if r.AuthMethod == "oauth" {780 if r.AuthMethod == "oauth" {
726 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))781 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
727 return r.wrapWithRefresh(inner)782 return r.wrapWithRefresh(inner)
M packages/agent/build_test.go +66 -0
@@ -165,6 +165,72 @@ func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
165165 }
166166 }
167167
168+func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
169+ t.Setenv("ZOT_HOME", t.TempDir())
170+ t.Setenv("MY_COMPANY_API_KEY", "test-key")
171+ path := filepath.Join(t.TempDir(), "models.json")
172+ if err := os.WriteFile(path, []byte(`{
173+ "providers": {
174+ "my-company": {
175+ "baseUrl": "https://provider.example.com/v1",
176+ "api": "openai",
177+ "models": [
178+ {"id": "fast", "baseUrl": "https://model.example.com/v1"}
179+ ]
180+ }
181+ }
182+ }`), 0o644); err != nil {
183+ t.Fatal(err)
184+ }
185+ models, warnings := provider.LoadUserModelsWithWarnings(path)
186+ if len(warnings) != 0 {
187+ t.Fatalf("warnings = %v", warnings)
188+ }
189+ provider.SetLiveModels(nil)
190+ provider.SetUserModels(models)
191+ t.Cleanup(func() { provider.SetLiveModels(nil) })
192+
193+ r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
194+ if err != nil {
195+ t.Fatalf("Resolve failed: %v", err)
196+ }
197+ if r.BaseURL != "https://model.example.com/v1" {
198+ t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
199+ }
200+}
201+
202+func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
203+ t.Setenv("ZOT_HOME", t.TempDir())
204+ t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
205+ path := filepath.Join(t.TempDir(), "models.json")
206+ if err := os.WriteFile(path, []byte(`{
207+ "providers": {
208+ "local-proxy": {
209+ "baseUrl": "https://proxy.example.com/v1",
210+ "api": "openai",
211+ "models": [{"id": "default"}]
212+ }
213+ }
214+ }`), 0o644); err != nil {
215+ t.Fatal(err)
216+ }
217+ models, warnings := provider.LoadUserModelsWithWarnings(path)
218+ if len(warnings) != 0 {
219+ t.Fatalf("warnings = %v", warnings)
220+ }
221+ provider.SetLiveModels(nil)
222+ provider.SetUserModels(models)
223+ t.Cleanup(func() { provider.SetLiveModels(nil) })
224+
225+ r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
226+ if err != nil {
227+ t.Fatalf("Resolve failed: %v", err)
228+ }
229+ if !r.InsecureTLS {
230+ t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
231+ }
232+}
233+
168234 func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
169235 t.Setenv("ZOT_HOME", t.TempDir())
170236 provider.SetLiveModels(nil)
@@ -165,6 +165,72 @@ func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
165 }165 }
166 }166 }
167 167
168+func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
169+ t.Setenv("ZOT_HOME", t.TempDir())
170+ t.Setenv("MY_COMPANY_API_KEY", "test-key")
171+ path := filepath.Join(t.TempDir(), "models.json")
172+ if err := os.WriteFile(path, []byte(`{
173+ "providers": {
174+ "my-company": {
175+ "baseUrl": "https://provider.example.com/v1",
176+ "api": "openai",
177+ "models": [
178+ {"id": "fast", "baseUrl": "https://model.example.com/v1"}
179+ ]
180+ }
181+ }
182+ }`), 0o644); err != nil {
183+ t.Fatal(err)
184+ }
185+ models, warnings := provider.LoadUserModelsWithWarnings(path)
186+ if len(warnings) != 0 {
187+ t.Fatalf("warnings = %v", warnings)
188+ }
189+ provider.SetLiveModels(nil)
190+ provider.SetUserModels(models)
191+ t.Cleanup(func() { provider.SetLiveModels(nil) })
192+
193+ r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
194+ if err != nil {
195+ t.Fatalf("Resolve failed: %v", err)
196+ }
197+ if r.BaseURL != "https://model.example.com/v1" {
198+ t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
199+ }
200+}
201+
202+func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
203+ t.Setenv("ZOT_HOME", t.TempDir())
204+ t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
205+ path := filepath.Join(t.TempDir(), "models.json")
206+ if err := os.WriteFile(path, []byte(`{
207+ "providers": {
208+ "local-proxy": {
209+ "baseUrl": "https://proxy.example.com/v1",
210+ "api": "openai",
211+ "models": [{"id": "default"}]
212+ }
213+ }
214+ }`), 0o644); err != nil {
215+ t.Fatal(err)
216+ }
217+ models, warnings := provider.LoadUserModelsWithWarnings(path)
218+ if len(warnings) != 0 {
219+ t.Fatalf("warnings = %v", warnings)
220+ }
221+ provider.SetLiveModels(nil)
222+ provider.SetUserModels(models)
223+ t.Cleanup(func() { provider.SetLiveModels(nil) })
224+
225+ r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
226+ if err != nil {
227+ t.Fatalf("Resolve failed: %v", err)
228+ }
229+ if !r.InsecureTLS {
230+ t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
231+ }
232+}
233+
168 func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {234 func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
169 t.Setenv("ZOT_HOME", t.TempDir())235 t.Setenv("ZOT_HOME", t.TempDir())
170 provider.SetLiveModels(nil)236 provider.SetLiveModels(nil)
M packages/agent/cli.go +19 -0
@@ -214,6 +214,18 @@ 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+ if !isBuiltinProvider(name) {
223+ names = append(names, name)
224+ }
225+ }
226+ auth.SetExtraAPIKeyProviders(names)
227+ }
228+
217229 // Repair config.json so a stale (provider, model) pair from an
218230 // interrupted /model switch can't strand the user with an
219231 // "unknown model" error on the first turn. Runs before any UI
@@ -979,6 +991,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
979991 seen[p] = true
980992 }
981993 }
994+ // Include custom providers that have credentials stored.
995+ for p := range provider.CustomProviders() {
996+ if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
997+ out = append(out, p)
998+ seen[p] = true
999+ }
1000+ }
9821001 // Ollama models are always available (no auth needed).
9831002 if !seen["ollama"] {
9841003 out = append(out, "ollama")
@@ -214,6 +214,18 @@ 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+ if !isBuiltinProvider(name) {
223+ names = append(names, name)
224+ }
225+ }
226+ auth.SetExtraAPIKeyProviders(names)
227+ }
228+
217 // Repair config.json so a stale (provider, model) pair from an229 // Repair config.json so a stale (provider, model) pair from an
218 // interrupted /model switch can't strand the user with an230 // interrupted /model switch can't strand the user with an
219 // "unknown model" error on the first turn. Runs before any UI231 // "unknown model" error on the first turn. Runs before any UI
@@ -979,6 +991,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
979 seen[p] = true991 seen[p] = true
980 }992 }
981 }993 }
994+ // Include custom providers that have credentials stored.
995+ for p := range provider.CustomProviders() {
996+ if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
997+ out = append(out, p)
998+ seen[p] = true
999+ }
1000+ }
982 // Ollama models are always available (no auth needed).1001 // Ollama models are always available (no auth needed).
983 if !seen["ollama"] {1002 if !seen["ollama"] {
984 out = append(out, "ollama")1003 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"
@@ -302,6 +303,12 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
302303 return v, "apikey", "", nil
303304 }
304305 }
306+ // Generic env var fallback for custom providers: normalize the
307+ // provider id to a shell-friendly env var name (hyphens to
308+ // underscores) and check {NAME}_API_KEY before auth.json.
309+ if v := os.Getenv(normalizeCustomProviderEnvVar(provider) + "_API_KEY"); v != "" {
310+ return v, "apikey", "", nil
311+ }
305312 c, err := AuthStoreFor().Load()
306313 if err != nil {
307314 return "", "", "", err
@@ -383,6 +390,15 @@ func SetKimiCLIFallbackDisabled(disabled bool) error {
383390 return os.WriteFile(path, []byte("disabled\n"), 0o600)
384391 }
385392
393+// normalizeCustomProviderEnvVar converts a provider id such as
394+// "my-company" to "MY_COMPANY", matching the common convention for
395+// shell environment variables.
396+func normalizeCustomProviderEnvVar(provider string) string {
397+ provider = strings.ToUpper(provider)
398+ provider = strings.ReplaceAll(provider, "-", "_")
399+ return provider
400+}
401+
386402 func loadKimiCodeCLIToken() *auth.OAuthToken {
387403 home, err := os.UserHomeDir()
388404 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"
@@ -302,6 +303,12 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
302 return v, "apikey", "", nil303 return v, "apikey", "", nil
303 }304 }
304 }305 }
306+ // Generic env var fallback for custom providers: normalize the
307+ // provider id to a shell-friendly env var name (hyphens to
308+ // underscores) and check {NAME}_API_KEY before auth.json.
309+ if v := os.Getenv(normalizeCustomProviderEnvVar(provider) + "_API_KEY"); v != "" {
310+ return v, "apikey", "", nil
311+ }
305 c, err := AuthStoreFor().Load()312 c, err := AuthStoreFor().Load()
306 if err != nil {313 if err != nil {
307 return "", "", "", err314 return "", "", "", err
@@ -383,6 +390,15 @@ func SetKimiCLIFallbackDisabled(disabled bool) error {
383 return os.WriteFile(path, []byte("disabled\n"), 0o600)390 return os.WriteFile(path, []byte("disabled\n"), 0o600)
384 }391 }
385 392
393+// normalizeCustomProviderEnvVar converts a provider id such as
394+// "my-company" to "MY_COMPANY", matching the common convention for
395+// shell environment variables.
396+func normalizeCustomProviderEnvVar(provider string) string {
397+ provider = strings.ToUpper(provider)
398+ provider = strings.ReplaceAll(provider, "-", "_")
399+ return provider
400+}
401+
386 func loadKimiCodeCLIToken() *auth.OAuthToken {402 func loadKimiCodeCLIToken() *auth.OAuthToken {
387 home, err := os.UserHomeDir()403 home, err := os.UserHomeDir()
388 if err != nil {404 if err != nil {
M packages/agent/modes/login_dialog.go +18 -5
@@ -233,17 +233,30 @@ 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. Dedupe in
249+ // case models.json also adds metadata for a built-in provider.
250+ seen := map[string]bool{}
251+ for _, name := range providers {
252+ seen[name] = true
253+ }
254+ for name := range provider.CustomProviders() {
255+ if !seen[name] {
256+ providers = append(providers, name)
257+ seen[name] = true
258+ }
259+ }
247260 }
248261 sort.Slice(providers, func(a, b int) bool {
249262 return providerLabel(providers[a]) < providerLabel(providers[b])
@@ -233,17 +233,30 @@ 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. Dedupe in
249+ // case models.json also adds metadata for a built-in provider.
250+ seen := map[string]bool{}
251+ for _, name := range providers {
252+ seen[name] = true
253+ }
254+ for name := range provider.CustomProviders() {
255+ if !seen[name] {
256+ providers = append(providers, name)
257+ seen[name] = true
258+ }
259+ }
247 }260 }
248 sort.Slice(providers, func(a, b int) bool {261 sort.Slice(providers, func(a, b int) bool {
249 return providerLabel(providers[a]) < providerLabel(providers[b])262 return providerLabel(providers[a]) < providerLabel(providers[b])
M packages/provider/auth/auth_test.go +9 -0
@@ -11,6 +11,15 @@ import (
1111 "time"
1212 )
1313
14+func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {
15+ SetExtraAPIKeyProviders([]string{"my-company"})
16+ t.Cleanup(func() { SetExtraAPIKeyProviders(nil) })
17+
18+ if err := ProbeAPIKey(context.Background(), "my-company", "test-key"); err != nil {
19+ t.Fatalf("ProbeAPIKey custom provider: %v", err)
20+ }
21+}
22+
1423 func TestStoreRoundTrip(t *testing.T) {
1524 dir := t.TempDir()
1625 s := NewStore(filepath.Join(dir, "auth.json"))
@@ -11,6 +11,15 @@ import (
11 "time"11 "time"
12 )12 )
13 13
14+func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {
15+ SetExtraAPIKeyProviders([]string{"my-company"})
16+ t.Cleanup(func() { SetExtraAPIKeyProviders(nil) })
17+
18+ if err := ProbeAPIKey(context.Background(), "my-company", "test-key"); err != nil {
19+ t.Fatalf("ProbeAPIKey custom provider: %v", err)
20+ }
21+}
22+
14 func TestStoreRoundTrip(t *testing.T) {23 func TestStoreRoundTrip(t *testing.T) {
15 dir := t.TempDir()24 dir := t.TempDir()
16 s := NewStore(filepath.Join(dir, "auth.json"))25 s := NewStore(filepath.Join(dir, "auth.json"))
M packages/provider/auth/probe.go +7 -0
@@ -188,6 +188,13 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
188188 case "github-copilot":
189189 return nil
190190 default:
191+ // Custom providers are registered with the auth package at startup
192+ // from models.json. Some self-hosted or enterprise gateways do not
193+ // expose a model-list endpoint, so accept and store the key without
194+ // probing just like Bedrock/Azure.
195+ if isExtraAPIKeyProvider(provider) {
196+ return nil
197+ }
191198 return fmt.Errorf("unknown provider %q", provider)
192199 }
193200
@@ -188,6 +188,13 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
188 case "github-copilot":188 case "github-copilot":
189 return nil189 return nil
190 default:190 default:
191+ // Custom providers are registered with the auth package at startup
192+ // from models.json. Some self-hosted or enterprise gateways do not
193+ // expose a model-list endpoint, so accept and store the key without
194+ // probing just like Bedrock/Azure.
195+ if isExtraAPIKeyProvider(provider) {
196+ return nil
197+ }
191 return fmt.Errorf("unknown provider %q", provider)198 return fmt.Errorf("unknown provider %q", provider)
192 }199 }
193 200
M packages/provider/auth/server.go +22 -1
@@ -95,6 +95,27 @@ 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+
110+func isExtraAPIKeyProvider(p string) bool {
111+ for _, name := range extraAPIKeyProviders {
112+ if p == name {
113+ return true
114+ }
115+ }
116+ return false
117+}
118+
98119 // isKnownAPIKeyProvider reports whether the given provider supports
99120 // API-key login through the loopback flow. OAuth-only paths are handled
100121 // elsewhere (manager.StartOAuth).
@@ -104,7 +125,7 @@ func isKnownAPIKeyProvider(p string) bool {
104125 return true
105126 }
106127 }
107- return false
128+ return isExtraAPIKeyProvider(p)
108129 }
109130
110131 func apiKeyProviderMessage() string {
@@ -95,6 +95,27 @@ 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+
110+func isExtraAPIKeyProvider(p string) bool {
111+ for _, name := range extraAPIKeyProviders {
112+ if p == name {
113+ return true
114+ }
115+ }
116+ return false
117+}
118+
98 // isKnownAPIKeyProvider reports whether the given provider supports119 // isKnownAPIKeyProvider reports whether the given provider supports
99 // API-key login through the loopback flow. OAuth-only paths are handled120 // API-key login through the loopback flow. OAuth-only paths are handled
100 // elsewhere (manager.StartOAuth).121 // elsewhere (manager.StartOAuth).
@@ -104,7 +125,7 @@ func isKnownAPIKeyProvider(p string) bool {
104 return true125 return true
105 }126 }
106 }127 }
107- return false128+ return isExtraAPIKeyProvider(p)
108 }129 }
109 130
110 func apiKeyProviderMessage() string {131 func apiKeyProviderMessage() string {
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 +76 -14
@@ -4,27 +4,26 @@ import (
44 "encoding/json"
55 "fmt"
66 "os"
7+ "strings"
78 )
89
910 // UserModelsFile is the JSON format for user-defined models.
1011 // 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:
12+// baked-in catalog or to override catalog entries. Custom providers
13+// (not in the built-in set) may specify a baseUrl and api format at
14+// the provider level:
1415 //
1516 // {
1617 // "providers": {
17-// "openai": {
18+// "my-company": {
19+// "baseUrl": "https://llm.mycompany.com/v1",
20+// "api": "openai",
1821 // "models": [
1922 // {
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
23+// "id": "company-llm-v2",
24+// "name": "Company LLM v2",
25+// "contextWindow": 128000,
26+// "maxTokens": 32000
2827 // }
2928 // ]
3029 // }
@@ -36,9 +35,25 @@ type UserModelsFile struct {
3635
3736 // UserProvider groups models under a provider key.
3837 type UserProvider struct {
39- Models []UserModel `json:"models"`
38+ BaseURL string `json:"baseUrl,omitempty"`
39+ API string `json:"api,omitempty"` // "openai" (default) or "anthropic"
40+ Models []UserModel `json:"models"`
41+}
42+
43+// CustomProviderConfig holds runtime config for a user-defined provider
44+// that isn't part of the built-in catalog.
45+type CustomProviderConfig struct {
46+ BaseURL string
47+ API string // "openai" or "anthropic"
4048 }
4149
50+var customProviders = map[string]CustomProviderConfig{}
51+
52+// CustomProviders returns the set of user-defined providers loaded from
53+// models.json. Keys are provider names; values carry the base URL and
54+// wire-format hint.
55+func CustomProviders() map[string]CustomProviderConfig { return customProviders }
56+
4257 // UserModel is a single model entry in the user's models.json.
4358 type UserModel struct {
4459 ID string `json:"id"`
@@ -83,6 +98,8 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
8398 }
8499
85100 var out []Model
101+ // Reset custom providers on each load so removed entries don't linger.
102+ customProviders = map[string]CustomProviderConfig{}
86103 for providerName, prov := range file.Providers {
87104 if providerName == "" {
88105 warnings = append(warnings, "models.json: empty provider key skipped")
@@ -101,6 +118,38 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
101118 normalized = "deepseek"
102119 }
103120
121+ // Register custom providers that carry endpoint metadata either at
122+ // the provider level or on any model. Model-level baseUrl-only
123+ // configs still need a custom provider entry so Resolve/NewClient
124+ // accept the provider name and choose a wire format.
125+ hasModelBaseURL := false
126+ for _, um := range prov.Models {
127+ if um.BaseURL != "" {
128+ hasModelBaseURL = true
129+ break
130+ }
131+ }
132+ if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {
133+ api := strings.ToLower(strings.TrimSpace(prov.API))
134+ if api == "" {
135+ api = "openai"
136+ }
137+ // Normalize common aliases for the wire format.
138+ switch api {
139+ case "openai-completions", "openai-chat", "chat", "openai":
140+ api = "openai"
141+ case "anthropic-messages", "messages", "anthropic":
142+ api = "anthropic"
143+ default:
144+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown api %q; defaulting to openai", providerName, prov.API))
145+ api = "openai"
146+ }
147+ customProviders[normalized] = CustomProviderConfig{
148+ BaseURL: prov.BaseURL,
149+ API: api,
150+ }
151+ }
152+
104153 for i, um := range prov.Models {
105154 if um.ID == "" {
106155 warnings = append(warnings, fmt.Sprintf("models.json: provider %q entry #%d has empty id; skipped", providerName, i))
@@ -115,6 +164,11 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
115164 um.MaxTokens = 0
116165 }
117166 }
167+ // Propagate provider-level BaseURL to models without their own.
168+ modelBaseURL := um.BaseURL
169+ if modelBaseURL == "" {
170+ modelBaseURL = prov.BaseURL
171+ }
118172 m := Model{
119173 Provider: normalized,
120174 ID: um.ID,
@@ -126,7 +180,7 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
126180 PriceOutput: um.PriceOutput,
127181 PriceCacheRead: um.PriceCacheRead,
128182 PriceCacheWrite: um.PriceCacheWrite,
129- BaseURL: um.BaseURL,
183+ BaseURL: modelBaseURL,
130184 Source: "user",
131185 }
132186 if m.DisplayName == "" {
@@ -148,6 +202,14 @@ func SetUserModels(models []Model) {
148202 activeMu.Lock()
149203 defer activeMu.Unlock()
150204
205+ // Ensure the active overlay starts from the full built-in catalog
206+ // when no live/cache overlay has been applied. Otherwise a fresh
207+ // install with only models.json would hide every built-in model.
208+ if !activeSet || active == nil {
209+ active = append([]Model(nil), Catalog...)
210+ activeSet = true
211+ }
212+
151213 // Build index of current active models.
152214 byKey := func(p, id string) string { return p + "/" + id }
153215 index := make(map[string]int, len(active))
@@ -4,27 +4,26 @@ import (
4 "encoding/json"4 "encoding/json"
5 "fmt"5 "fmt"
6 "os"6 "os"
7+ "strings"
7 )8 )
8 9
9 // UserModelsFile is the JSON format for user-defined models.10 // UserModelsFile is the JSON format for user-defined models.
10 // Place a models.json in $ZOT_HOME to add models that aren't in the11 // 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+// baked-in catalog or to override catalog entries. Custom providers
12-//13+// (not in the built-in set) may specify a baseUrl and api format at
13-// Example:14+// the provider level:
14 //15 //
15 // {16 // {
16 // "providers": {17 // "providers": {
17-// "openai": {18+// "my-company": {
19+// "baseUrl": "https://llm.mycompany.com/v1",
20+// "api": "openai",
18 // "models": [21 // "models": [
19 // {22 // {
20-// "id": "gpt-5.5",23+// "id": "company-llm-v2",
21-// "name": "GPT-5.5",24+// "name": "Company LLM v2",
22-// "reasoning": true,25+// "contextWindow": 128000,
23-// "contextWindow": 400000,26+// "maxTokens": 32000
24-// "maxTokens": 128000,
25-// "priceInput": 2.50,
26-// "priceOutput": 15.00,
27-// "priceCacheRead": 0.25
28 // }27 // }
29 // ]28 // ]
30 // }29 // }
@@ -36,9 +35,25 @@ type UserModelsFile struct {
36 35
37 // UserProvider groups models under a provider key.36 // UserProvider groups models under a provider key.
38 type UserProvider struct {37 type UserProvider struct {
39- Models []UserModel `json:"models"`38+ BaseURL string `json:"baseUrl,omitempty"`
39+ API string `json:"api,omitempty"` // "openai" (default) or "anthropic"
40+ Models []UserModel `json:"models"`
41+}
42+
43+// CustomProviderConfig holds runtime config for a user-defined provider
44+// that isn't part of the built-in catalog.
45+type CustomProviderConfig struct {
46+ BaseURL string
47+ API string // "openai" or "anthropic"
40 }48 }
41 49
50+var customProviders = map[string]CustomProviderConfig{}
51+
52+// CustomProviders returns the set of user-defined providers loaded from
53+// models.json. Keys are provider names; values carry the base URL and
54+// wire-format hint.
55+func CustomProviders() map[string]CustomProviderConfig { return customProviders }
56+
42 // UserModel is a single model entry in the user's models.json.57 // UserModel is a single model entry in the user's models.json.
43 type UserModel struct {58 type UserModel struct {
44 ID string `json:"id"`59 ID string `json:"id"`
@@ -83,6 +98,8 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
83 }98 }
84 99
85 var out []Model100 var out []Model
101+ // Reset custom providers on each load so removed entries don't linger.
102+ customProviders = map[string]CustomProviderConfig{}
86 for providerName, prov := range file.Providers {103 for providerName, prov := range file.Providers {
87 if providerName == "" {104 if providerName == "" {
88 warnings = append(warnings, "models.json: empty provider key skipped")105 warnings = append(warnings, "models.json: empty provider key skipped")
@@ -101,6 +118,38 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
101 normalized = "deepseek"118 normalized = "deepseek"
102 }119 }
103 120
121+ // Register custom providers that carry endpoint metadata either at
122+ // the provider level or on any model. Model-level baseUrl-only
123+ // configs still need a custom provider entry so Resolve/NewClient
124+ // accept the provider name and choose a wire format.
125+ hasModelBaseURL := false
126+ for _, um := range prov.Models {
127+ if um.BaseURL != "" {
128+ hasModelBaseURL = true
129+ break
130+ }
131+ }
132+ if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {
133+ api := strings.ToLower(strings.TrimSpace(prov.API))
134+ if api == "" {
135+ api = "openai"
136+ }
137+ // Normalize common aliases for the wire format.
138+ switch api {
139+ case "openai-completions", "openai-chat", "chat", "openai":
140+ api = "openai"
141+ case "anthropic-messages", "messages", "anthropic":
142+ api = "anthropic"
143+ default:
144+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown api %q; defaulting to openai", providerName, prov.API))
145+ api = "openai"
146+ }
147+ customProviders[normalized] = CustomProviderConfig{
148+ BaseURL: prov.BaseURL,
149+ API: api,
150+ }
151+ }
152+
104 for i, um := range prov.Models {153 for i, um := range prov.Models {
105 if um.ID == "" {154 if um.ID == "" {
106 warnings = append(warnings, fmt.Sprintf("models.json: provider %q entry #%d has empty id; skipped", providerName, i))155 warnings = append(warnings, fmt.Sprintf("models.json: provider %q entry #%d has empty id; skipped", providerName, i))
@@ -115,6 +164,11 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
115 um.MaxTokens = 0164 um.MaxTokens = 0
116 }165 }
117 }166 }
167+ // Propagate provider-level BaseURL to models without their own.
168+ modelBaseURL := um.BaseURL
169+ if modelBaseURL == "" {
170+ modelBaseURL = prov.BaseURL
171+ }
118 m := Model{172 m := Model{
119 Provider: normalized,173 Provider: normalized,
120 ID: um.ID,174 ID: um.ID,
@@ -126,7 +180,7 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
126 PriceOutput: um.PriceOutput,180 PriceOutput: um.PriceOutput,
127 PriceCacheRead: um.PriceCacheRead,181 PriceCacheRead: um.PriceCacheRead,
128 PriceCacheWrite: um.PriceCacheWrite,182 PriceCacheWrite: um.PriceCacheWrite,
129- BaseURL: um.BaseURL,183+ BaseURL: modelBaseURL,
130 Source: "user",184 Source: "user",
131 }185 }
132 if m.DisplayName == "" {186 if m.DisplayName == "" {
@@ -148,6 +202,14 @@ func SetUserModels(models []Model) {
148 activeMu.Lock()202 activeMu.Lock()
149 defer activeMu.Unlock()203 defer activeMu.Unlock()
150 204
205+ // Ensure the active overlay starts from the full built-in catalog
206+ // when no live/cache overlay has been applied. Otherwise a fresh
207+ // install with only models.json would hide every built-in model.
208+ if !activeSet || active == nil {
209+ active = append([]Model(nil), Catalog...)
210+ activeSet = true
211+ }
212+
151 // Build index of current active models.213 // Build index of current active models.
152 byKey := func(p, id string) string { return p + "/" + id }214 byKey := func(p, id string) string { return p + "/" + id }
153 index := make(map[string]int, len(active))215 index := make(map[string]int, len(active))
A packages/provider/usermodels_test.go +82 -0
@@ -0,0 +1,82 @@
1+package provider
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestSetUserModelsPreservesCatalogWithoutLiveOverlay(t *testing.T) {
11+ SetLiveModels(nil)
12+ t.Cleanup(func() { SetLiveModels(nil) })
13+
14+ SetUserModels([]Model{{
15+ Provider: "custom-test",
16+ ID: "custom-model",
17+ DisplayName: "Custom Model",
18+ Source: "user",
19+ }})
20+
21+ if _, err := FindModel("anthropic", "claude-sonnet-4-5"); err != nil {
22+ t.Fatalf("built-in model hidden after SetUserModels: %v", err)
23+ }
24+ if _, err := FindModel("custom-test", "custom-model"); err != nil {
25+ t.Fatalf("custom model missing after SetUserModels: %v", err)
26+ }
27+}
28+
29+func TestLoadUserModelsRegistersModelLevelBaseURLCustomProvider(t *testing.T) {
30+ dir := t.TempDir()
31+ path := filepath.Join(dir, "models.json")
32+ if err := os.WriteFile(path, []byte(`{
33+ "providers": {
34+ "model-base-only": {
35+ "models": [
36+ {"id": "m1", "baseUrl": "https://llm.example.com/v1"}
37+ ]
38+ }
39+ }
40+ }`), 0o644); err != nil {
41+ t.Fatal(err)
42+ }
43+
44+ models, warnings := LoadUserModelsWithWarnings(path)
45+ if len(warnings) != 0 {
46+ t.Fatalf("warnings = %v, want none", warnings)
47+ }
48+ if len(models) != 1 {
49+ t.Fatalf("models = %d, want 1", len(models))
50+ }
51+ cfg, ok := CustomProviders()["model-base-only"]
52+ if !ok {
53+ t.Fatal("custom provider was not registered")
54+ }
55+ if cfg.API != "openai" {
56+ t.Fatalf("api = %q, want openai", cfg.API)
57+ }
58+}
59+
60+func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {
61+ dir := t.TempDir()
62+ path := filepath.Join(dir, "models.json")
63+ if err := os.WriteFile(path, []byte(`{
64+ "providers": {
65+ "bad-api": {
66+ "baseUrl": "https://llm.example.com/v1",
67+ "api": "anthropic-message",
68+ "models": [{"id": "m1"}]
69+ }
70+ }
71+ }`), 0o644); err != nil {
72+ t.Fatal(err)
73+ }
74+
75+ _, warnings := LoadUserModelsWithWarnings(path)
76+ if len(warnings) != 1 || !strings.Contains(warnings[0], "unknown api") {
77+ t.Fatalf("warnings = %v, want unknown api warning", warnings)
78+ }
79+ if cfg := CustomProviders()["bad-api"]; cfg.API != "openai" {
80+ t.Fatalf("api = %q, want openai", cfg.API)
81+ }
82+}
@@ -0,0 +1,82 @@
1+package provider
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestSetUserModelsPreservesCatalogWithoutLiveOverlay(t *testing.T) {
11+ SetLiveModels(nil)
12+ t.Cleanup(func() { SetLiveModels(nil) })
13+
14+ SetUserModels([]Model{{
15+ Provider: "custom-test",
16+ ID: "custom-model",
17+ DisplayName: "Custom Model",
18+ Source: "user",
19+ }})
20+
21+ if _, err := FindModel("anthropic", "claude-sonnet-4-5"); err != nil {
22+ t.Fatalf("built-in model hidden after SetUserModels: %v", err)
23+ }
24+ if _, err := FindModel("custom-test", "custom-model"); err != nil {
25+ t.Fatalf("custom model missing after SetUserModels: %v", err)
26+ }
27+}
28+
29+func TestLoadUserModelsRegistersModelLevelBaseURLCustomProvider(t *testing.T) {
30+ dir := t.TempDir()
31+ path := filepath.Join(dir, "models.json")
32+ if err := os.WriteFile(path, []byte(`{
33+ "providers": {
34+ "model-base-only": {
35+ "models": [
36+ {"id": "m1", "baseUrl": "https://llm.example.com/v1"}
37+ ]
38+ }
39+ }
40+ }`), 0o644); err != nil {
41+ t.Fatal(err)
42+ }
43+
44+ models, warnings := LoadUserModelsWithWarnings(path)
45+ if len(warnings) != 0 {
46+ t.Fatalf("warnings = %v, want none", warnings)
47+ }
48+ if len(models) != 1 {
49+ t.Fatalf("models = %d, want 1", len(models))
50+ }
51+ cfg, ok := CustomProviders()["model-base-only"]
52+ if !ok {
53+ t.Fatal("custom provider was not registered")
54+ }
55+ if cfg.API != "openai" {
56+ t.Fatalf("api = %q, want openai", cfg.API)
57+ }
58+}
59+
60+func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {
61+ dir := t.TempDir()
62+ path := filepath.Join(dir, "models.json")
63+ if err := os.WriteFile(path, []byte(`{
64+ "providers": {
65+ "bad-api": {
66+ "baseUrl": "https://llm.example.com/v1",
67+ "api": "anthropic-message",
68+ "models": [{"id": "m1"}]
69+ }
70+ }
71+ }`), 0o644); err != nil {
72+ t.Fatal(err)
73+ }
74+
75+ _, warnings := LoadUserModelsWithWarnings(path)
76+ if len(warnings) != 1 || !strings.Contains(warnings[0], "unknown api") {
77+ t.Fatalf("warnings = %v, want unknown api warning", warnings)
78+ }
79+ if cfg := CustomProviders()["bad-api"]; cfg.API != "openai" {
80+ t.Fatalf("api = %q, want openai", cfg.API)
81+ }
82+}

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