detached ac7da16

Harden custom provider support

Keep built-in models visible when merging models.json, accept custom provider API keys through the login flow, preserve model-level base URLs, and route custom clients through the scoped HTTP wrapper.

Also register providers from model-level baseUrl metadata, warn on unknown api values, dedupe login picker entries, and cover the custom-provider behavior with regression tests.

Co-authored-by: pulyankote <4314305+pulyankote@users.noreply.github.com>
commit
ac7da16dd5d909d85b95b179a1835bb21ae63db1
author
patriceckhart · · 2026-06-16 18:17 UTC
parent
4570db2
signature
unsigned
9 files · +215 -25
M packages/agent/build.go +6 -6
@@ -416,9 +416,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
416416 }
417417 err = nil
418418 }
419- } else if cfg, ok := provider.CustomProviders()[provName]; ok && cfg.BaseURL != "" {
420- // Prefer the base URL configured in models.json over the
421- // model base URL (which may be stale from live discovery).
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.
422422 resolvedModel.BaseURL = cfg.BaseURL
423423 }
424424 if err != nil {
@@ -456,7 +456,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
456456 model = fm.ID
457457 }
458458
459- explicitBaseURL := args.BaseURL != ""
459+ explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
460460
461461 // If the model defines a base URL (e.g. local ollama) and the
462462 // user didn't pass --base-url, use the model's URL. For ollama,
@@ -772,9 +772,9 @@ func (r Resolved) NewClient() provider.Client {
772772 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
773773 switch cfg.API {
774774 case "anthropic":
775- return provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL)
775+ return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
776776 default: // "openai"
777- return provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, "")
777+ return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
778778 }
779779 }
780780 if r.AuthMethod == "oauth" {
@@ -416,9 +416,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
416 }416 }
417 err = nil417 err = nil
418 }418 }
419- } else if cfg, ok := provider.CustomProviders()[provName]; ok && cfg.BaseURL != "" {419+ } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
420- // Prefer the base URL configured in models.json over the420+ // Fall back to the provider-level base URL when the model does
421- // model base URL (which may be stale from live discovery).421+ // not define its own endpoint.
422 resolvedModel.BaseURL = cfg.BaseURL422 resolvedModel.BaseURL = cfg.BaseURL
423 }423 }
424 if err != nil {424 if err != nil {
@@ -456,7 +456,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
456 model = fm.ID456 model = fm.ID
457 }457 }
458 458
459- explicitBaseURL := args.BaseURL != ""459+ explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
460 460
461 // 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
462 // 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,
@@ -772,9 +772,9 @@ func (r Resolved) NewClient() provider.Client {
772 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {772 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
773 switch cfg.API {773 switch cfg.API {
774 case "anthropic":774 case "anthropic":
775- return provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL)775+ return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
776 default: // "openai"776 default: // "openai"
777- return provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, "")777+ return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
778 }778 }
779 }779 }
780 if r.AuthMethod == "oauth" {780 if r.AuthMethod == "oauth" {
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 +3 -1
@@ -219,7 +219,9 @@ func Run(rawArgs []string, version string) error {
219219 if cps := provider.CustomProviders(); len(cps) > 0 {
220220 names := make([]string, 0, len(cps))
221221 for name := range cps {
222- names = append(names, name)
222+ if !isBuiltinProvider(name) {
223+ names = append(names, name)
224+ }
223225 }
224226 auth.SetExtraAPIKeyProviders(names)
225227 }
@@ -219,7 +219,9 @@ func Run(rawArgs []string, version string) error {
219 if cps := provider.CustomProviders(); len(cps) > 0 {219 if cps := provider.CustomProviders(); len(cps) > 0 {
220 names := make([]string, 0, len(cps))220 names := make([]string, 0, len(cps))
221 for name := range cps {221 for name := range cps {
222- names = append(names, name)222+ if !isBuiltinProvider(name) {
223+ names = append(names, name)
224+ }
223 }225 }
224 auth.SetExtraAPIKeyProviders(names)226 auth.SetExtraAPIKeyProviders(names)
225 }227 }
M packages/agent/modes/login_dialog.go +10 -2
@@ -245,9 +245,17 @@ func providersForMethod(method string) []string {
245245 } else {
246246 providers = auth.APIKeyProviders()
247247 // Append custom providers from models.json so they appear
248- // in the /login picker alongside built-in providers.
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+ }
249254 for name := range provider.CustomProviders() {
250- providers = append(providers, name)
255+ if !seen[name] {
256+ providers = append(providers, name)
257+ seen[name] = true
258+ }
251259 }
252260 }
253261 sort.Slice(providers, func(a, b int) bool {
@@ -245,9 +245,17 @@ func providersForMethod(method string) []string {
245 } else {245 } else {
246 providers = auth.APIKeyProviders()246 providers = auth.APIKeyProviders()
247 // Append custom providers from models.json so they appear247 // Append custom providers from models.json so they appear
248- // in the /login picker alongside built-in providers.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+ }
249 for name := range provider.CustomProviders() {254 for name := range provider.CustomProviders() {
250- providers = append(providers, name)255+ if !seen[name] {
256+ providers = append(providers, name)
257+ seen[name] = true
258+ }
251 }259 }
252 }260 }
253 sort.Slice(providers, func(a, b int) bool {261 sort.Slice(providers, func(a, b int) bool {
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 +10 -6
@@ -107,6 +107,15 @@ func SetExtraAPIKeyProviders(names []string) {
107107 extraAPIKeyProviders = names
108108 }
109109
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+
110119 // isKnownAPIKeyProvider reports whether the given provider supports
111120 // API-key login through the loopback flow. OAuth-only paths are handled
112121 // elsewhere (manager.StartOAuth).
@@ -116,12 +125,7 @@ func isKnownAPIKeyProvider(p string) bool {
116125 return true
117126 }
118127 }
119- for _, name := range extraAPIKeyProviders {
120- if p == name {
121- return true
122- }
123- }
124- return false
128+ return isExtraAPIKeyProvider(p)
125129 }
126130
127131 func apiKeyProviderMessage() string {
@@ -107,6 +107,15 @@ func SetExtraAPIKeyProviders(names []string) {
107 extraAPIKeyProviders = names107 extraAPIKeyProviders = names
108 }108 }
109 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+
110 // isKnownAPIKeyProvider reports whether the given provider supports119 // isKnownAPIKeyProvider reports whether the given provider supports
111 // API-key login through the loopback flow. OAuth-only paths are handled120 // API-key login through the loopback flow. OAuth-only paths are handled
112 // elsewhere (manager.StartOAuth).121 // elsewhere (manager.StartOAuth).
@@ -116,12 +125,7 @@ func isKnownAPIKeyProvider(p string) bool {
116 return true125 return true
117 }126 }
118 }127 }
119- for _, name := range extraAPIKeyProviders {128+ return isExtraAPIKeyProvider(p)
120- if p == name {
121- return true
122- }
123- }
124- return false
125 }129 }
126 130
127 func apiKeyProviderMessage() string {131 func apiKeyProviderMessage() string {
M packages/provider/usermodels.go +22 -10
@@ -4,6 +4,7 @@ import (
44 "encoding/json"
55 "fmt"
66 "os"
7+ "strings"
78 )
89
910 // UserModelsFile is the JSON format for user-defined models.
@@ -117,13 +118,19 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
117118 normalized = "deepseek"
118119 }
119120
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
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))
127134 if api == "" {
128135 api = "openai"
129136 }
@@ -133,6 +140,9 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
133140 api = "openai"
134141 case "anthropic-messages", "messages", "anthropic":
135142 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"
136146 }
137147 customProviders[normalized] = CustomProviderConfig{
138148 BaseURL: prov.BaseURL,
@@ -192,9 +202,11 @@ func SetUserModels(models []Model) {
192202 activeMu.Lock()
193203 defer activeMu.Unlock()
194204
195- // Ensure the active overlay is initialized so Active() reads from
196- // it instead of falling back to the static Catalog.
197- if !activeSet {
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...)
198210 activeSet = true
199211 }
200212
@@ -4,6 +4,7 @@ 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.
@@ -117,13 +118,19 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
117 normalized = "deepseek"118 normalized = "deepseek"
118 }119 }
119 120
120- // Register custom providers: any provider with a baseUrl or121+ // Register custom providers that carry endpoint metadata either at
121- // an api format that isn't the built-in "openai"/"anthropic"122+ // the provider level or on any model. Model-level baseUrl-only
122- // alias is treated as user-defined. Also catch providers whose123+ // configs still need a custom provider entry so Resolve/NewClient
123- // name doesn't match a known provider (detected later in124+ // accept the provider name and choose a wire format.
124- // build.go's isKnownProvider check).125+ hasModelBaseURL := false
125- if prov.BaseURL != "" || prov.API != "" {126+ for _, um := range prov.Models {
126- api := prov.API127+ 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))
127 if api == "" {134 if api == "" {
128 api = "openai"135 api = "openai"
129 }136 }
@@ -133,6 +140,9 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
133 api = "openai"140 api = "openai"
134 case "anthropic-messages", "messages", "anthropic":141 case "anthropic-messages", "messages", "anthropic":
135 api = "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"
136 }146 }
137 customProviders[normalized] = CustomProviderConfig{147 customProviders[normalized] = CustomProviderConfig{
138 BaseURL: prov.BaseURL,148 BaseURL: prov.BaseURL,
@@ -192,9 +202,11 @@ func SetUserModels(models []Model) {
192 activeMu.Lock()202 activeMu.Lock()
193 defer activeMu.Unlock()203 defer activeMu.Unlock()
194 204
195- // Ensure the active overlay is initialized so Active() reads from205+ // Ensure the active overlay starts from the full built-in catalog
196- // it instead of falling back to the static Catalog.206+ // when no live/cache overlay has been applied. Otherwise a fresh
197- if !activeSet {207+ // install with only models.json would hide every built-in model.
208+ if !activeSet || active == nil {
209+ active = append([]Model(nil), Catalog...)
198 activeSet = true210 activeSet = true
199 }211 }
200 212
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