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) { | |||
| 416 | 416 | } | |
| 417 | 417 | err = nil | |
| 418 | 418 | } | |
| 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. | |
| 422 | 422 | resolvedModel.BaseURL = cfg.BaseURL | |
| 423 | 423 | } | |
| 424 | 424 | if err != nil { | |
| @@ -456,7 +456,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 456 | 456 | model = fm.ID | |
| 457 | 457 | } | |
| 458 | 458 | ||
| 459 | - | explicitBaseURL := args.BaseURL != "" | |
| 459 | + | explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "") | |
| 460 | 460 | ||
| 461 | 461 | // If the model defines a base URL (e.g. local ollama) and the | |
| 462 | 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 | 772 | if cfg, ok := provider.CustomProviders()[r.Provider]; ok { | |
| 773 | 773 | switch cfg.API { | |
| 774 | 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 | 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 | 780 | if r.AuthMethod == "oauth" { | |
| @@ -416,9 +416,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 416 | } | 416 | } | ||
| 417 | err = nil | 417 | 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 the | 420 | + | // 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.BaseURL | 422 | 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.ID | 456 | 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 the | 461 | // 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) { | |||
| 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 | 234 | func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) { | |
| 169 | 235 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 170 | 236 | 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 { | |||
| 219 | 219 | if cps := provider.CustomProviders(); len(cps) > 0 { | |
| 220 | 220 | names := make([]string, 0, len(cps)) | |
| 221 | 221 | for name := range cps { | |
| 222 | - | names = append(names, name) | |
| 222 | + | if !isBuiltinProvider(name) { | |
| 223 | + | names = append(names, name) | |
| 224 | + | } | |
| 223 | 225 | } | |
| 224 | 226 | auth.SetExtraAPIKeyProviders(names) | |
| 225 | 227 | } | |
| @@ -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 { | |||
| 245 | 245 | } else { | |
| 246 | 246 | providers = auth.APIKeyProviders() | |
| 247 | 247 | // 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 | 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 | 261 | 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 appear | 247 | // 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 ( | |||
| 11 | 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 | 23 | func TestStoreRoundTrip(t *testing.T) { | |
| 15 | 24 | dir := t.TempDir() | |
| 16 | 25 | 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 { | |||
| 188 | 188 | case "github-copilot": | |
| 189 | 189 | return nil | |
| 190 | 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 | 198 | return fmt.Errorf("unknown provider %q", provider) | |
| 192 | 199 | } | |
| 193 | 200 | ||
| @@ -188,6 +188,13 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||||
| 188 | case "github-copilot": | 188 | case "github-copilot": | ||
| 189 | return nil | 189 | 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) { | |||
| 107 | 107 | 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 | 119 | // isKnownAPIKeyProvider reports whether the given provider supports | |
| 111 | 120 | // API-key login through the loopback flow. OAuth-only paths are handled | |
| 112 | 121 | // elsewhere (manager.StartOAuth). | |
| @@ -116,12 +125,7 @@ func isKnownAPIKeyProvider(p string) bool { | |||
| 116 | 125 | return true | |
| 117 | 126 | } | |
| 118 | 127 | } | |
| 119 | - | for _, name := range extraAPIKeyProviders { | |
| 120 | - | if p == name { | |
| 121 | - | return true | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | return false | |
| 128 | + | return isExtraAPIKeyProvider(p) | |
| 125 | 129 | } | |
| 126 | 130 | ||
| 127 | 131 | func apiKeyProviderMessage() string { | |
| @@ -107,6 +107,15 @@ func SetExtraAPIKeyProviders(names []string) { | |||||
| 107 | extraAPIKeyProviders = names | 107 | 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 supports | 119 | // isKnownAPIKeyProvider reports whether the given provider supports | ||
| 111 | // API-key login through the loopback flow. OAuth-only paths are handled | 120 | // 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 true | 125 | 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 ( | |||
| 4 | 4 | "encoding/json" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | + | "strings" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // UserModelsFile is the JSON format for user-defined models. | |
| @@ -117,13 +118,19 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 117 | 118 | normalized = "deepseek" | |
| 118 | 119 | } | |
| 119 | 120 | ||
| 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)) | |
| 127 | 134 | if api == "" { | |
| 128 | 135 | api = "openai" | |
| 129 | 136 | } | |
| @@ -133,6 +140,9 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 133 | 140 | api = "openai" | |
| 134 | 141 | case "anthropic-messages", "messages", "anthropic": | |
| 135 | 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 | 147 | customProviders[normalized] = CustomProviderConfig{ | |
| 138 | 148 | BaseURL: prov.BaseURL, | |
| @@ -192,9 +202,11 @@ func SetUserModels(models []Model) { | |||
| 192 | 202 | activeMu.Lock() | |
| 193 | 203 | defer activeMu.Unlock() | |
| 194 | 204 | ||
| 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...) | |
| 198 | 210 | activeSet = true | |
| 199 | 211 | } | |
| 200 | 212 | ||
| @@ -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 or | 121 | + | // 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 whose | 123 | + | // configs still need a custom provider entry so Resolve/NewClient |
| 123 | - | // name doesn't match a known provider (detected later in | 124 | + | // 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.API | 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)) | |||
| 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 from | 205 | + | // 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 = true | 210 | 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 | + | } | |||