detached 98f35c8

feat: discover models from custom providers on demand

Add a provider-level "discover" flag to models.json. When set together
with a baseUrl, zot lists models from the provider's OpenAI-compatible
/models endpoint in the background at startup, when /model opens, and
when launching with a discovered model that is not in the catalog yet.

Discovered entries live in the transient managed catalog, keep the
previous snapshot on failure, and are overridden by explicit models.json
entries. Config repair preserves a persisted selection for
discovery-enabled providers. Background refresh never runs
api_key_command.

Fixes #185
commit
98f35c8eedade887fc893c9fd0562f0bb6b39105
author
patriceckhart · · 2026-09-19 09:35 UTC
parent
f3f644f
signature
unsigned
refs
tag: v0.3.81
13 files · +805 -20
M README.md +17 -0
@@ -584,6 +584,23 @@ The provider IDs in `models.json` and `auth.json` must match. Then select the cu
584584 zot --provider my-company --model company-llm-v2
585585 ```
586586
587+##### Live model discovery for custom providers
588+
589+Set `"discover": true` on a custom provider to list its models from the OpenAI-compatible `/models` endpoint instead of, or in addition to, a static `models` array. Discovery is off by default and requires a provider-level `baseUrl`.
590+
591+```json
592+{
593+ "providers": {
594+ "m4": {
595+ "baseUrl": "http://127.0.0.1:48000/v1",
596+ "discover": true
597+ }
598+ }
599+}
600+```
601+
602+zot queries `<baseUrl>/models` (or `<baseUrl>/v1/models` when the base has no version segment) with a 3 second timeout: in the background at startup, when `/model` opens, and when launching with a discovered model that is not yet in the catalog. The stored or environment API key is sent as a bearer token when present; a missing key is not an error. Discovered IDs get a conservative 32K context and 4K output default, and IDs containing `embed` are skipped. Entries in the provider's `models` array override discovered metadata for the same ID. The list stays in memory only, failed refreshes keep the previous snapshot, and `/model` reports the error. Background refresh never executes `api_key_command`.
603+
587604 ### Kimi Code
588605
589606 zot has built-in Kimi support through the Kimi Coding endpoint and Moonshot's OpenAI-compatible chat API.
@@ -584,6 +584,23 @@ The provider IDs in `models.json` and `auth.json` must match. Then select the cu
584 zot --provider my-company --model company-llm-v2584 zot --provider my-company --model company-llm-v2
585 ```585 ```
586 586
587+##### Live model discovery for custom providers
588+
589+Set `"discover": true` on a custom provider to list its models from the OpenAI-compatible `/models` endpoint instead of, or in addition to, a static `models` array. Discovery is off by default and requires a provider-level `baseUrl`.
590+
591+```json
592+{
593+ "providers": {
594+ "m4": {
595+ "baseUrl": "http://127.0.0.1:48000/v1",
596+ "discover": true
597+ }
598+ }
599+}
600+```
601+
602+zot queries `<baseUrl>/models` (or `<baseUrl>/v1/models` when the base has no version segment) with a 3 second timeout: in the background at startup, when `/model` opens, and when launching with a discovered model that is not yet in the catalog. The stored or environment API key is sent as a bearer token when present; a missing key is not an error. Discovered IDs get a conservative 32K context and 4K output default, and IDs containing `embed` are skipped. Entries in the provider's `models` array override discovered metadata for the same ID. The list stays in memory only, failed refreshes keep the previous snapshot, and `/model` reports the error. Background refresh never executes `api_key_command`.
603+
587 ### Kimi Code604 ### Kimi Code
588 605
589 zot has built-in Kimi support through the Kimi Coding endpoint and Moonshot's OpenAI-compatible chat API.606 zot has built-in Kimi support through the Kimi Coding endpoint and Moonshot's OpenAI-compatible chat API.
M docs/providers.md +53 -0
@@ -484,6 +484,59 @@ custom model directly:
484484 zot --provider my-company --model company-llm-v2
485485 ```
486486
487+### Live model discovery
488+
489+A custom provider can list its models from the OpenAI-compatible `/models`
490+endpoint instead of, or in addition to, a static `models` array. Set
491+`"discover": true` at the provider level. Discovery is off by default so
492+existing configurations never gain network traffic, and it requires a
493+provider-level `baseUrl`. A `discover` flag without one is ignored with a
494+warning.
495+
496+```json
497+{
498+ "providers": {
499+ "m4": {
500+ "baseUrl": "http://127.0.0.1:48000/v1",
501+ "discover": true
502+ },
503+ "m5": {
504+ "baseUrl": "http://127.0.0.1:48001/v1",
505+ "discover": true,
506+ "models": [
507+ { "id": "big-model", "contextWindow": 200000, "maxTokens": 16000 }
508+ ]
509+ }
510+ }
511+}
512+```
513+
514+zot requests `<baseUrl>/models`, or `<baseUrl>/v1/models` when the base URL
515+has no trailing version segment, with a 3 second timeout. It refreshes in the
516+background on startup, when `/model` opens, and when launching with a
517+discovered model that is not yet in the catalog. The API key from the
518+environment or `auth.json` is sent as a bearer token when present. A missing key
519+is not an error because local servers commonly accept unauthenticated requests.
520+Background refresh never executes an `api_key_command`; opening `/model` does.
521+
522+Only model IDs are read from the response. Discovered models default to a
523+32,768 token context and 4,096 output tokens, have zero pricing, and no
524+reasoning controls. IDs containing `embed` are skipped. Entries in the
525+provider's `models` array override discovered metadata for the same ID, which
526+is how to raise limits or enable reasoning for a specific model.
527+
528+The discovered list stays in memory, separate from the six-hour cloud model
529+cache. A failed refresh keeps the previous snapshot for that provider and
530+`/model` shows the error while other providers stay usable. A persisted
531+selection of a discovered model survives restarts even though the model is not
532+in `models.json`. The `/models` wire format is assumed to be OpenAI style
533+(`{"data": [{"id": ...}]}`) regardless of the provider's `api` setting.
534+
535+As with every custom provider, `/model` only lists it once a credential exists.
536+For a keyless local server, store any placeholder value through `/login` or the
537+derived environment variable. `--list-models` prints the catalog before the
538+background refresh completes, so discovered models may be missing there.
539+
487540 ## Credential resolution
488541
489542 For each request, zot checks credentials in this order:
@@ -484,6 +484,59 @@ custom model directly:
484 zot --provider my-company --model company-llm-v2484 zot --provider my-company --model company-llm-v2
485 ```485 ```
486 486
487+### Live model discovery
488+
489+A custom provider can list its models from the OpenAI-compatible `/models`
490+endpoint instead of, or in addition to, a static `models` array. Set
491+`"discover": true` at the provider level. Discovery is off by default so
492+existing configurations never gain network traffic, and it requires a
493+provider-level `baseUrl`. A `discover` flag without one is ignored with a
494+warning.
495+
496+```json
497+{
498+ "providers": {
499+ "m4": {
500+ "baseUrl": "http://127.0.0.1:48000/v1",
501+ "discover": true
502+ },
503+ "m5": {
504+ "baseUrl": "http://127.0.0.1:48001/v1",
505+ "discover": true,
506+ "models": [
507+ { "id": "big-model", "contextWindow": 200000, "maxTokens": 16000 }
508+ ]
509+ }
510+ }
511+}
512+```
513+
514+zot requests `<baseUrl>/models`, or `<baseUrl>/v1/models` when the base URL
515+has no trailing version segment, with a 3 second timeout. It refreshes in the
516+background on startup, when `/model` opens, and when launching with a
517+discovered model that is not yet in the catalog. The API key from the
518+environment or `auth.json` is sent as a bearer token when present. A missing key
519+is not an error because local servers commonly accept unauthenticated requests.
520+Background refresh never executes an `api_key_command`; opening `/model` does.
521+
522+Only model IDs are read from the response. Discovered models default to a
523+32,768 token context and 4,096 output tokens, have zero pricing, and no
524+reasoning controls. IDs containing `embed` are skipped. Entries in the
525+provider's `models` array override discovered metadata for the same ID, which
526+is how to raise limits or enable reasoning for a specific model.
527+
528+The discovered list stays in memory, separate from the six-hour cloud model
529+cache. A failed refresh keeps the previous snapshot for that provider and
530+`/model` shows the error while other providers stay usable. A persisted
531+selection of a discovered model survives restarts even though the model is not
532+in `models.json`. The `/models` wire format is assumed to be OpenAI style
533+(`{"data": [{"id": ...}]}`) regardless of the provider's `api` setting.
534+
535+As with every custom provider, `/model` only lists it once a credential exists.
536+For a keyless local server, store any placeholder value through `/login` or the
537+derived environment variable. `--list-models` prints the catalog before the
538+background refresh completes, so discovered models may be missing there.
539+
487 ## Credential resolution540 ## Credential resolution
488 541
489 For each request, zot checks credentials in this order:542 For each request, zot checks credentials in this order:
M packages/agent/build.go +11 -1
@@ -446,6 +446,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
446446 // Restore transient metadata when resuming a local model after restart.
447447 _ = RefreshLMStudioModels(context.Background())
448448 }
449+ if isDiscoverableCustomProvider(provName) {
450+ if _, err := provider.FindModel(provName, model); err != nil {
451+ // Same restore for discovery-enabled custom providers. Skipped
452+ // when the model is already known to avoid a probe per launch.
453+ _ = RefreshCustomProviderModels(context.Background())
454+ }
455+ }
449456 resolvedModel, err := provider.FindModel(provName, model)
450457 if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID) {
451458 // Local providers are intentionally open-catalogue: any model id the
@@ -538,7 +545,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
538545 }
539546 }
540547
541- explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
548+ // Discovered custom-provider models inherit the user's models.json
549+ // endpoint, so they count as explicit for --insecure scoping too.
550+ discoveredCustom := resolvedModel.Source == "live" && isDiscoverableCustomProvider(provName)
551+ explicitBaseURL := args.BaseURL != "" || ((resolvedModel.Source == "user" || discoveredCustom) && resolvedModel.BaseURL != "")
542552
543553 // If the model defines a base URL (e.g. local ollama) and the
544554 // user didn't pass --base-url, use the model's URL. For ollama,
@@ -446,6 +446,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
446 // Restore transient metadata when resuming a local model after restart.446 // Restore transient metadata when resuming a local model after restart.
447 _ = RefreshLMStudioModels(context.Background())447 _ = RefreshLMStudioModels(context.Background())
448 }448 }
449+ if isDiscoverableCustomProvider(provName) {
450+ if _, err := provider.FindModel(provName, model); err != nil {
451+ // Same restore for discovery-enabled custom providers. Skipped
452+ // when the model is already known to avoid a probe per launch.
453+ _ = RefreshCustomProviderModels(context.Background())
454+ }
455+ }
449 resolvedModel, err := provider.FindModel(provName, model)456 resolvedModel, err := provider.FindModel(provName, model)
450 if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID) {457 if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID) {
451 // Local providers are intentionally open-catalogue: any model id the458 // Local providers are intentionally open-catalogue: any model id the
@@ -538,7 +545,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
538 }545 }
539 }546 }
540 547
541- explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")548+ // Discovered custom-provider models inherit the user's models.json
549+ // endpoint, so they count as explicit for --insecure scoping too.
550+ discoveredCustom := resolvedModel.Source == "live" && isDiscoverableCustomProvider(provName)
551+ explicitBaseURL := args.BaseURL != "" || ((resolvedModel.Source == "user" || discoveredCustom) && resolvedModel.BaseURL != "")
542 552
543 // If the model defines a base URL (e.g. local ollama) and the553 // If the model defines a base URL (e.g. local ollama) and the
544 // user didn't pass --base-url, use the model's URL. For ollama,554 // user didn't pass --base-url, use the model's URL. For ollama,
M packages/agent/cli.go +11 -9
@@ -1379,15 +1379,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13791379 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
13801380 }
13811381 },
1382- AuthManager: mgr,
1383- LlamaCPPConfig: ResolveLlamaCPPConfig,
1384- RefreshLlamaCPPModels: RefreshLlamaCPPModels,
1385- RefreshLMStudioModels: RefreshLMStudioModels,
1386- LMStudioConfigured: LMStudioConfigured,
1387- BuildAgent: buildAgent,
1388- SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
1389- BuildAgentFor: buildAgentFor,
1390- BuildAgentForRescue: buildAgentForRescue,
1382+ AuthManager: mgr,
1383+ LlamaCPPConfig: ResolveLlamaCPPConfig,
1384+ RefreshLlamaCPPModels: RefreshLlamaCPPModels,
1385+ RefreshLMStudioModels: RefreshLMStudioModels,
1386+ LMStudioConfigured: LMStudioConfigured,
1387+ RefreshCustomProviderModels: RefreshCustomProviderModels,
1388+ CustomDiscoveryConfigured: CustomDiscoveryConfigured,
1389+ BuildAgent: buildAgent,
1390+ SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
1391+ BuildAgentFor: buildAgentFor,
1392+ BuildAgentForRescue: buildAgentForRescue,
13911393 LoggedInProviders: func() []string {
13921394 var out []string
13931395 seen := map[string]bool{}
@@ -1379,15 +1379,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1379 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)1379 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1380 }1380 }
1381 },1381 },
1382- AuthManager: mgr,1382+ AuthManager: mgr,
1383- LlamaCPPConfig: ResolveLlamaCPPConfig,1383+ LlamaCPPConfig: ResolveLlamaCPPConfig,
1384- RefreshLlamaCPPModels: RefreshLlamaCPPModels,1384+ RefreshLlamaCPPModels: RefreshLlamaCPPModels,
1385- RefreshLMStudioModels: RefreshLMStudioModels,1385+ RefreshLMStudioModels: RefreshLMStudioModels,
1386- LMStudioConfigured: LMStudioConfigured,1386+ LMStudioConfigured: LMStudioConfigured,
1387- BuildAgent: buildAgent,1387+ RefreshCustomProviderModels: RefreshCustomProviderModels,
1388- SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,1388+ CustomDiscoveryConfigured: CustomDiscoveryConfigured,
1389- BuildAgentFor: buildAgentFor,1389+ BuildAgent: buildAgent,
1390- BuildAgentForRescue: buildAgentForRescue,1390+ SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
1391+ BuildAgentFor: buildAgentFor,
1392+ BuildAgentForRescue: buildAgentForRescue,
1391 LoggedInProviders: func() []string {1393 LoggedInProviders: func() []string {
1392 var out []string1394 var out []string
1393 seen := map[string]bool{}1395 seen := map[string]bool{}
A packages/agent/customdiscovery.go +94 -0
@@ -0,0 +1,94 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "sort"
8+ "strings"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+// discoverableCustomProviders returns the models.json providers that opted
15+// into live discovery, sorted for deterministic refresh order.
16+func discoverableCustomProviders() []string {
17+ var out []string
18+ for name := range provider.CustomProviders() {
19+ if isDiscoverableCustomProvider(name) {
20+ out = append(out, name)
21+ }
22+ }
23+ sort.Strings(out)
24+ return out
25+}
26+
27+// isDiscoverableCustomProvider reports whether name is a custom provider
28+// with live discovery enabled. Its catalog entries are transient, so config
29+// repair must not treat a missing model as stale.
30+func isDiscoverableCustomProvider(name string) bool {
31+ cfg, ok := provider.CustomProviders()[name]
32+ return ok && cfg.Discover && strings.TrimSpace(cfg.BaseURL) != "" && !isBuiltinProvider(name)
33+}
34+
35+// CustomDiscoveryConfigured reports whether any custom provider has live
36+// discovery enabled. It reads only loaded configuration, never the network.
37+func CustomDiscoveryConfigured() bool {
38+ return len(discoverableCustomProviders()) > 0
39+}
40+
41+// customProviderKey resolves the optional bearer key for a custom provider.
42+// A missing key is not an error because local servers commonly accept
43+// unauthenticated requests. skip is true when the only key is command-backed
44+// and mode forbids running the command.
45+func customProviderKey(ctx context.Context, name string, mode apiKeyCommandMode) (key string, skip bool, err error) {
46+ if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" {
47+ return v, false, nil
48+ }
49+ creds, err := AuthStoreFor().Load()
50+ if err != nil {
51+ return "", false, err
52+ }
53+ stored, ok := creds.AdditionalAPIKeyCreds[name]
54+ if !ok {
55+ return "", false, nil
56+ }
57+ if stored.APIKey == "" && stored.APIKeyCommand != nil && mode == apiKeyCommandSkip {
58+ return "", true, nil
59+ }
60+ key, _, err = resolveStoredAPIKey(ctx, name, stored, mode)
61+ return key, false, err
62+}
63+
64+// RefreshCustomProviderModels lists models from every discovery-enabled
65+// custom provider and merges them into the transient managed catalog.
66+// A failing provider keeps its previous snapshot; errors are joined after
67+// all providers were attempted.
68+func RefreshCustomProviderModels(ctx context.Context) error {
69+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
70+ defer cancel()
71+ return refreshCustomProviderModels(ctx, apiKeyCommandExecute)
72+}
73+
74+func refreshCustomProviderModels(ctx context.Context, mode apiKeyCommandMode) error {
75+ var errs []error
76+ for _, name := range discoverableCustomProviders() {
77+ cfg := provider.CustomProviders()[name]
78+ key, skip, err := customProviderKey(ctx, name, mode)
79+ if err != nil {
80+ errs = append(errs, err)
81+ continue
82+ }
83+ if skip {
84+ continue
85+ }
86+ models, err := provider.DiscoverCustomProvider(ctx, name, cfg.BaseURL, key)
87+ if err != nil {
88+ errs = append(errs, err)
89+ continue
90+ }
91+ provider.SetManagedModelsForProvider(name, models)
92+ }
93+ return errors.Join(errs...)
94+}
@@ -0,0 +1,94 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "sort"
8+ "strings"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+// discoverableCustomProviders returns the models.json providers that opted
15+// into live discovery, sorted for deterministic refresh order.
16+func discoverableCustomProviders() []string {
17+ var out []string
18+ for name := range provider.CustomProviders() {
19+ if isDiscoverableCustomProvider(name) {
20+ out = append(out, name)
21+ }
22+ }
23+ sort.Strings(out)
24+ return out
25+}
26+
27+// isDiscoverableCustomProvider reports whether name is a custom provider
28+// with live discovery enabled. Its catalog entries are transient, so config
29+// repair must not treat a missing model as stale.
30+func isDiscoverableCustomProvider(name string) bool {
31+ cfg, ok := provider.CustomProviders()[name]
32+ return ok && cfg.Discover && strings.TrimSpace(cfg.BaseURL) != "" && !isBuiltinProvider(name)
33+}
34+
35+// CustomDiscoveryConfigured reports whether any custom provider has live
36+// discovery enabled. It reads only loaded configuration, never the network.
37+func CustomDiscoveryConfigured() bool {
38+ return len(discoverableCustomProviders()) > 0
39+}
40+
41+// customProviderKey resolves the optional bearer key for a custom provider.
42+// A missing key is not an error because local servers commonly accept
43+// unauthenticated requests. skip is true when the only key is command-backed
44+// and mode forbids running the command.
45+func customProviderKey(ctx context.Context, name string, mode apiKeyCommandMode) (key string, skip bool, err error) {
46+ if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" {
47+ return v, false, nil
48+ }
49+ creds, err := AuthStoreFor().Load()
50+ if err != nil {
51+ return "", false, err
52+ }
53+ stored, ok := creds.AdditionalAPIKeyCreds[name]
54+ if !ok {
55+ return "", false, nil
56+ }
57+ if stored.APIKey == "" && stored.APIKeyCommand != nil && mode == apiKeyCommandSkip {
58+ return "", true, nil
59+ }
60+ key, _, err = resolveStoredAPIKey(ctx, name, stored, mode)
61+ return key, false, err
62+}
63+
64+// RefreshCustomProviderModels lists models from every discovery-enabled
65+// custom provider and merges them into the transient managed catalog.
66+// A failing provider keeps its previous snapshot; errors are joined after
67+// all providers were attempted.
68+func RefreshCustomProviderModels(ctx context.Context) error {
69+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
70+ defer cancel()
71+ return refreshCustomProviderModels(ctx, apiKeyCommandExecute)
72+}
73+
74+func refreshCustomProviderModels(ctx context.Context, mode apiKeyCommandMode) error {
75+ var errs []error
76+ for _, name := range discoverableCustomProviders() {
77+ cfg := provider.CustomProviders()[name]
78+ key, skip, err := customProviderKey(ctx, name, mode)
79+ if err != nil {
80+ errs = append(errs, err)
81+ continue
82+ }
83+ if skip {
84+ continue
85+ }
86+ models, err := provider.DiscoverCustomProvider(ctx, name, cfg.BaseURL, key)
87+ if err != nil {
88+ errs = append(errs, err)
89+ continue
90+ }
91+ provider.SetManagedModelsForProvider(name, models)
92+ }
93+ return errors.Join(errs...)
94+}
A packages/agent/customdiscovery_test.go +263 -0
@@ -0,0 +1,263 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "sync/atomic"
13+ "testing"
14+
15+ "github.com/patriceckhart/zot/packages/provider"
16+ "github.com/patriceckhart/zot/packages/provider/auth"
17+)
18+
19+// loadTestModelsJSON installs a models.json under ZOT_HOME and clears the
20+// custom provider registry and managed catalog when the test ends.
21+func loadTestModelsJSON(t *testing.T, body string) {
22+ t.Helper()
23+ if err := os.WriteFile(UserModelsPath(), []byte(body), 0o644); err != nil {
24+ t.Fatal(err)
25+ }
26+ provider.SetLiveModels(nil)
27+ provider.SetManagedModels(nil)
28+ LoadUserModels()
29+ t.Cleanup(func() {
30+ _ = os.WriteFile(UserModelsPath(), []byte(`{"providers":{}}`), 0o644)
31+ LoadUserModels()
32+ provider.SetLiveModels(nil)
33+ provider.SetManagedModels(nil)
34+ })
35+}
36+
37+func newModelsServer(t *testing.T, requests *atomic.Int32, wantAuth string, ids ...string) *httptest.Server {
38+ t.Helper()
39+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+ requests.Add(1)
41+ if r.URL.Path != "/v1/models" {
42+ t.Errorf("unexpected path %s", r.URL.Path)
43+ }
44+ if got := r.Header.Get("Authorization"); got != wantAuth {
45+ t.Errorf("Authorization = %q, want %q", got, wantAuth)
46+ }
47+ var data []map[string]string
48+ for _, id := range ids {
49+ data = append(data, map[string]string{"id": id})
50+ }
51+ _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
52+ }))
53+ t.Cleanup(server.Close)
54+ return server
55+}
56+
57+func TestRefreshCustomProviderModelsIsOptIn(t *testing.T) {
58+ t.Setenv("ZOT_HOME", t.TempDir())
59+ t.Setenv("M4_API_KEY", "")
60+ t.Setenv("M5_API_KEY", "")
61+ var requests atomic.Int32
62+ server := newModelsServer(t, &requests, "", "qwen3-coder")
63+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {
64+ "m4": {"baseUrl": %q, "discover": true},
65+ "m5": {"baseUrl": %q, "models": [{"id": "pinned"}]}
66+ }}`, server.URL+"/v1", server.URL+"/v1"))
67+
68+ if !CustomDiscoveryConfigured() {
69+ t.Fatal("discovery not reported as configured")
70+ }
71+ if got := discoverableCustomProviders(); len(got) != 1 || got[0] != "m4" {
72+ t.Fatalf("discoverable = %v", got)
73+ }
74+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
75+ t.Fatal(err)
76+ }
77+ if requests.Load() != 1 {
78+ t.Fatalf("requests = %d, want 1 (only m4)", requests.Load())
79+ }
80+ m, err := provider.FindModel("m4", "qwen3-coder")
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ if m.Source != "live" || m.BaseURL != server.URL+"/v1" || m.ContextWindow <= 0 || m.MaxOutput <= 0 {
85+ t.Fatalf("model = %+v", m)
86+ }
87+ if _, err := provider.FindModel("m5", "qwen3-coder"); err == nil {
88+ t.Fatal("m5 gained discovered models without opting in")
89+ }
90+ if _, err := provider.FindModel("m5", "pinned"); err != nil {
91+ t.Fatal("m5 user model lost")
92+ }
93+}
94+
95+func TestRefreshCustomProviderModelsUsesStoredKeyAndKeepsUserOverrides(t *testing.T) {
96+ t.Setenv("ZOT_HOME", t.TempDir())
97+ t.Setenv("M4_API_KEY", "")
98+ var requests atomic.Int32
99+ server := newModelsServer(t, &requests, "Bearer dummy", "qwen3-coder", "big-model")
100+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {
101+ "m4": {"baseUrl": %q, "discover": true, "models": [{"id": "big-model", "contextWindow": 200000, "maxTokens": 16000}]}
102+ }}`, server.URL+"/v1"))
103+ if err := AuthStoreFor().SetAPIKey("m4", "dummy"); err != nil {
104+ t.Fatal(err)
105+ }
106+
107+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
108+ t.Fatal(err)
109+ }
110+ if requests.Load() != 1 {
111+ t.Fatalf("requests = %d", requests.Load())
112+ }
113+ big, err := provider.FindModel("m4", "big-model")
114+ if err != nil {
115+ t.Fatal(err)
116+ }
117+ if big.Source != "user" || big.ContextWindow != 200000 || big.MaxOutput != 16000 {
118+ t.Fatalf("models.json override lost: %+v", big)
119+ }
120+ if _, err := provider.FindModel("m4", "qwen3-coder"); err != nil {
121+ t.Fatal("discovered model missing")
122+ }
123+
124+ // Env var beats auth.json, matching credential resolution order.
125+ t.Setenv("M4_API_KEY", "from-env")
126+ server2 := newModelsServer(t, &requests, "Bearer from-env", "other")
127+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {"m4": {"baseUrl": %q, "discover": true}}}`, server2.URL))
128+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
129+ t.Fatal(err)
130+ }
131+ if _, err := provider.FindModel("m4", "other"); err != nil {
132+ t.Fatal("refresh did not replace snapshot")
133+ }
134+ if _, err := provider.FindModel("m4", "qwen3-coder"); err == nil {
135+ t.Fatal("stale discovered model retained after successful refresh")
136+ }
137+}
138+
139+func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) {
140+ home := t.TempDir()
141+ t.Setenv("ZOT_HOME", home)
142+ t.Setenv("M4_API_KEY", "")
143+ var requests atomic.Int32
144+ server := newModelsServer(t, &requests, "", "m")
145+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {"m4": {"baseUrl": %q, "discover": true}}}`, server.URL))
146+ creds := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
147+ "m4": {APIKeyCommand: &auth.APIKeyCommand{Program: filepath.Join(home, "does-not-exist")}},
148+ }}
149+ encoded, err := json.Marshal(creds)
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
154+ t.Fatal(err)
155+ }
156+
157+ if err := refreshCustomProviderModels(context.Background(), apiKeyCommandSkip); err != nil {
158+ t.Fatal(err)
159+ }
160+ if requests.Load() != 0 {
161+ t.Fatal("background refresh probed a provider whose key needs a command")
162+ }
163+ // Interactive refresh runs the command and reports its failure
164+ // instead of silently probing without a key.
165+ err = RefreshCustomProviderModels(context.Background())
166+ if err == nil || !strings.Contains(err.Error(), "m4") {
167+ t.Fatalf("command failure not reported: %v", err)
168+ }
169+ if requests.Load() != 0 {
170+ t.Fatal("refresh probed after key command failed")
171+ }
172+}
173+
174+func TestRefreshCustomProviderModelsKeepsSnapshotOnFailure(t *testing.T) {
175+ t.Setenv("ZOT_HOME", t.TempDir())
176+ t.Setenv("M4_API_KEY", "")
177+ t.Setenv("M5_API_KEY", "")
178+ var requests atomic.Int32
179+ good := newModelsServer(t, &requests, "", "alive")
180+ failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181+ http.Error(w, "nope", http.StatusServiceUnavailable)
182+ }))
183+ defer failing.Close()
184+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {
185+ "m4": {"baseUrl": %q, "discover": true},
186+ "m5": {"baseUrl": %q, "discover": true}
187+ }}`, good.URL, failing.URL))
188+ provider.SetManagedModelsForProvider("m5", []provider.Model{{Provider: "m5", ID: "previous", Source: "live"}})
189+
190+ err := RefreshCustomProviderModels(context.Background())
191+ if err == nil || !strings.Contains(err.Error(), "m5 discovery HTTP 503") {
192+ t.Fatalf("err = %v", err)
193+ }
194+ if _, err := provider.FindModel("m4", "alive"); err != nil {
195+ t.Fatal("healthy provider not refreshed when a sibling failed")
196+ }
197+ if _, err := provider.FindModel("m5", "previous"); err != nil {
198+ t.Fatal("failed refresh dropped the previous snapshot")
199+ }
200+}
201+
202+func TestValidateAndRepairConfig_PreservesDiscoverableCustomModel(t *testing.T) {
203+ home := t.TempDir()
204+ t.Setenv("ZOT_HOME", home)
205+ loadTestModelsJSON(t, `{"providers": {
206+ "m4": {"baseUrl": "http://127.0.0.1:1/v1", "discover": true},
207+ "m5": {"baseUrl": "http://127.0.0.1:1/v1", "models": [{"id": "pinned"}]}
208+ }}`)
209+
210+ write := func(c Config) {
211+ t.Helper()
212+ b, _ := json.Marshal(c)
213+ if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
214+ t.Fatal(err)
215+ }
216+ }
217+
218+ // Discovery has not run yet at repair time, so the model is unknown.
219+ write(Config{Provider: "m4", Model: "qwen3-coder"})
220+ ValidateAndRepairConfig()
221+ out, _ := LoadConfig()
222+ if out.Provider != "m4" || out.Model != "qwen3-coder" {
223+ t.Fatalf("discoverable custom model repaired away: %+v", out)
224+ }
225+
226+ // A provider without discovery keeps the existing repair behavior.
227+ write(Config{Provider: "m5", Model: "gone"})
228+ ValidateAndRepairConfig()
229+ out, _ = LoadConfig()
230+ if out.Provider != "m5" || out.Model != "pinned" {
231+ t.Fatalf("non-discoverable custom model not repaired: %+v", out)
232+ }
233+}
234+
235+func TestResolveRestoresDiscoveredCustomModelAfterRestart(t *testing.T) {
236+ t.Setenv("ZOT_HOME", t.TempDir())
237+ t.Setenv("M4_API_KEY", "")
238+ var requests atomic.Int32
239+ server := newModelsServer(t, &requests, "", "qwen3-coder")
240+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {"m4": {"baseUrl": %q, "discover": true}}}`, server.URL+"/v1"))
241+
242+ r, err := Resolve(Args{Provider: "m4", Model: "qwen3-coder", NoSkill: true}, true)
243+ if err != nil {
244+ t.Fatal(err)
245+ }
246+ if requests.Load() != 1 {
247+ t.Fatalf("requests = %d, want a restore probe", requests.Load())
248+ }
249+ if r.Provider != "m4" || r.Model != "qwen3-coder" || r.BaseURL != server.URL+"/v1" {
250+ t.Fatalf("resolved = provider=%s model=%s base=%s", r.Provider, r.Model, r.BaseURL)
251+ }
252+ if r.MaxOutput <= 0 {
253+ t.Fatalf("discovered model lost output budget: %+v", r.MaxOutput)
254+ }
255+
256+ // Once the model is known, launching again does not probe.
257+ if _, err := Resolve(Args{Provider: "m4", Model: "qwen3-coder", NoSkill: true}, true); err != nil {
258+ t.Fatal(err)
259+ }
260+ if requests.Load() != 1 {
261+ t.Fatalf("requests = %d, want no extra probe", requests.Load())
262+ }
263+}
@@ -0,0 +1,263 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "sync/atomic"
13+ "testing"
14+
15+ "github.com/patriceckhart/zot/packages/provider"
16+ "github.com/patriceckhart/zot/packages/provider/auth"
17+)
18+
19+// loadTestModelsJSON installs a models.json under ZOT_HOME and clears the
20+// custom provider registry and managed catalog when the test ends.
21+func loadTestModelsJSON(t *testing.T, body string) {
22+ t.Helper()
23+ if err := os.WriteFile(UserModelsPath(), []byte(body), 0o644); err != nil {
24+ t.Fatal(err)
25+ }
26+ provider.SetLiveModels(nil)
27+ provider.SetManagedModels(nil)
28+ LoadUserModels()
29+ t.Cleanup(func() {
30+ _ = os.WriteFile(UserModelsPath(), []byte(`{"providers":{}}`), 0o644)
31+ LoadUserModels()
32+ provider.SetLiveModels(nil)
33+ provider.SetManagedModels(nil)
34+ })
35+}
36+
37+func newModelsServer(t *testing.T, requests *atomic.Int32, wantAuth string, ids ...string) *httptest.Server {
38+ t.Helper()
39+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+ requests.Add(1)
41+ if r.URL.Path != "/v1/models" {
42+ t.Errorf("unexpected path %s", r.URL.Path)
43+ }
44+ if got := r.Header.Get("Authorization"); got != wantAuth {
45+ t.Errorf("Authorization = %q, want %q", got, wantAuth)
46+ }
47+ var data []map[string]string
48+ for _, id := range ids {
49+ data = append(data, map[string]string{"id": id})
50+ }
51+ _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
52+ }))
53+ t.Cleanup(server.Close)
54+ return server
55+}
56+
57+func TestRefreshCustomProviderModelsIsOptIn(t *testing.T) {
58+ t.Setenv("ZOT_HOME", t.TempDir())
59+ t.Setenv("M4_API_KEY", "")
60+ t.Setenv("M5_API_KEY", "")
61+ var requests atomic.Int32
62+ server := newModelsServer(t, &requests, "", "qwen3-coder")
63+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {
64+ "m4": {"baseUrl": %q, "discover": true},
65+ "m5": {"baseUrl": %q, "models": [{"id": "pinned"}]}
66+ }}`, server.URL+"/v1", server.URL+"/v1"))
67+
68+ if !CustomDiscoveryConfigured() {
69+ t.Fatal("discovery not reported as configured")
70+ }
71+ if got := discoverableCustomProviders(); len(got) != 1 || got[0] != "m4" {
72+ t.Fatalf("discoverable = %v", got)
73+ }
74+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
75+ t.Fatal(err)
76+ }
77+ if requests.Load() != 1 {
78+ t.Fatalf("requests = %d, want 1 (only m4)", requests.Load())
79+ }
80+ m, err := provider.FindModel("m4", "qwen3-coder")
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ if m.Source != "live" || m.BaseURL != server.URL+"/v1" || m.ContextWindow <= 0 || m.MaxOutput <= 0 {
85+ t.Fatalf("model = %+v", m)
86+ }
87+ if _, err := provider.FindModel("m5", "qwen3-coder"); err == nil {
88+ t.Fatal("m5 gained discovered models without opting in")
89+ }
90+ if _, err := provider.FindModel("m5", "pinned"); err != nil {
91+ t.Fatal("m5 user model lost")
92+ }
93+}
94+
95+func TestRefreshCustomProviderModelsUsesStoredKeyAndKeepsUserOverrides(t *testing.T) {
96+ t.Setenv("ZOT_HOME", t.TempDir())
97+ t.Setenv("M4_API_KEY", "")
98+ var requests atomic.Int32
99+ server := newModelsServer(t, &requests, "Bearer dummy", "qwen3-coder", "big-model")
100+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {
101+ "m4": {"baseUrl": %q, "discover": true, "models": [{"id": "big-model", "contextWindow": 200000, "maxTokens": 16000}]}
102+ }}`, server.URL+"/v1"))
103+ if err := AuthStoreFor().SetAPIKey("m4", "dummy"); err != nil {
104+ t.Fatal(err)
105+ }
106+
107+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
108+ t.Fatal(err)
109+ }
110+ if requests.Load() != 1 {
111+ t.Fatalf("requests = %d", requests.Load())
112+ }
113+ big, err := provider.FindModel("m4", "big-model")
114+ if err != nil {
115+ t.Fatal(err)
116+ }
117+ if big.Source != "user" || big.ContextWindow != 200000 || big.MaxOutput != 16000 {
118+ t.Fatalf("models.json override lost: %+v", big)
119+ }
120+ if _, err := provider.FindModel("m4", "qwen3-coder"); err != nil {
121+ t.Fatal("discovered model missing")
122+ }
123+
124+ // Env var beats auth.json, matching credential resolution order.
125+ t.Setenv("M4_API_KEY", "from-env")
126+ server2 := newModelsServer(t, &requests, "Bearer from-env", "other")
127+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {"m4": {"baseUrl": %q, "discover": true}}}`, server2.URL))
128+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
129+ t.Fatal(err)
130+ }
131+ if _, err := provider.FindModel("m4", "other"); err != nil {
132+ t.Fatal("refresh did not replace snapshot")
133+ }
134+ if _, err := provider.FindModel("m4", "qwen3-coder"); err == nil {
135+ t.Fatal("stale discovered model retained after successful refresh")
136+ }
137+}
138+
139+func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) {
140+ home := t.TempDir()
141+ t.Setenv("ZOT_HOME", home)
142+ t.Setenv("M4_API_KEY", "")
143+ var requests atomic.Int32
144+ server := newModelsServer(t, &requests, "", "m")
145+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {"m4": {"baseUrl": %q, "discover": true}}}`, server.URL))
146+ creds := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
147+ "m4": {APIKeyCommand: &auth.APIKeyCommand{Program: filepath.Join(home, "does-not-exist")}},
148+ }}
149+ encoded, err := json.Marshal(creds)
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
154+ t.Fatal(err)
155+ }
156+
157+ if err := refreshCustomProviderModels(context.Background(), apiKeyCommandSkip); err != nil {
158+ t.Fatal(err)
159+ }
160+ if requests.Load() != 0 {
161+ t.Fatal("background refresh probed a provider whose key needs a command")
162+ }
163+ // Interactive refresh runs the command and reports its failure
164+ // instead of silently probing without a key.
165+ err = RefreshCustomProviderModels(context.Background())
166+ if err == nil || !strings.Contains(err.Error(), "m4") {
167+ t.Fatalf("command failure not reported: %v", err)
168+ }
169+ if requests.Load() != 0 {
170+ t.Fatal("refresh probed after key command failed")
171+ }
172+}
173+
174+func TestRefreshCustomProviderModelsKeepsSnapshotOnFailure(t *testing.T) {
175+ t.Setenv("ZOT_HOME", t.TempDir())
176+ t.Setenv("M4_API_KEY", "")
177+ t.Setenv("M5_API_KEY", "")
178+ var requests atomic.Int32
179+ good := newModelsServer(t, &requests, "", "alive")
180+ failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181+ http.Error(w, "nope", http.StatusServiceUnavailable)
182+ }))
183+ defer failing.Close()
184+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {
185+ "m4": {"baseUrl": %q, "discover": true},
186+ "m5": {"baseUrl": %q, "discover": true}
187+ }}`, good.URL, failing.URL))
188+ provider.SetManagedModelsForProvider("m5", []provider.Model{{Provider: "m5", ID: "previous", Source: "live"}})
189+
190+ err := RefreshCustomProviderModels(context.Background())
191+ if err == nil || !strings.Contains(err.Error(), "m5 discovery HTTP 503") {
192+ t.Fatalf("err = %v", err)
193+ }
194+ if _, err := provider.FindModel("m4", "alive"); err != nil {
195+ t.Fatal("healthy provider not refreshed when a sibling failed")
196+ }
197+ if _, err := provider.FindModel("m5", "previous"); err != nil {
198+ t.Fatal("failed refresh dropped the previous snapshot")
199+ }
200+}
201+
202+func TestValidateAndRepairConfig_PreservesDiscoverableCustomModel(t *testing.T) {
203+ home := t.TempDir()
204+ t.Setenv("ZOT_HOME", home)
205+ loadTestModelsJSON(t, `{"providers": {
206+ "m4": {"baseUrl": "http://127.0.0.1:1/v1", "discover": true},
207+ "m5": {"baseUrl": "http://127.0.0.1:1/v1", "models": [{"id": "pinned"}]}
208+ }}`)
209+
210+ write := func(c Config) {
211+ t.Helper()
212+ b, _ := json.Marshal(c)
213+ if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
214+ t.Fatal(err)
215+ }
216+ }
217+
218+ // Discovery has not run yet at repair time, so the model is unknown.
219+ write(Config{Provider: "m4", Model: "qwen3-coder"})
220+ ValidateAndRepairConfig()
221+ out, _ := LoadConfig()
222+ if out.Provider != "m4" || out.Model != "qwen3-coder" {
223+ t.Fatalf("discoverable custom model repaired away: %+v", out)
224+ }
225+
226+ // A provider without discovery keeps the existing repair behavior.
227+ write(Config{Provider: "m5", Model: "gone"})
228+ ValidateAndRepairConfig()
229+ out, _ = LoadConfig()
230+ if out.Provider != "m5" || out.Model != "pinned" {
231+ t.Fatalf("non-discoverable custom model not repaired: %+v", out)
232+ }
233+}
234+
235+func TestResolveRestoresDiscoveredCustomModelAfterRestart(t *testing.T) {
236+ t.Setenv("ZOT_HOME", t.TempDir())
237+ t.Setenv("M4_API_KEY", "")
238+ var requests atomic.Int32
239+ server := newModelsServer(t, &requests, "", "qwen3-coder")
240+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {"m4": {"baseUrl": %q, "discover": true}}}`, server.URL+"/v1"))
241+
242+ r, err := Resolve(Args{Provider: "m4", Model: "qwen3-coder", NoSkill: true}, true)
243+ if err != nil {
244+ t.Fatal(err)
245+ }
246+ if requests.Load() != 1 {
247+ t.Fatalf("requests = %d, want a restore probe", requests.Load())
248+ }
249+ if r.Provider != "m4" || r.Model != "qwen3-coder" || r.BaseURL != server.URL+"/v1" {
250+ t.Fatalf("resolved = provider=%s model=%s base=%s", r.Provider, r.Model, r.BaseURL)
251+ }
252+ if r.MaxOutput <= 0 {
253+ t.Fatalf("discovered model lost output budget: %+v", r.MaxOutput)
254+ }
255+
256+ // Once the model is known, launching again does not probe.
257+ if _, err := Resolve(Args{Provider: "m4", Model: "qwen3-coder", NoSkill: true}, true); err != nil {
258+ t.Fatal(err)
259+ }
260+ if requests.Load() != 1 {
261+ t.Fatalf("requests = %d, want no extra probe", requests.Load())
262+ }
263+}
M packages/agent/modelsync.go +9 -3
@@ -105,10 +105,11 @@ func ValidateAndRepairConfig() {
105105 // Gateway providers can serve routed model ids like
106106 // "deepseek/deepseek-v4-flash" even when the local catalog does not
107107 // know them. Preserve only routed ids; plain typos are still repaired.
108- if cfg.Provider == provider.LMStudioProviderID || (isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model)) {
108+ if cfg.Provider == provider.LMStudioProviderID || isDiscoverableCustomProvider(cfg.Provider) || (isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model)) {
109109 // Local discovery is transient. Preserve the selected LM Studio
110- // ID even if another provider has an identically named model.
111- // Routed gateway IDs are also valid without a catalog entry.
110+ // or discovery-enabled custom provider ID even if another
111+ // provider has an identically named model. Routed gateway IDs
112+ // are also valid without a catalog entry.
112113 } else if m, err := provider.FindModel("", cfg.Model); err == nil {
113114 fix := defaultModelForProvider(cfg.Provider)
114115 fmt.Fprintf(os.Stderr,
@@ -154,6 +155,11 @@ func RefreshModelsAsync() {
154155 defer cancel()
155156 _ = refreshLlamaCPPModels(ctx, apiKeyCommandSkip)
156157 }()
158+ go func() {
159+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
160+ defer cancel()
161+ _ = refreshCustomProviderModels(ctx, apiKeyCommandSkip)
162+ }()
157163 }
158164
159165 // Copilot availability is account-specific and must not use the shared disk cache.
@@ -105,10 +105,11 @@ func ValidateAndRepairConfig() {
105 // Gateway providers can serve routed model ids like105 // Gateway providers can serve routed model ids like
106 // "deepseek/deepseek-v4-flash" even when the local catalog does not106 // "deepseek/deepseek-v4-flash" even when the local catalog does not
107 // know them. Preserve only routed ids; plain typos are still repaired.107 // know them. Preserve only routed ids; plain typos are still repaired.
108- if cfg.Provider == provider.LMStudioProviderID || (isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model)) {108+ if cfg.Provider == provider.LMStudioProviderID || isDiscoverableCustomProvider(cfg.Provider) || (isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model)) {
109 // Local discovery is transient. Preserve the selected LM Studio109 // Local discovery is transient. Preserve the selected LM Studio
110- // ID even if another provider has an identically named model.110+ // or discovery-enabled custom provider ID even if another
111- // Routed gateway IDs are also valid without a catalog entry.111+ // provider has an identically named model. Routed gateway IDs
112+ // are also valid without a catalog entry.
112 } else if m, err := provider.FindModel("", cfg.Model); err == nil {113 } else if m, err := provider.FindModel("", cfg.Model); err == nil {
113 fix := defaultModelForProvider(cfg.Provider)114 fix := defaultModelForProvider(cfg.Provider)
114 fmt.Fprintf(os.Stderr,115 fmt.Fprintf(os.Stderr,
@@ -154,6 +155,11 @@ func RefreshModelsAsync() {
154 defer cancel()155 defer cancel()
155 _ = refreshLlamaCPPModels(ctx, apiKeyCommandSkip)156 _ = refreshLlamaCPPModels(ctx, apiKeyCommandSkip)
156 }()157 }()
158+ go func() {
159+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
160+ defer cancel()
161+ _ = refreshCustomProviderModels(ctx, apiKeyCommandSkip)
162+ }()
157 }163 }
158 164
159 // Copilot availability is account-specific and must not use the shared disk cache.165 // Copilot availability is account-specific and must not use the shared disk cache.
M packages/agent/modes/interactive.go +25 -2
@@ -187,6 +187,12 @@ type InteractiveConfig struct {
187187 RefreshLMStudioModels func(context.Context) error
188188 LMStudioConfigured func() bool
189189
190+ // RefreshCustomProviderModels lists models from models.json providers
191+ // that enabled discovery. CustomDiscoveryConfigured gates the refresh
192+ // so /model opens immediately when nothing opted in.
193+ RefreshCustomProviderModels func(context.Context) error
194+ CustomDiscoveryConfigured func() bool
195+
190196 // BuildAgent is called after a successful login to (re)construct the
191197 // agent with the fresh credential. It returns the new agent and
192198 // the concrete provider/model in use.
@@ -4551,7 +4557,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
45514557 case "/model":
45524558 if len(parts) >= 2 {
45534559 i.applyModelSelection("", parts[1])
4554- } else if (i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil) || (i.cfg.LMStudioConfigured != nil && i.cfg.LMStudioConfigured() && i.cfg.RefreshLMStudioModels != nil) {
4560+ } else if i.modelRefreshNeeded() {
45554561 i.mu.Lock()
45564562 i.statusOK = "refreshing models"
45574563 i.statusErr = ""
@@ -4571,6 +4577,11 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
45714577 err = llamaErr
45724578 }
45734579 }
4580+ if i.cfg.RefreshCustomProviderModels != nil && i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured() {
4581+ if customErr := refresh(i.cfg.RefreshCustomProviderModels); err == nil {
4582+ err = customErr
4583+ }
4584+ }
45744585 select {
45754586 case i.modelRefresh <- modelRefreshResult{err: err}:
45764587 case <-ctx.Done():
@@ -4753,6 +4764,18 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
47534764 return false
47544765 }
47554766
4767+// modelRefreshNeeded reports whether /model must synchronize transient
4768+// local catalogs before the picker opens.
4769+func (i *Interactive) modelRefreshNeeded() bool {
4770+ if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
4771+ return true
4772+ }
4773+ if i.cfg.LMStudioConfigured != nil && i.cfg.LMStudioConfigured() && i.cfg.RefreshLMStudioModels != nil {
4774+ return true
4775+ }
4776+ return i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured() && i.cfg.RefreshCustomProviderModels != nil
4777+}
4778+
47564779 func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
47574780 var loggedIn []string
47584781 if i.cfg.LoggedInProviders != nil {
@@ -4761,7 +4784,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
47614784 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
47624785 i.mu.Lock()
47634786 if refreshErr != nil {
4764- i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
4787+ i.statusErr = "model refresh: " + refreshErr.Error()
47654788 i.statusOK = ""
47664789 } else if i.statusOK == "refreshing models" {
47674790 i.statusOK = ""
@@ -187,6 +187,12 @@ type InteractiveConfig struct {
187 RefreshLMStudioModels func(context.Context) error187 RefreshLMStudioModels func(context.Context) error
188 LMStudioConfigured func() bool188 LMStudioConfigured func() bool
189 189
190+ // RefreshCustomProviderModels lists models from models.json providers
191+ // that enabled discovery. CustomDiscoveryConfigured gates the refresh
192+ // so /model opens immediately when nothing opted in.
193+ RefreshCustomProviderModels func(context.Context) error
194+ CustomDiscoveryConfigured func() bool
195+
190 // BuildAgent is called after a successful login to (re)construct the196 // BuildAgent is called after a successful login to (re)construct the
191 // agent with the fresh credential. It returns the new agent and197 // agent with the fresh credential. It returns the new agent and
192 // the concrete provider/model in use.198 // the concrete provider/model in use.
@@ -4551,7 +4557,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4551 case "/model":4557 case "/model":
4552 if len(parts) >= 2 {4558 if len(parts) >= 2 {
4553 i.applyModelSelection("", parts[1])4559 i.applyModelSelection("", parts[1])
4554- } else if (i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil) || (i.cfg.LMStudioConfigured != nil && i.cfg.LMStudioConfigured() && i.cfg.RefreshLMStudioModels != nil) {4560+ } else if i.modelRefreshNeeded() {
4555 i.mu.Lock()4561 i.mu.Lock()
4556 i.statusOK = "refreshing models"4562 i.statusOK = "refreshing models"
4557 i.statusErr = ""4563 i.statusErr = ""
@@ -4571,6 +4577,11 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4571 err = llamaErr4577 err = llamaErr
4572 }4578 }
4573 }4579 }
4580+ if i.cfg.RefreshCustomProviderModels != nil && i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured() {
4581+ if customErr := refresh(i.cfg.RefreshCustomProviderModels); err == nil {
4582+ err = customErr
4583+ }
4584+ }
4574 select {4585 select {
4575 case i.modelRefresh <- modelRefreshResult{err: err}:4586 case i.modelRefresh <- modelRefreshResult{err: err}:
4576 case <-ctx.Done():4587 case <-ctx.Done():
@@ -4753,6 +4764,18 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4753 return false4764 return false
4754 }4765 }
4755 4766
4767+// modelRefreshNeeded reports whether /model must synchronize transient
4768+// local catalogs before the picker opens.
4769+func (i *Interactive) modelRefreshNeeded() bool {
4770+ if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
4771+ return true
4772+ }
4773+ if i.cfg.LMStudioConfigured != nil && i.cfg.LMStudioConfigured() && i.cfg.RefreshLMStudioModels != nil {
4774+ return true
4775+ }
4776+ return i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured() && i.cfg.RefreshCustomProviderModels != nil
4777+}
4778+
4756 func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {4779 func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
4757 var loggedIn []string4780 var loggedIn []string
4758 if i.cfg.LoggedInProviders != nil {4781 if i.cfg.LoggedInProviders != nil {
@@ -4761,7 +4784,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
4761 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)4784 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
4762 i.mu.Lock()4785 i.mu.Lock()
4763 if refreshErr != nil {4786 if refreshErr != nil {
4764- i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()4787+ i.statusErr = "model refresh: " + refreshErr.Error()
4765 i.statusOK = ""4788 i.statusOK = ""
4766 } else if i.statusOK == "refreshing models" {4789 } else if i.statusOK == "refreshing models" {
4767 i.statusOK = ""4790 i.statusOK = ""
M packages/agent/modes/interactive_model_test.go +53 -0
@@ -5,6 +5,7 @@ import (
55 "slices"
66 "strings"
77 "testing"
8+ "time"
89
910 "github.com/patriceckhart/zot/packages/core"
1011 "github.com/patriceckhart/zot/packages/tui"
@@ -139,3 +140,55 @@ func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) {
139140 t.Fatal("refresh status shown without llama.cpp router configuration")
140141 }
141142 }
143+
144+func TestModelPickerRefreshesDiscoverableCustomProviders(t *testing.T) {
145+ refreshed := make(chan struct{}, 1)
146+ i := &Interactive{
147+ cfg: InteractiveConfig{
148+ CustomDiscoveryConfigured: func() bool { return true },
149+ RefreshCustomProviderModels: func(context.Context) error {
150+ refreshed <- struct{}{}
151+ return nil
152+ },
153+ },
154+ modelRefresh: make(chan modelRefreshResult, 1),
155+ modelDialog: newModelDialog(),
156+ }
157+
158+ i.runSlash(context.Background(), "/model")
159+
160+ if i.modelDialog.Active() {
161+ t.Fatal("model picker opened before the custom provider refresh finished")
162+ }
163+ if i.statusOK != "refreshing models" {
164+ t.Fatalf("status = %q", i.statusOK)
165+ }
166+ select {
167+ case <-refreshed:
168+ case <-time.After(5 * time.Second):
169+ t.Fatal("custom provider refresh was not invoked")
170+ }
171+ select {
172+ case result := <-i.modelRefresh:
173+ i.openModelPickerAfterRefresh(result.err)
174+ case <-time.After(5 * time.Second):
175+ t.Fatal("refresh result never delivered")
176+ }
177+ if !i.modelDialog.Active() {
178+ t.Fatal("model picker did not open after refresh")
179+ }
180+
181+ // Without opt-in, the picker opens immediately even when the hook exists.
182+ i = &Interactive{
183+ cfg: InteractiveConfig{
184+ CustomDiscoveryConfigured: func() bool { return false },
185+ RefreshCustomProviderModels: func(context.Context) error { t.Error("unexpected refresh"); return nil },
186+ },
187+ modelRefresh: make(chan modelRefreshResult, 1),
188+ modelDialog: newModelDialog(),
189+ }
190+ i.runSlash(context.Background(), "/model")
191+ if !i.modelDialog.Active() {
192+ t.Fatal("model picker did not open immediately")
193+ }
194+}
@@ -5,6 +5,7 @@ import (
5 "slices"5 "slices"
6 "strings"6 "strings"
7 "testing"7 "testing"
8+ "time"
8 9
9 "github.com/patriceckhart/zot/packages/core"10 "github.com/patriceckhart/zot/packages/core"
10 "github.com/patriceckhart/zot/packages/tui"11 "github.com/patriceckhart/zot/packages/tui"
@@ -139,3 +140,55 @@ func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) {
139 t.Fatal("refresh status shown without llama.cpp router configuration")140 t.Fatal("refresh status shown without llama.cpp router configuration")
140 }141 }
141 }142 }
143+
144+func TestModelPickerRefreshesDiscoverableCustomProviders(t *testing.T) {
145+ refreshed := make(chan struct{}, 1)
146+ i := &Interactive{
147+ cfg: InteractiveConfig{
148+ CustomDiscoveryConfigured: func() bool { return true },
149+ RefreshCustomProviderModels: func(context.Context) error {
150+ refreshed <- struct{}{}
151+ return nil
152+ },
153+ },
154+ modelRefresh: make(chan modelRefreshResult, 1),
155+ modelDialog: newModelDialog(),
156+ }
157+
158+ i.runSlash(context.Background(), "/model")
159+
160+ if i.modelDialog.Active() {
161+ t.Fatal("model picker opened before the custom provider refresh finished")
162+ }
163+ if i.statusOK != "refreshing models" {
164+ t.Fatalf("status = %q", i.statusOK)
165+ }
166+ select {
167+ case <-refreshed:
168+ case <-time.After(5 * time.Second):
169+ t.Fatal("custom provider refresh was not invoked")
170+ }
171+ select {
172+ case result := <-i.modelRefresh:
173+ i.openModelPickerAfterRefresh(result.err)
174+ case <-time.After(5 * time.Second):
175+ t.Fatal("refresh result never delivered")
176+ }
177+ if !i.modelDialog.Active() {
178+ t.Fatal("model picker did not open after refresh")
179+ }
180+
181+ // Without opt-in, the picker opens immediately even when the hook exists.
182+ i = &Interactive{
183+ cfg: InteractiveConfig{
184+ CustomDiscoveryConfigured: func() bool { return false },
185+ RefreshCustomProviderModels: func(context.Context) error { t.Error("unexpected refresh"); return nil },
186+ },
187+ modelRefresh: make(chan modelRefreshResult, 1),
188+ modelDialog: newModelDialog(),
189+ }
190+ i.runSlash(context.Background(), "/model")
191+ if !i.modelDialog.Active() {
192+ t.Fatal("model picker did not open immediately")
193+ }
194+}
A packages/provider/discover_custom.go +103 -0
@@ -0,0 +1,103 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "net/url"
9+ "strings"
10+ "time"
11+)
12+
13+// Conservative metadata for models that a list endpoint describes by ID
14+// only. Explicit models.json entries override these values.
15+const (
16+ customDiscoveryContextWindow = 32768
17+ customDiscoveryMaxOutput = 4096
18+)
19+
20+// customModelsURL derives the list endpoint from a custom provider base URL.
21+// A base that already ends in an API version segment such as "/v1" gets
22+// "/models" appended; a bare host gets "/v1/models".
23+func customModelsURL(baseURL string) (string, error) {
24+ u, err := url.Parse(strings.TrimSpace(baseURL))
25+ if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
26+ return "", fmt.Errorf("custom provider base URL must be an http or https URL")
27+ }
28+ u.Path = strings.TrimRight(u.Path, "/")
29+ u.RawPath = ""
30+ u.RawQuery = ""
31+ u.Fragment = ""
32+ base := u.String()
33+ if versionSegmentSuffix.MatchString(base) {
34+ return base + "/models", nil
35+ }
36+ return base + "/v1/models", nil
37+}
38+
39+// DiscoverCustomProvider lists models from a user-defined provider's
40+// OpenAI-style /models endpoint. Only IDs are trusted from the response;
41+// context and output limits use conservative defaults because
42+// self-hosted gateways rarely report them consistently. Entries whose ID
43+// mentions embeddings are skipped. Discovered models carry the provider's
44+// base URL so they route like models.json entries.
45+func DiscoverCustomProvider(ctx context.Context, providerID, baseURL, apiKey string) ([]Model, error) {
46+ providerID = strings.TrimSpace(providerID)
47+ if providerID == "" {
48+ return nil, fmt.Errorf("custom provider discovery requires a provider id")
49+ }
50+ endpoint, err := customModelsURL(baseURL)
51+ if err != nil {
52+ return nil, err
53+ }
54+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
55+ if err != nil {
56+ return nil, err
57+ }
58+ if apiKey != "" {
59+ req.Header.Set("Authorization", "Bearer "+apiKey)
60+ }
61+ client := &http.Client{Timeout: 3 * time.Second}
62+ resp, err := client.Do(req)
63+ if err != nil {
64+ if ctx.Err() != nil {
65+ return nil, ctx.Err()
66+ }
67+ return nil, fmt.Errorf("%s discovery request failed", providerID)
68+ }
69+ defer resp.Body.Close()
70+ if resp.StatusCode != http.StatusOK {
71+ return nil, fmt.Errorf("%s discovery HTTP %d", providerID, resp.StatusCode)
72+ }
73+ var page struct {
74+ Data []struct {
75+ ID string `json:"id"`
76+ } `json:"data"`
77+ }
78+ if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 8<<20)).Decode(&page); err != nil {
79+ return nil, fmt.Errorf("%s discovery returned invalid model data", providerID)
80+ }
81+ if page.Data == nil {
82+ return nil, fmt.Errorf("%s discovery response is missing data", providerID)
83+ }
84+ var models []Model
85+ seen := map[string]bool{}
86+ for _, d := range page.Data {
87+ id := strings.TrimSpace(d.ID)
88+ if id == "" || seen[id] || strings.Contains(strings.ToLower(id), "embed") {
89+ continue
90+ }
91+ seen[id] = true
92+ models = append(models, Model{
93+ Provider: providerID,
94+ ID: id,
95+ DisplayName: id,
96+ ContextWindow: customDiscoveryContextWindow,
97+ MaxOutput: customDiscoveryMaxOutput,
98+ BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
99+ Source: "live",
100+ })
101+ }
102+ return models, nil
103+}
@@ -0,0 +1,103 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "net/url"
9+ "strings"
10+ "time"
11+)
12+
13+// Conservative metadata for models that a list endpoint describes by ID
14+// only. Explicit models.json entries override these values.
15+const (
16+ customDiscoveryContextWindow = 32768
17+ customDiscoveryMaxOutput = 4096
18+)
19+
20+// customModelsURL derives the list endpoint from a custom provider base URL.
21+// A base that already ends in an API version segment such as "/v1" gets
22+// "/models" appended; a bare host gets "/v1/models".
23+func customModelsURL(baseURL string) (string, error) {
24+ u, err := url.Parse(strings.TrimSpace(baseURL))
25+ if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
26+ return "", fmt.Errorf("custom provider base URL must be an http or https URL")
27+ }
28+ u.Path = strings.TrimRight(u.Path, "/")
29+ u.RawPath = ""
30+ u.RawQuery = ""
31+ u.Fragment = ""
32+ base := u.String()
33+ if versionSegmentSuffix.MatchString(base) {
34+ return base + "/models", nil
35+ }
36+ return base + "/v1/models", nil
37+}
38+
39+// DiscoverCustomProvider lists models from a user-defined provider's
40+// OpenAI-style /models endpoint. Only IDs are trusted from the response;
41+// context and output limits use conservative defaults because
42+// self-hosted gateways rarely report them consistently. Entries whose ID
43+// mentions embeddings are skipped. Discovered models carry the provider's
44+// base URL so they route like models.json entries.
45+func DiscoverCustomProvider(ctx context.Context, providerID, baseURL, apiKey string) ([]Model, error) {
46+ providerID = strings.TrimSpace(providerID)
47+ if providerID == "" {
48+ return nil, fmt.Errorf("custom provider discovery requires a provider id")
49+ }
50+ endpoint, err := customModelsURL(baseURL)
51+ if err != nil {
52+ return nil, err
53+ }
54+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
55+ if err != nil {
56+ return nil, err
57+ }
58+ if apiKey != "" {
59+ req.Header.Set("Authorization", "Bearer "+apiKey)
60+ }
61+ client := &http.Client{Timeout: 3 * time.Second}
62+ resp, err := client.Do(req)
63+ if err != nil {
64+ if ctx.Err() != nil {
65+ return nil, ctx.Err()
66+ }
67+ return nil, fmt.Errorf("%s discovery request failed", providerID)
68+ }
69+ defer resp.Body.Close()
70+ if resp.StatusCode != http.StatusOK {
71+ return nil, fmt.Errorf("%s discovery HTTP %d", providerID, resp.StatusCode)
72+ }
73+ var page struct {
74+ Data []struct {
75+ ID string `json:"id"`
76+ } `json:"data"`
77+ }
78+ if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 8<<20)).Decode(&page); err != nil {
79+ return nil, fmt.Errorf("%s discovery returned invalid model data", providerID)
80+ }
81+ if page.Data == nil {
82+ return nil, fmt.Errorf("%s discovery response is missing data", providerID)
83+ }
84+ var models []Model
85+ seen := map[string]bool{}
86+ for _, d := range page.Data {
87+ id := strings.TrimSpace(d.ID)
88+ if id == "" || seen[id] || strings.Contains(strings.ToLower(id), "embed") {
89+ continue
90+ }
91+ seen[id] = true
92+ models = append(models, Model{
93+ Provider: providerID,
94+ ID: id,
95+ DisplayName: id,
96+ ContextWindow: customDiscoveryContextWindow,
97+ MaxOutput: customDiscoveryMaxOutput,
98+ BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
99+ Source: "live",
100+ })
101+ }
102+ return models, nil
103+}
A packages/provider/discover_custom_test.go +108 -0
@@ -0,0 +1,108 @@
1+package provider
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+)
11+
12+func TestCustomModelsURL(t *testing.T) {
13+ cases := map[string]string{
14+ "http://127.0.0.1:48000": "http://127.0.0.1:48000/v1/models",
15+ "http://127.0.0.1:48000/": "http://127.0.0.1:48000/v1/models",
16+ "http://127.0.0.1:48000/v1": "http://127.0.0.1:48000/v1/models",
17+ "http://127.0.0.1:48000/v1/": "http://127.0.0.1:48000/v1/models",
18+ "https://llm.example.com/paas/v4": "https://llm.example.com/paas/v4/models",
19+ }
20+ for in, want := range cases {
21+ got, err := customModelsURL(in)
22+ if err != nil || got != want {
23+ t.Errorf("customModelsURL(%q) = %q, %v; want %q", in, got, err, want)
24+ }
25+ }
26+ for _, bad := range []string{"", "localhost:1234", "ftp://x", "/v1"} {
27+ if _, err := customModelsURL(bad); err == nil {
28+ t.Errorf("customModelsURL(%q) accepted", bad)
29+ }
30+ }
31+}
32+
33+func TestDiscoverCustomProvider(t *testing.T) {
34+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35+ if r.URL.Path != "/v1/models" {
36+ t.Errorf("unexpected path %s", r.URL.Path)
37+ }
38+ if r.Header.Get("Authorization") != "Bearer dummy" {
39+ t.Errorf("missing bearer header: %q", r.Header.Get("Authorization"))
40+ }
41+ fmt.Fprint(w, `{"object":"list","data":[{"id":"qwen3-coder"},{"id":"nomic-embed-text"},{"id":"qwen3-coder"},{"id":""},{"id":"gpt-oss-20b"}]}`)
42+ }))
43+ defer server.Close()
44+
45+ models, err := DiscoverCustomProvider(context.Background(), "m4", server.URL+"/v1", "dummy")
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ if len(models) != 2 {
50+ t.Fatalf("models = %+v", models)
51+ }
52+ first := models[0]
53+ if first.Provider != "m4" || first.ID != "qwen3-coder" || first.Source != "live" || first.BaseURL != server.URL+"/v1" {
54+ t.Fatalf("model = %+v", first)
55+ }
56+ if first.ContextWindow != customDiscoveryContextWindow || first.MaxOutput != customDiscoveryMaxOutput {
57+ t.Fatalf("defaults = %d/%d", first.ContextWindow, first.MaxOutput)
58+ }
59+ if models[1].ID != "gpt-oss-20b" {
60+ t.Fatalf("second model = %+v", models[1])
61+ }
62+}
63+
64+func TestDiscoverCustomProviderOmitsHeaderWithoutKey(t *testing.T) {
65+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+ if _, ok := r.Header["Authorization"]; ok {
67+ t.Error("unexpected Authorization header")
68+ }
69+ fmt.Fprint(w, `{"data":[{"id":"local"}]}`)
70+ }))
71+ defer server.Close()
72+ models, err := DiscoverCustomProvider(context.Background(), "m5", server.URL, "")
73+ if err != nil || len(models) != 1 {
74+ t.Fatalf("models=%+v err=%v", models, err)
75+ }
76+}
77+
78+func TestDiscoverCustomProviderErrors(t *testing.T) {
79+ status := http.StatusUnauthorized
80+ body := `{}`
81+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82+ w.WriteHeader(status)
83+ fmt.Fprint(w, body)
84+ }))
85+ defer server.Close()
86+
87+ if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil || err.Error() != "m4 discovery HTTP 401" {
88+ t.Fatalf("status error = %v", err)
89+ }
90+ status = http.StatusOK
91+ body = `{"object":"list"}`
92+ if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil {
93+ t.Fatal("missing data accepted")
94+ }
95+ body = `not json`
96+ if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil {
97+ t.Fatal("invalid JSON accepted")
98+ }
99+ if _, err := DiscoverCustomProvider(context.Background(), "", server.URL, "k"); err == nil {
100+ t.Fatal("empty provider id accepted")
101+ }
102+
103+ ctx, cancel := context.WithCancel(context.Background())
104+ cancel()
105+ if _, err := DiscoverCustomProvider(ctx, "m4", server.URL, "k"); !errors.Is(err, context.Canceled) {
106+ t.Fatalf("cancellation error = %v", err)
107+ }
108+}
@@ -0,0 +1,108 @@
1+package provider
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+)
11+
12+func TestCustomModelsURL(t *testing.T) {
13+ cases := map[string]string{
14+ "http://127.0.0.1:48000": "http://127.0.0.1:48000/v1/models",
15+ "http://127.0.0.1:48000/": "http://127.0.0.1:48000/v1/models",
16+ "http://127.0.0.1:48000/v1": "http://127.0.0.1:48000/v1/models",
17+ "http://127.0.0.1:48000/v1/": "http://127.0.0.1:48000/v1/models",
18+ "https://llm.example.com/paas/v4": "https://llm.example.com/paas/v4/models",
19+ }
20+ for in, want := range cases {
21+ got, err := customModelsURL(in)
22+ if err != nil || got != want {
23+ t.Errorf("customModelsURL(%q) = %q, %v; want %q", in, got, err, want)
24+ }
25+ }
26+ for _, bad := range []string{"", "localhost:1234", "ftp://x", "/v1"} {
27+ if _, err := customModelsURL(bad); err == nil {
28+ t.Errorf("customModelsURL(%q) accepted", bad)
29+ }
30+ }
31+}
32+
33+func TestDiscoverCustomProvider(t *testing.T) {
34+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35+ if r.URL.Path != "/v1/models" {
36+ t.Errorf("unexpected path %s", r.URL.Path)
37+ }
38+ if r.Header.Get("Authorization") != "Bearer dummy" {
39+ t.Errorf("missing bearer header: %q", r.Header.Get("Authorization"))
40+ }
41+ fmt.Fprint(w, `{"object":"list","data":[{"id":"qwen3-coder"},{"id":"nomic-embed-text"},{"id":"qwen3-coder"},{"id":""},{"id":"gpt-oss-20b"}]}`)
42+ }))
43+ defer server.Close()
44+
45+ models, err := DiscoverCustomProvider(context.Background(), "m4", server.URL+"/v1", "dummy")
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ if len(models) != 2 {
50+ t.Fatalf("models = %+v", models)
51+ }
52+ first := models[0]
53+ if first.Provider != "m4" || first.ID != "qwen3-coder" || first.Source != "live" || first.BaseURL != server.URL+"/v1" {
54+ t.Fatalf("model = %+v", first)
55+ }
56+ if first.ContextWindow != customDiscoveryContextWindow || first.MaxOutput != customDiscoveryMaxOutput {
57+ t.Fatalf("defaults = %d/%d", first.ContextWindow, first.MaxOutput)
58+ }
59+ if models[1].ID != "gpt-oss-20b" {
60+ t.Fatalf("second model = %+v", models[1])
61+ }
62+}
63+
64+func TestDiscoverCustomProviderOmitsHeaderWithoutKey(t *testing.T) {
65+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+ if _, ok := r.Header["Authorization"]; ok {
67+ t.Error("unexpected Authorization header")
68+ }
69+ fmt.Fprint(w, `{"data":[{"id":"local"}]}`)
70+ }))
71+ defer server.Close()
72+ models, err := DiscoverCustomProvider(context.Background(), "m5", server.URL, "")
73+ if err != nil || len(models) != 1 {
74+ t.Fatalf("models=%+v err=%v", models, err)
75+ }
76+}
77+
78+func TestDiscoverCustomProviderErrors(t *testing.T) {
79+ status := http.StatusUnauthorized
80+ body := `{}`
81+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82+ w.WriteHeader(status)
83+ fmt.Fprint(w, body)
84+ }))
85+ defer server.Close()
86+
87+ if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil || err.Error() != "m4 discovery HTTP 401" {
88+ t.Fatalf("status error = %v", err)
89+ }
90+ status = http.StatusOK
91+ body = `{"object":"list"}`
92+ if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil {
93+ t.Fatal("missing data accepted")
94+ }
95+ body = `not json`
96+ if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil {
97+ t.Fatal("invalid JSON accepted")
98+ }
99+ if _, err := DiscoverCustomProvider(context.Background(), "", server.URL, "k"); err == nil {
100+ t.Fatal("empty provider id accepted")
101+ }
102+
103+ ctx, cancel := context.WithCancel(context.Background())
104+ cancel()
105+ if _, err := DiscoverCustomProvider(ctx, "m4", server.URL, "k"); !errors.Is(err, context.Canceled) {
106+ t.Fatalf("cancellation error = %v", err)
107+ }
108+}
M packages/provider/usermodels.go +20 -5
@@ -36,9 +36,13 @@ type UserModelsFile struct {
3636
3737 // UserProvider groups models under a provider key.
3838 type UserProvider struct {
39- BaseURL string `json:"baseUrl,omitempty"`
40- API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"
41- Models []UserModel `json:"models"`
39+ BaseURL string `json:"baseUrl,omitempty"`
40+ API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"
41+ // Discover enables live model listing from the provider-level
42+ // baseUrl's /models endpoint. Off by default so existing custom
43+ // providers never gain unexpected network traffic.
44+ Discover bool `json:"discover,omitempty"`
45+ Models []UserModel `json:"models"`
4246 }
4347
4448 // CustomProviderConfig holds runtime config for a user-defined provider
@@ -46,6 +50,9 @@ type UserProvider struct {
4650 type CustomProviderConfig struct {
4751 BaseURL string
4852 API string // "openai", "openai-responses", or "anthropic"
53+ // Discover reports whether live model discovery is enabled. It is
54+ // only true when a provider-level BaseURL is present.
55+ Discover bool
4956 }
5057
5158 var customProviders = map[string]CustomProviderConfig{}
@@ -158,6 +165,13 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
158165 break
159166 }
160167 }
168+ // Discovery needs one endpoint to list from, so it is tied to the
169+ // provider-level baseUrl rather than per-model overrides.
170+ discover := prov.Discover
171+ if discover && strings.TrimSpace(prov.BaseURL) == "" {
172+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName))
173+ discover = false
174+ }
161175 if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {
162176 api := strings.ToLower(strings.TrimSpace(prov.API))
163177 if api == "" {
@@ -176,8 +190,9 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
176190 api = "openai"
177191 }
178192 customProviders[normalized] = CustomProviderConfig{
179- BaseURL: prov.BaseURL,
180- API: api,
193+ BaseURL: prov.BaseURL,
194+ API: api,
195+ Discover: discover,
181196 }
182197 }
183198
@@ -36,9 +36,13 @@ type UserModelsFile struct {
36 36
37 // UserProvider groups models under a provider key.37 // UserProvider groups models under a provider key.
38 type UserProvider struct {38 type UserProvider struct {
39- BaseURL string `json:"baseUrl,omitempty"`39+ BaseURL string `json:"baseUrl,omitempty"`
40- API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"40+ API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"
41- Models []UserModel `json:"models"`41+ // Discover enables live model listing from the provider-level
42+ // baseUrl's /models endpoint. Off by default so existing custom
43+ // providers never gain unexpected network traffic.
44+ Discover bool `json:"discover,omitempty"`
45+ Models []UserModel `json:"models"`
42 }46 }
43 47
44 // CustomProviderConfig holds runtime config for a user-defined provider48 // CustomProviderConfig holds runtime config for a user-defined provider
@@ -46,6 +50,9 @@ type UserProvider struct {
46 type CustomProviderConfig struct {50 type CustomProviderConfig struct {
47 BaseURL string51 BaseURL string
48 API string // "openai", "openai-responses", or "anthropic"52 API string // "openai", "openai-responses", or "anthropic"
53+ // Discover reports whether live model discovery is enabled. It is
54+ // only true when a provider-level BaseURL is present.
55+ Discover bool
49 }56 }
50 57
51 var customProviders = map[string]CustomProviderConfig{}58 var customProviders = map[string]CustomProviderConfig{}
@@ -158,6 +165,13 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
158 break165 break
159 }166 }
160 }167 }
168+ // Discovery needs one endpoint to list from, so it is tied to the
169+ // provider-level baseUrl rather than per-model overrides.
170+ discover := prov.Discover
171+ if discover && strings.TrimSpace(prov.BaseURL) == "" {
172+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName))
173+ discover = false
174+ }
161 if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {175 if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {
162 api := strings.ToLower(strings.TrimSpace(prov.API))176 api := strings.ToLower(strings.TrimSpace(prov.API))
163 if api == "" {177 if api == "" {
@@ -176,8 +190,9 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
176 api = "openai"190 api = "openai"
177 }191 }
178 customProviders[normalized] = CustomProviderConfig{192 customProviders[normalized] = CustomProviderConfig{
179- BaseURL: prov.BaseURL,193+ BaseURL: prov.BaseURL,
180- API: api,194+ API: api,
195+ Discover: discover,
181 }196 }
182 }197 }
183 198
M packages/provider/usermodels_test.go +38 -0
@@ -168,3 +168,41 @@ func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {
168168 t.Fatalf("api = %q, want openai", cfg.API)
169169 }
170170 }
171+
172+func TestLoadUserModelsDiscoverFlag(t *testing.T) {
173+ dir := t.TempDir()
174+ path := filepath.Join(dir, "models.json")
175+ if err := os.WriteFile(path, []byte(`{
176+ "providers": {
177+ "m4": {
178+ "baseUrl": "http://127.0.0.1:48000/v1",
179+ "discover": true
180+ },
181+ "m5": {
182+ "baseUrl": "http://127.0.0.1:48001/v1",
183+ "models": [{"id": "pinned"}]
184+ },
185+ "no-base": {
186+ "discover": true,
187+ "models": [{"id": "m1", "baseUrl": "http://127.0.0.1:48002/v1"}]
188+ }
189+ }
190+ }`), 0o644); err != nil {
191+ t.Fatal(err)
192+ }
193+
194+ _, warnings := LoadUserModelsWithWarnings(path)
195+ if len(warnings) != 1 || !strings.Contains(warnings[0], `"no-base"`) || !strings.Contains(warnings[0], "discover") {
196+ t.Fatalf("warnings = %v, want one discover warning for no-base", warnings)
197+ }
198+ cps := CustomProviders()
199+ if !cps["m4"].Discover {
200+ t.Fatal("m4 discovery not enabled")
201+ }
202+ if cps["m5"].Discover {
203+ t.Fatal("m5 discovery enabled without flag")
204+ }
205+ if cfg, ok := cps["no-base"]; !ok || cfg.Discover {
206+ t.Fatalf("no-base = %+v, %v; want registered without discovery", cfg, ok)
207+ }
208+}
@@ -168,3 +168,41 @@ func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {
168 t.Fatalf("api = %q, want openai", cfg.API)168 t.Fatalf("api = %q, want openai", cfg.API)
169 }169 }
170 }170 }
171+
172+func TestLoadUserModelsDiscoverFlag(t *testing.T) {
173+ dir := t.TempDir()
174+ path := filepath.Join(dir, "models.json")
175+ if err := os.WriteFile(path, []byte(`{
176+ "providers": {
177+ "m4": {
178+ "baseUrl": "http://127.0.0.1:48000/v1",
179+ "discover": true
180+ },
181+ "m5": {
182+ "baseUrl": "http://127.0.0.1:48001/v1",
183+ "models": [{"id": "pinned"}]
184+ },
185+ "no-base": {
186+ "discover": true,
187+ "models": [{"id": "m1", "baseUrl": "http://127.0.0.1:48002/v1"}]
188+ }
189+ }
190+ }`), 0o644); err != nil {
191+ t.Fatal(err)
192+ }
193+
194+ _, warnings := LoadUserModelsWithWarnings(path)
195+ if len(warnings) != 1 || !strings.Contains(warnings[0], `"no-base"`) || !strings.Contains(warnings[0], "discover") {
196+ t.Fatalf("warnings = %v, want one discover warning for no-base", warnings)
197+ }
198+ cps := CustomProviders()
199+ if !cps["m4"].Discover {
200+ t.Fatal("m4 discovery not enabled")
201+ }
202+ if cps["m5"].Discover {
203+ t.Fatal("m5 discovery enabled without flag")
204+ }
205+ if cfg, ok := cps["no-base"]; !ok || cfg.Discover {
206+ t.Fatalf("no-base = %+v, %v; want registered without discovery", cfg, ok)
207+ }
208+}

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