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 | |||
| 584 | 584 | 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 | 604 | ### Kimi Code | |
| 588 | 605 | ||
| 589 | 606 | 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-v2 | 584 | 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 Code | 604 | ### 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: | |||
| 484 | 484 | 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 | 540 | ## Credential resolution | |
| 488 | 541 | ||
| 489 | 542 | For each request, zot checks credentials in this order: | |
| @@ -484,6 +484,59 @@ custom model directly: | |||||
| 484 | zot --provider my-company --model company-llm-v2 | 484 | 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 resolution | 540 | ## 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) { | |||
| 446 | 446 | // Restore transient metadata when resuming a local model after restart. | |
| 447 | 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 | 456 | resolvedModel, err := provider.FindModel(provName, model) | |
| 450 | 457 | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID) { | |
| 451 | 458 | // 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 | 553 | // If the model defines a base URL (e.g. local ollama) and the | |
| 544 | 554 | // 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 the | 458 | // 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 the | 553 | // 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 { | |||
| 1379 | 1379 | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) | |
| 1380 | 1380 | } | |
| 1381 | 1381 | }, | |
| 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, | |
| 1391 | 1393 | LoggedInProviders: func() []string { | |
| 1392 | 1394 | var out []string | |
| 1393 | 1395 | 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 []string | 1394 | 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() { | |||
| 105 | 105 | // Gateway providers can serve routed model ids like | |
| 106 | 106 | // "deepseek/deepseek-v4-flash" even when the local catalog does not | |
| 107 | 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 | 109 | // 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. | |
| 112 | 113 | } else if m, err := provider.FindModel("", cfg.Model); err == nil { | |
| 113 | 114 | fix := defaultModelForProvider(cfg.Provider) | |
| 114 | 115 | fmt.Fprintf(os.Stderr, | |
| @@ -154,6 +155,11 @@ func RefreshModelsAsync() { | |||
| 154 | 155 | defer cancel() | |
| 155 | 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 | 165 | // 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 like | 105 | // Gateway providers can serve routed model ids like | ||
| 106 | // "deepseek/deepseek-v4-flash" even when the local catalog does not | 106 | // "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 Studio | 109 | // 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 { | |||
| 187 | 187 | RefreshLMStudioModels func(context.Context) error | |
| 188 | 188 | 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 | 196 | // BuildAgent is called after a successful login to (re)construct the | |
| 191 | 197 | // agent with the fresh credential. It returns the new agent and | |
| 192 | 198 | // the concrete provider/model in use. | |
| @@ -4551,7 +4557,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4551 | 4557 | case "/model": | |
| 4552 | 4558 | if len(parts) >= 2 { | |
| 4553 | 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 | 4561 | i.mu.Lock() | |
| 4556 | 4562 | i.statusOK = "refreshing models" | |
| 4557 | 4563 | i.statusErr = "" | |
| @@ -4571,6 +4577,11 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4571 | 4577 | 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 | 4585 | select { | |
| 4575 | 4586 | case i.modelRefresh <- modelRefreshResult{err: err}: | |
| 4576 | 4587 | case <-ctx.Done(): | |
| @@ -4753,6 +4764,18 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4753 | 4764 | 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 | 4779 | func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |
| 4757 | 4780 | var loggedIn []string | |
| 4758 | 4781 | if i.cfg.LoggedInProviders != nil { | |
| @@ -4761,7 +4784,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |||
| 4761 | 4784 | i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning) | |
| 4762 | 4785 | i.mu.Lock() | |
| 4763 | 4786 | if refreshErr != nil { | |
| 4764 | - | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | |
| 4787 | + | i.statusErr = "model refresh: " + refreshErr.Error() | |
| 4765 | 4788 | i.statusOK = "" | |
| 4766 | 4789 | } else if i.statusOK == "refreshing models" { | |
| 4767 | 4790 | i.statusOK = "" | |
| @@ -187,6 +187,12 @@ type InteractiveConfig struct { | |||||
| 187 | RefreshLMStudioModels func(context.Context) error | 187 | RefreshLMStudioModels func(context.Context) error | ||
| 188 | LMStudioConfigured func() bool | 188 | 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 the | 196 | // BuildAgent is called after a successful login to (re)construct the | ||
| 191 | // agent with the fresh credential. It returns the new agent and | 197 | // 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 = llamaErr | 4577 | 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 false | 4764 | 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 []string | 4780 | 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 ( | |||
| 5 | 5 | "slices" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/patriceckhart/zot/packages/core" | |
| 10 | 11 | "github.com/patriceckhart/zot/packages/tui" | |
| @@ -139,3 +140,55 @@ func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) { | |||
| 139 | 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 | + | } | |
| @@ -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 { | |||
| 36 | 36 | ||
| 37 | 37 | // UserProvider groups models under a provider key. | |
| 38 | 38 | 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"` | |
| 42 | 46 | } | |
| 43 | 47 | ||
| 44 | 48 | // CustomProviderConfig holds runtime config for a user-defined provider | |
| @@ -46,6 +50,9 @@ type UserProvider struct { | |||
| 46 | 50 | type CustomProviderConfig struct { | |
| 47 | 51 | BaseURL string | |
| 48 | 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 | 58 | var customProviders = map[string]CustomProviderConfig{} | |
| @@ -158,6 +165,13 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 158 | 165 | 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 | 175 | if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL { | |
| 162 | 176 | api := strings.ToLower(strings.TrimSpace(prov.API)) | |
| 163 | 177 | if api == "" { | |
| @@ -176,8 +190,9 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 176 | 190 | api = "openai" | |
| 177 | 191 | } | |
| 178 | 192 | customProviders[normalized] = CustomProviderConfig{ | |
| 179 | - | BaseURL: prov.BaseURL, | |
| 180 | - | API: api, | |
| 193 | + | BaseURL: prov.BaseURL, | |
| 194 | + | API: api, | |
| 195 | + | Discover: discover, | |
| 181 | 196 | } | |
| 182 | 197 | } | |
| 183 | 198 | ||
| @@ -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 provider | 48 | // 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 string | 51 | 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 | break | 165 | 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) { | |||
| 168 | 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 | + | } | |
| @@ -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 | + | } | |||