Merge pull request #223 from Oscar-Williams/fix/custom-provider-keyless-auth
fix(provider): support keyless custom OpenAI endpoints
13 files · +320 -81
M README.md +2 -2
| @@ -609,7 +609,7 @@ A top-level provider key that is not a built-in id defines a custom provider. Gi | |||
| 609 | 609 | } | |
| 610 | 610 | ``` | |
| 611 | 611 | ||
| 612 | - | Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. Supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Because many self-hosted gateways do not expose a model-list endpoint, custom provider keys are accepted and stored without a verification probe; an invalid key surfaces on the first model call. | |
| 612 | + | Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. For endpoints that require authentication, supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Custom providers with a configured base URL also work without a key. For a keyless endpoint that must ignore an existing stored, environment, or CLI key, set `"auth": "none"` on its provider in `models.json`. This also disables credentials for model discovery. Only use this with trusted endpoints. Because many self-hosted gateways do not expose a model-list endpoint, custom provider keys are accepted and stored without a verification probe; an invalid key surfaces on the first model call. | |
| 613 | 613 | ||
| 614 | 614 | To retrieve this custom provider's key from a password manager, add a matching entry to `$ZOT_HOME/auth.json`: | |
| 615 | 615 | ||
| @@ -648,7 +648,7 @@ Set `"discover": true` on a custom provider to list its models from the OpenAI-c | |||
| 648 | 648 | } | |
| 649 | 649 | ``` | |
| 650 | 650 | ||
| 651 | - | 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`. | |
| 651 | + | 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, unless the provider sets `"auth": "none"`; 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`. | |
| 652 | 652 | ||
| 653 | 653 | ### Kimi Code | |
| 654 | 654 | ||
| @@ -609,7 +609,7 @@ A top-level provider key that is not a built-in id defines a custom provider. Gi | |||||
| 609 | } | 609 | } | ||
| 610 | ``` | 610 | ``` | ||
| 611 | 611 | ||||
| 612 | - | Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. Supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Because many self-hosted gateways do not expose a model-list endpoint, custom provider keys are accepted and stored without a verification probe; an invalid key surfaces on the first model call. | 612 | + | Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. For endpoints that require authentication, supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Custom providers with a configured base URL also work without a key. For a keyless endpoint that must ignore an existing stored, environment, or CLI key, set `"auth": "none"` on its provider in `models.json`. This also disables credentials for model discovery. Only use this with trusted endpoints. Because many self-hosted gateways do not expose a model-list endpoint, custom provider keys are accepted and stored without a verification probe; an invalid key surfaces on the first model call. |
| 613 | 613 | ||||
| 614 | To retrieve this custom provider's key from a password manager, add a matching entry to `$ZOT_HOME/auth.json`: | 614 | To retrieve this custom provider's key from a password manager, add a matching entry to `$ZOT_HOME/auth.json`: | ||
| 615 | 615 | ||||
| @@ -648,7 +648,7 @@ Set `"discover": true` on a custom provider to list its models from the OpenAI-c | |||||
| 648 | } | 648 | } | ||
| 649 | ``` | 649 | ``` | ||
| 650 | 650 | ||||
| 651 | - | 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`. | 651 | + | 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, unless the provider sets `"auth": "none"`; 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`. |
| 652 | 652 | ||||
| 653 | ### Kimi Code | 653 | ### Kimi Code | ||
| 654 | 654 | ||||
M docs/providers.md +13 -9
| @@ -470,11 +470,16 @@ unknown `api` value falls back to `openai` with a warning. | |||
| 470 | 470 | ``` | |
| 471 | 471 | ||
| 472 | 472 | Custom providers are first-class: they appear in `--list-models`, `/model`, and | |
| 473 | - | `/login`. `models.json` never stores secrets. Supply the key through `/login`, | |
| 474 | - | `--api-key`, or a derived environment variable in upper snake case, so | |
| 475 | - | `my-company` reads `MY_COMPANY_API_KEY`. Because many self-hosted gateways do | |
| 476 | - | not expose a model-list endpoint, custom provider keys are accepted and stored | |
| 477 | - | without a verification probe; an invalid key surfaces on the first model call. | |
| 473 | + | `/login`. `models.json` never stores secrets. If the endpoint requires a key, | |
| 474 | + | supply it through `/login`, `--api-key`, or a derived environment variable in | |
| 475 | + | upper snake case, so `my-company` reads `MY_COMPANY_API_KEY`. Because many | |
| 476 | + | self-hosted gateways do not expose a model-list endpoint, custom provider keys | |
| 477 | + | are accepted and stored without a verification probe; an invalid key surfaces | |
| 478 | + | on the first model call. Custom providers with a configured base URL also work | |
| 479 | + | without a key. If a stored, environment, or CLI key must be ignored for a | |
| 480 | + | keyless endpoint, set `"auth": "none"` on that provider in `models.json`. This | |
| 481 | + | also suppresses credentials during live model discovery. Use only with trusted | |
| 482 | + | endpoints, since requests will be unauthenticated even if a key is available. | |
| 478 | 483 | ||
| 479 | 484 | To retrieve this custom provider's key from a password manager, add a matching | |
| 480 | 485 | entry to `$ZOT_HOME/auth.json`: | |
| @@ -548,10 +553,9 @@ selection of a discovered model survives restarts even though the model is not | |||
| 548 | 553 | in `models.json`. The `/models` wire format is assumed to be OpenAI style | |
| 549 | 554 | (`{"data": [{"id": ...}]}`) regardless of the provider's `api` setting. | |
| 550 | 555 | ||
| 551 | - | As with every custom provider, `/model` only lists it once a credential exists. | |
| 552 | - | For a keyless local server, store any placeholder value through `/login` or the | |
| 553 | - | derived environment variable. `--list-models` prints the catalog before the | |
| 554 | - | background refresh completes, so discovered models may be missing there. | |
| 556 | + | `/model` also lists custom providers with a configured base URL and no key. | |
| 557 | + | `--list-models` prints the catalog before the background refresh completes, | |
| 558 | + | so discovered models may be missing there. | |
| 555 | 559 | ||
| 556 | 560 | ## Credential resolution | |
| 557 | 561 | ||
| @@ -470,11 +470,16 @@ unknown `api` value falls back to `openai` with a warning. | |||||
| 470 | ``` | 470 | ``` | ||
| 471 | 471 | ||||
| 472 | Custom providers are first-class: they appear in `--list-models`, `/model`, and | 472 | Custom providers are first-class: they appear in `--list-models`, `/model`, and | ||
| 473 | - | `/login`. `models.json` never stores secrets. Supply the key through `/login`, | 473 | + | `/login`. `models.json` never stores secrets. If the endpoint requires a key, |
| 474 | - | `--api-key`, or a derived environment variable in upper snake case, so | 474 | + | supply it through `/login`, `--api-key`, or a derived environment variable in |
| 475 | - | `my-company` reads `MY_COMPANY_API_KEY`. Because many self-hosted gateways do | 475 | + | upper snake case, so `my-company` reads `MY_COMPANY_API_KEY`. Because many |
| 476 | - | not expose a model-list endpoint, custom provider keys are accepted and stored | 476 | + | self-hosted gateways do not expose a model-list endpoint, custom provider keys |
| 477 | - | without a verification probe; an invalid key surfaces on the first model call. | 477 | + | are accepted and stored without a verification probe; an invalid key surfaces |
| 478 | + | on the first model call. Custom providers with a configured base URL also work | |||
| 479 | + | without a key. If a stored, environment, or CLI key must be ignored for a | |||
| 480 | + | keyless endpoint, set `"auth": "none"` on that provider in `models.json`. This | |||
| 481 | + | also suppresses credentials during live model discovery. Use only with trusted | |||
| 482 | + | endpoints, since requests will be unauthenticated even if a key is available. | |||
| 478 | 483 | ||||
| 479 | To retrieve this custom provider's key from a password manager, add a matching | 484 | To retrieve this custom provider's key from a password manager, add a matching | ||
| 480 | entry to `$ZOT_HOME/auth.json`: | 485 | entry to `$ZOT_HOME/auth.json`: | ||
| @@ -548,10 +553,9 @@ selection of a discovered model survives restarts even though the model is not | |||||
| 548 | in `models.json`. The `/models` wire format is assumed to be OpenAI style | 553 | in `models.json`. The `/models` wire format is assumed to be OpenAI style | ||
| 549 | (`{"data": [{"id": ...}]}`) regardless of the provider's `api` setting. | 554 | (`{"data": [{"id": ...}]}`) regardless of the provider's `api` setting. | ||
| 550 | 555 | ||||
| 551 | - | As with every custom provider, `/model` only lists it once a credential exists. | 556 | + | `/model` also lists custom providers with a configured base URL and no key. |
| 552 | - | For a keyless local server, store any placeholder value through `/login` or the | 557 | + | `--list-models` prints the catalog before the background refresh completes, |
| 553 | - | derived environment variable. `--list-models` prints the catalog before the | 558 | + | so discovered models may be missing there. |
| 554 | - | background refresh completes, so discovered models may be missing there. | |||
| 555 | 559 | ||||
| 556 | ## Credential resolution | 560 | ## Credential resolution | ||
| 557 | 561 | ||||
M packages/agent/build.go +63 -41
| @@ -66,14 +66,18 @@ type Resolved struct { | |||
| 66 | 66 | // Bookkeeping for MergeExtensionTools. Captured at Resolve time | |
| 67 | 67 | // so the system prompt can be rebuilt later without re-running | |
| 68 | 68 | // resolve. | |
| 69 | - | systemAppend []string | |
| 70 | - | systemCustom string | |
| 71 | - | systemCustomSet bool | |
| 72 | - | toolDescriptions map[string]string | |
| 69 | + | credentialOptional bool | |
| 70 | + | systemAppend []string | |
| 71 | + | systemCustom string | |
| 72 | + | systemCustomSet bool | |
| 73 | + | toolDescriptions map[string]string | |
| 73 | 74 | } | |
| 74 | 75 | ||
| 75 | - | // HasCredential reports whether a credential was resolved. | |
| 76 | - | func (r Resolved) HasCredential() bool { return r.Credential != "" } | |
| 76 | + | // HasCredential reports whether the resolved configuration can create a client. | |
| 77 | + | // Configured custom endpoints may intentionally omit an API credential. | |
| 78 | + | func (r Resolved) HasCredential() bool { | |
| 79 | + | return r.Credential != "" || r.credentialOptional | |
| 80 | + | } | |
| 77 | 81 | ||
| 78 | 82 | // MergeExtensionTools folds every tool registered by an extension | |
| 79 | 83 | // into r's ToolRegistry and re-renders the system prompt's tool | |
| @@ -363,7 +367,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 363 | 367 | accountID string | |
| 364 | 368 | credErr error | |
| 365 | 369 | ) | |
| 366 | - | if args.inheritedCredential != "" { | |
| 370 | + | customCfg, customProvider := provider.CustomProviders()[provName] | |
| 371 | + | keylessCustom := customProvider && !isBuiltinProvider(provName) && customCfg.NoAuth | |
| 372 | + | if keylessCustom { | |
| 373 | + | // Explicit keyless configuration takes precedence over CLI, env, and | |
| 374 | + | // stored credentials. Never execute a stored API key command. | |
| 375 | + | } else if args.inheritedCredential != "" { | |
| 367 | 376 | cred = args.inheritedCredential | |
| 368 | 377 | method = args.inheritedAuthMethod | |
| 369 | 378 | accountID = args.inheritedAccountID | |
| @@ -376,7 +385,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 376 | 385 | ||
| 377 | 386 | // Persist --api-key for custom providers so subsequent runs don't | |
| 378 | 387 | // need to pass it again. | |
| 379 | - | if !isBuiltinProvider(provName) && args.APIKey != "" { | |
| 388 | + | if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" { | |
| 380 | 389 | if store := AuthStoreFor(); store != nil { | |
| 381 | 390 | _ = store.SetAPIKey(provName, args.APIKey) | |
| 382 | 391 | } | |
| @@ -594,13 +603,25 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 594 | 603 | ||
| 595 | 604 | // If the model has a base URL, credentials are optional (local | |
| 596 | 605 | // models like ollama don't need real API keys). | |
| 597 | - | if resolvedModel.BaseURL != "" && credErr != nil { | |
| 598 | - | cred = "ollama" | |
| 599 | - | if provName == "amazon-bedrock" { | |
| 600 | - | // The default AWS CLI profile is resolved by the Bedrock client, | |
| 601 | - | // not the general credential resolver. Never pass the local-model | |
| 602 | - | // placeholder as a bearer token. | |
| 603 | - | cred = "<aws>" | |
| 606 | + | credentialOptional := false | |
| 607 | + | if keylessCustom { | |
| 608 | + | if args.BaseURL == "" { | |
| 609 | + | return Resolved{}, fmt.Errorf("custom provider %q has auth none but no base URL", provName) | |
| 610 | + | } | |
| 611 | + | credentialOptional = true | |
| 612 | + | } else if resolvedModel.BaseURL != "" && credErr != nil { | |
| 613 | + | if _, isCustom := provider.CustomProviders()[provName]; isCustom { | |
| 614 | + | // A custom endpoint may be configured for keyless access. Keep the | |
| 615 | + | // credential empty so the provider does not send a placeholder token. | |
| 616 | + | credentialOptional = true | |
| 617 | + | } else { | |
| 618 | + | cred = "ollama" | |
| 619 | + | if provName == "amazon-bedrock" { | |
| 620 | + | // The default AWS CLI profile is resolved by the Bedrock client, | |
| 621 | + | // not the general credential resolver. Never pass the local-model | |
| 622 | + | // placeholder as a bearer token. | |
| 623 | + | cred = "<aws>" | |
| 624 | + | } | |
| 604 | 625 | } | |
| 605 | 626 | credErr = nil | |
| 606 | 627 | requireCred = false | |
| @@ -713,29 +734,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 713 | 734 | max := args.MaxSteps // 0 = unlimited | |
| 714 | 735 | ||
| 715 | 736 | return Resolved{ | |
| 716 | - | Provider: provName, | |
| 717 | - | Model: model, | |
| 718 | - | Credential: cred, | |
| 719 | - | AuthMethod: method, | |
| 720 | - | AccountID: accountID, | |
| 721 | - | BaseURL: args.BaseURL, | |
| 722 | - | InsecureTLS: insecureTLS, | |
| 723 | - | CWD: args.CWD, | |
| 724 | - | Reasoning: reasoning, | |
| 725 | - | Temperature: temperature, | |
| 726 | - | ToolRegistry: reg, | |
| 727 | - | ToolSummary: summaries, | |
| 728 | - | SystemPrompt: sys, | |
| 729 | - | MaxSteps: max, | |
| 730 | - | MaxOutput: resolvedModel.MaxOutput, | |
| 731 | - | Sandbox: sandbox, | |
| 732 | - | SkillTool: skillTool, | |
| 733 | - | SkillDiagnostics: skillDiagnostics, | |
| 734 | - | ContextFiles: contextFiles, | |
| 735 | - | systemAppend: append_, | |
| 736 | - | systemCustom: custom, | |
| 737 | - | systemCustomSet: customSet, | |
| 738 | - | toolDescriptions: descMapFromSummaries(summaries), | |
| 737 | + | Provider: provName, | |
| 738 | + | Model: model, | |
| 739 | + | Credential: cred, | |
| 740 | + | AuthMethod: method, | |
| 741 | + | AccountID: accountID, | |
| 742 | + | BaseURL: args.BaseURL, | |
| 743 | + | InsecureTLS: insecureTLS, | |
| 744 | + | CWD: args.CWD, | |
| 745 | + | Reasoning: reasoning, | |
| 746 | + | Temperature: temperature, | |
| 747 | + | ToolRegistry: reg, | |
| 748 | + | ToolSummary: summaries, | |
| 749 | + | SystemPrompt: sys, | |
| 750 | + | MaxSteps: max, | |
| 751 | + | MaxOutput: resolvedModel.MaxOutput, | |
| 752 | + | Sandbox: sandbox, | |
| 753 | + | SkillTool: skillTool, | |
| 754 | + | SkillDiagnostics: skillDiagnostics, | |
| 755 | + | ContextFiles: contextFiles, | |
| 756 | + | credentialOptional: credentialOptional, | |
| 757 | + | systemAppend: append_, | |
| 758 | + | systemCustom: custom, | |
| 759 | + | systemCustomSet: customSet, | |
| 760 | + | toolDescriptions: descMapFromSummaries(summaries), | |
| 739 | 761 | }, nil | |
| 740 | 762 | } | |
| 741 | 763 | ||
| @@ -844,11 +866,11 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string { | |||
| 844 | 866 | } | |
| 845 | 867 | ||
| 846 | 868 | // NewClient returns a provider.Client for r, choosing the auth mode | |
| 847 | - | // based on r.AuthMethod. Panics if no credential is present; callers | |
| 848 | - | // must check HasCredential() first. | |
| 869 | + | // based on r.AuthMethod. Panics if no credential or usable keyless | |
| 870 | + | // endpoint is present; callers must check HasCredential() first. | |
| 849 | 871 | func (r Resolved) NewClient() provider.Client { | |
| 850 | 872 | if !r.HasCredential() { | |
| 851 | - | panic("NewClient called without credential; check HasCredential first") | |
| 873 | + | panic("NewClient called without credential or keyless endpoint; check HasCredential first") | |
| 852 | 874 | } | |
| 853 | 875 | wrap := r.withHTTPClient | |
| 854 | 876 | switch r.Provider { | |
| @@ -66,14 +66,18 @@ type Resolved struct { | |||||
| 66 | // Bookkeeping for MergeExtensionTools. Captured at Resolve time | 66 | // Bookkeeping for MergeExtensionTools. Captured at Resolve time | ||
| 67 | // so the system prompt can be rebuilt later without re-running | 67 | // so the system prompt can be rebuilt later without re-running | ||
| 68 | // resolve. | 68 | // resolve. | ||
| 69 | - | systemAppend []string | 69 | + | credentialOptional bool |
| 70 | - | systemCustom string | 70 | + | systemAppend []string |
| 71 | - | systemCustomSet bool | 71 | + | systemCustom string |
| 72 | - | toolDescriptions map[string]string | 72 | + | systemCustomSet bool |
| 73 | + | toolDescriptions map[string]string | |||
| 73 | } | 74 | } | ||
| 74 | 75 | ||||
| 75 | - | // HasCredential reports whether a credential was resolved. | 76 | + | // HasCredential reports whether the resolved configuration can create a client. |
| 76 | - | func (r Resolved) HasCredential() bool { return r.Credential != "" } | 77 | + | // Configured custom endpoints may intentionally omit an API credential. |
| 78 | + | func (r Resolved) HasCredential() bool { | |||
| 79 | + | return r.Credential != "" || r.credentialOptional | |||
| 80 | + | } | |||
| 77 | 81 | ||||
| 78 | // MergeExtensionTools folds every tool registered by an extension | 82 | // MergeExtensionTools folds every tool registered by an extension | ||
| 79 | // into r's ToolRegistry and re-renders the system prompt's tool | 83 | // into r's ToolRegistry and re-renders the system prompt's tool | ||
| @@ -363,7 +367,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 363 | accountID string | 367 | accountID string | ||
| 364 | credErr error | 368 | credErr error | ||
| 365 | ) | 369 | ) | ||
| 366 | - | if args.inheritedCredential != "" { | 370 | + | customCfg, customProvider := provider.CustomProviders()[provName] |
| 371 | + | keylessCustom := customProvider && !isBuiltinProvider(provName) && customCfg.NoAuth | |||
| 372 | + | if keylessCustom { | |||
| 373 | + | // Explicit keyless configuration takes precedence over CLI, env, and | |||
| 374 | + | // stored credentials. Never execute a stored API key command. | |||
| 375 | + | } else if args.inheritedCredential != "" { | |||
| 367 | cred = args.inheritedCredential | 376 | cred = args.inheritedCredential | ||
| 368 | method = args.inheritedAuthMethod | 377 | method = args.inheritedAuthMethod | ||
| 369 | accountID = args.inheritedAccountID | 378 | accountID = args.inheritedAccountID | ||
| @@ -376,7 +385,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 376 | 385 | ||||
| 377 | // Persist --api-key for custom providers so subsequent runs don't | 386 | // Persist --api-key for custom providers so subsequent runs don't | ||
| 378 | // need to pass it again. | 387 | // need to pass it again. | ||
| 379 | - | if !isBuiltinProvider(provName) && args.APIKey != "" { | 388 | + | if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" { |
| 380 | if store := AuthStoreFor(); store != nil { | 389 | if store := AuthStoreFor(); store != nil { | ||
| 381 | _ = store.SetAPIKey(provName, args.APIKey) | 390 | _ = store.SetAPIKey(provName, args.APIKey) | ||
| 382 | } | 391 | } | ||
| @@ -594,13 +603,25 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 594 | 603 | ||||
| 595 | // If the model has a base URL, credentials are optional (local | 604 | // If the model has a base URL, credentials are optional (local | ||
| 596 | // models like ollama don't need real API keys). | 605 | // models like ollama don't need real API keys). | ||
| 597 | - | if resolvedModel.BaseURL != "" && credErr != nil { | 606 | + | credentialOptional := false |
| 598 | - | cred = "ollama" | 607 | + | if keylessCustom { |
| 599 | - | if provName == "amazon-bedrock" { | 608 | + | if args.BaseURL == "" { |
| 600 | - | // The default AWS CLI profile is resolved by the Bedrock client, | 609 | + | return Resolved{}, fmt.Errorf("custom provider %q has auth none but no base URL", provName) |
| 601 | - | // not the general credential resolver. Never pass the local-model | 610 | + | } |
| 602 | - | // placeholder as a bearer token. | 611 | + | credentialOptional = true |
| 603 | - | cred = "<aws>" | 612 | + | } else if resolvedModel.BaseURL != "" && credErr != nil { |
| 613 | + | if _, isCustom := provider.CustomProviders()[provName]; isCustom { | |||
| 614 | + | // A custom endpoint may be configured for keyless access. Keep the | |||
| 615 | + | // credential empty so the provider does not send a placeholder token. | |||
| 616 | + | credentialOptional = true | |||
| 617 | + | } else { | |||
| 618 | + | cred = "ollama" | |||
| 619 | + | if provName == "amazon-bedrock" { | |||
| 620 | + | // The default AWS CLI profile is resolved by the Bedrock client, | |||
| 621 | + | // not the general credential resolver. Never pass the local-model | |||
| 622 | + | // placeholder as a bearer token. | |||
| 623 | + | cred = "<aws>" | |||
| 624 | + | } | |||
| 604 | } | 625 | } | ||
| 605 | credErr = nil | 626 | credErr = nil | ||
| 606 | requireCred = false | 627 | requireCred = false | ||
| @@ -713,29 +734,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 713 | max := args.MaxSteps // 0 = unlimited | 734 | max := args.MaxSteps // 0 = unlimited | ||
| 714 | 735 | ||||
| 715 | return Resolved{ | 736 | return Resolved{ | ||
| 716 | - | Provider: provName, | 737 | + | Provider: provName, |
| 717 | - | Model: model, | 738 | + | Model: model, |
| 718 | - | Credential: cred, | 739 | + | Credential: cred, |
| 719 | - | AuthMethod: method, | 740 | + | AuthMethod: method, |
| 720 | - | AccountID: accountID, | 741 | + | AccountID: accountID, |
| 721 | - | BaseURL: args.BaseURL, | 742 | + | BaseURL: args.BaseURL, |
| 722 | - | InsecureTLS: insecureTLS, | 743 | + | InsecureTLS: insecureTLS, |
| 723 | - | CWD: args.CWD, | 744 | + | CWD: args.CWD, |
| 724 | - | Reasoning: reasoning, | 745 | + | Reasoning: reasoning, |
| 725 | - | Temperature: temperature, | 746 | + | Temperature: temperature, |
| 726 | - | ToolRegistry: reg, | 747 | + | ToolRegistry: reg, |
| 727 | - | ToolSummary: summaries, | 748 | + | ToolSummary: summaries, |
| 728 | - | SystemPrompt: sys, | 749 | + | SystemPrompt: sys, |
| 729 | - | MaxSteps: max, | 750 | + | MaxSteps: max, |
| 730 | - | MaxOutput: resolvedModel.MaxOutput, | 751 | + | MaxOutput: resolvedModel.MaxOutput, |
| 731 | - | Sandbox: sandbox, | 752 | + | Sandbox: sandbox, |
| 732 | - | SkillTool: skillTool, | 753 | + | SkillTool: skillTool, |
| 733 | - | SkillDiagnostics: skillDiagnostics, | 754 | + | SkillDiagnostics: skillDiagnostics, |
| 734 | - | ContextFiles: contextFiles, | 755 | + | ContextFiles: contextFiles, |
| 735 | - | systemAppend: append_, | 756 | + | credentialOptional: credentialOptional, |
| 736 | - | systemCustom: custom, | 757 | + | systemAppend: append_, |
| 737 | - | systemCustomSet: customSet, | 758 | + | systemCustom: custom, |
| 738 | - | toolDescriptions: descMapFromSummaries(summaries), | 759 | + | systemCustomSet: customSet, |
| 760 | + | toolDescriptions: descMapFromSummaries(summaries), | |||
| 739 | }, nil | 761 | }, nil | ||
| 740 | } | 762 | } | ||
| 741 | 763 | ||||
| @@ -844,11 +866,11 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string { | |||||
| 844 | } | 866 | } | ||
| 845 | 867 | ||||
| 846 | // NewClient returns a provider.Client for r, choosing the auth mode | 868 | // NewClient returns a provider.Client for r, choosing the auth mode | ||
| 847 | - | // based on r.AuthMethod. Panics if no credential is present; callers | 869 | + | // based on r.AuthMethod. Panics if no credential or usable keyless |
| 848 | - | // must check HasCredential() first. | 870 | + | // endpoint is present; callers must check HasCredential() first. |
| 849 | func (r Resolved) NewClient() provider.Client { | 871 | func (r Resolved) NewClient() provider.Client { | ||
| 850 | if !r.HasCredential() { | 872 | if !r.HasCredential() { | ||
| 851 | - | panic("NewClient called without credential; check HasCredential first") | 873 | + | panic("NewClient called without credential or keyless endpoint; check HasCredential first") |
| 852 | } | 874 | } | ||
| 853 | wrap := r.withHTTPClient | 875 | wrap := r.withHTTPClient | ||
| 854 | switch r.Provider { | 876 | switch r.Provider { | ||
M packages/agent/build_test.go +137 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "net/http/httptest" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "slices" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "testing" | |
| 11 | 12 | ||
| @@ -569,6 +570,142 @@ func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) { | |||
| 569 | 570 | } | |
| 570 | 571 | } | |
| 571 | 572 | ||
| 573 | + | func TestCustomProviderKeylessOpenAICompatOmitsAuthorization(t *testing.T) { | |
| 574 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 575 | + | authorization := make(chan string, 1) | |
| 576 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 577 | + | authorization <- r.Header.Get("Authorization") | |
| 578 | + | if got := r.Header.Get("Authorization"); got != "" { | |
| 579 | + | http.Error(w, `{"error":"unexpected authorization"}`, http.StatusUnauthorized) | |
| 580 | + | return | |
| 581 | + | } | |
| 582 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 583 | + | w.WriteHeader(http.StatusOK) | |
| 584 | + | })) | |
| 585 | + | t.Cleanup(srv.Close) | |
| 586 | + | ||
| 587 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`) | |
| 588 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true) | |
| 589 | + | if err != nil { | |
| 590 | + | t.Fatalf("Resolve failed: %v", err) | |
| 591 | + | } | |
| 592 | + | if r.Credential != "" { | |
| 593 | + | t.Fatalf("credential = %q, want empty for a keyless endpoint", r.Credential) | |
| 594 | + | } | |
| 595 | + | if !r.HasCredential() { | |
| 596 | + | t.Fatal("keyless custom endpoint should still be usable") | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | events, err := r.NewClient().Stream(context.Background(), provider.Request{ | |
| 600 | + | Model: "qista", | |
| 601 | + | Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}}, | |
| 602 | + | }) | |
| 603 | + | if err != nil { | |
| 604 | + | t.Fatalf("Stream failed: %v", err) | |
| 605 | + | } | |
| 606 | + | for range events { | |
| 607 | + | } | |
| 608 | + | if got := <-authorization; got != "" { | |
| 609 | + | t.Fatalf("Authorization = %q, want header omitted", got) | |
| 610 | + | } | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | func TestCustomProviderExplicitKeylessIgnoresStoredKey(t *testing.T) { | |
| 614 | + | for _, api := range []string{"openai", "openai-responses", "anthropic"} { | |
| 615 | + | t.Run(api, func(t *testing.T) { | |
| 616 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 617 | + | t.Setenv("UNSLOTH_API_KEY", "env-key") | |
| 618 | + | authorization := make(chan string, 1) | |
| 619 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 620 | + | if _, ok := r.Header["Authorization"]; ok { | |
| 621 | + | authorization <- "Authorization present" | |
| 622 | + | } else if _, ok := r.Header["X-Api-Key"]; ok { | |
| 623 | + | authorization <- "X-Api-Key present" | |
| 624 | + | } else { | |
| 625 | + | authorization <- "" | |
| 626 | + | } | |
| 627 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 628 | + | w.WriteHeader(http.StatusOK) | |
| 629 | + | })) | |
| 630 | + | t.Cleanup(srv.Close) | |
| 631 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"`+api+`","auth":"none","models":[{"id":"qista"}]}}}`) | |
| 632 | + | if err := AuthStoreFor().SetAPIKey("unsloth", "stored-key"); err != nil { | |
| 633 | + | t.Fatal(err) | |
| 634 | + | } | |
| 635 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", APIKey: "cli-key", NoSkill: true}, true) | |
| 636 | + | if err != nil { | |
| 637 | + | t.Fatal(err) | |
| 638 | + | } | |
| 639 | + | if r.Credential != "" || !r.HasCredential() { | |
| 640 | + | t.Fatalf("keyless resolved credential = %q, usable = %v", r.Credential, r.HasCredential()) | |
| 641 | + | } | |
| 642 | + | events, err := r.NewClient().Stream(context.Background(), provider.Request{ | |
| 643 | + | Model: "qista", Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}}, | |
| 644 | + | }) | |
| 645 | + | if err != nil { | |
| 646 | + | t.Fatal(err) | |
| 647 | + | } | |
| 648 | + | for range events { | |
| 649 | + | } | |
| 650 | + | if got := <-authorization; got != "" { | |
| 651 | + | t.Fatalf("Authorization = %q, want omitted", got) | |
| 652 | + | } | |
| 653 | + | t.Setenv("UNSLOTH_API_KEY", "") | |
| 654 | + | stored, _, _, err := ResolveCredentialFull("unsloth", "") | |
| 655 | + | if err != nil || stored != "stored-key" { | |
| 656 | + | t.Fatalf("stored credential changed: %q, %v", stored, err) | |
| 657 | + | } | |
| 658 | + | }) | |
| 659 | + | } | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | func TestModelPickerProvidersIncludeKeylessCustomEndpoint(t *testing.T) { | |
| 663 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 664 | + | t.Setenv("UNSLOTH_API_KEY", "") | |
| 665 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"http://127.0.0.1:8888/v1","api":"openai","models":[{"id":"qista"}]}}}`) | |
| 666 | + | if CredentialAvailable("unsloth") { | |
| 667 | + | t.Fatal("test requires no credential") | |
| 668 | + | } | |
| 669 | + | if !slices.Contains(modelPickerProviders(), "unsloth") { | |
| 670 | + | t.Fatal("keyless custom endpoint missing from model picker") | |
| 671 | + | } | |
| 672 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"models":[{"id":"qista","baseUrl":"http://127.0.0.1:8888/v1"}]}}}`) | |
| 673 | + | if !slices.Contains(modelPickerProviders(), "unsloth") { | |
| 674 | + | t.Fatal("model-level keyless endpoint missing from model picker") | |
| 675 | + | } | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | func TestCustomProviderWithKeyStillSendsAuthorization(t *testing.T) { | |
| 679 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 680 | + | t.Setenv("UNSLOTH_API_KEY", "") | |
| 681 | + | authorization := make(chan string, 1) | |
| 682 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 683 | + | authorization <- r.Header.Get("Authorization") | |
| 684 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 685 | + | w.WriteHeader(http.StatusOK) | |
| 686 | + | })) | |
| 687 | + | t.Cleanup(srv.Close) | |
| 688 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`) | |
| 689 | + | if err := AuthStoreFor().SetAPIKey("unsloth", "stored-key"); err != nil { | |
| 690 | + | t.Fatal(err) | |
| 691 | + | } | |
| 692 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true) | |
| 693 | + | if err != nil { | |
| 694 | + | t.Fatal(err) | |
| 695 | + | } | |
| 696 | + | events, err := r.NewClient().Stream(context.Background(), provider.Request{ | |
| 697 | + | Model: "qista", Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}}, | |
| 698 | + | }) | |
| 699 | + | if err != nil { | |
| 700 | + | t.Fatal(err) | |
| 701 | + | } | |
| 702 | + | for range events { | |
| 703 | + | } | |
| 704 | + | if got := <-authorization; got != "Bearer stored-key" { | |
| 705 | + | t.Fatalf("Authorization = %q, want stored key", got) | |
| 706 | + | } | |
| 707 | + | } | |
| 708 | + | ||
| 572 | 709 | func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) { | |
| 573 | 710 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 574 | 711 | t.Setenv("LOCAL_PROXY_API_KEY", "test-key") | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "net/http/httptest" | 6 | "net/http/httptest" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "slices" | |||
| 9 | "strings" | 10 | "strings" | ||
| 10 | "testing" | 11 | "testing" | ||
| 11 | 12 | ||||
| @@ -569,6 +570,142 @@ func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) { | |||||
| 569 | } | 570 | } | ||
| 570 | } | 571 | } | ||
| 571 | 572 | ||||
| 573 | + | func TestCustomProviderKeylessOpenAICompatOmitsAuthorization(t *testing.T) { | |||
| 574 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 575 | + | authorization := make(chan string, 1) | |||
| 576 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 577 | + | authorization <- r.Header.Get("Authorization") | |||
| 578 | + | if got := r.Header.Get("Authorization"); got != "" { | |||
| 579 | + | http.Error(w, `{"error":"unexpected authorization"}`, http.StatusUnauthorized) | |||
| 580 | + | return | |||
| 581 | + | } | |||
| 582 | + | w.Header().Set("Content-Type", "text/event-stream") | |||
| 583 | + | w.WriteHeader(http.StatusOK) | |||
| 584 | + | })) | |||
| 585 | + | t.Cleanup(srv.Close) | |||
| 586 | + | ||||
| 587 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`) | |||
| 588 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true) | |||
| 589 | + | if err != nil { | |||
| 590 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 591 | + | } | |||
| 592 | + | if r.Credential != "" { | |||
| 593 | + | t.Fatalf("credential = %q, want empty for a keyless endpoint", r.Credential) | |||
| 594 | + | } | |||
| 595 | + | if !r.HasCredential() { | |||
| 596 | + | t.Fatal("keyless custom endpoint should still be usable") | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | events, err := r.NewClient().Stream(context.Background(), provider.Request{ | |||
| 600 | + | Model: "qista", | |||
| 601 | + | Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}}, | |||
| 602 | + | }) | |||
| 603 | + | if err != nil { | |||
| 604 | + | t.Fatalf("Stream failed: %v", err) | |||
| 605 | + | } | |||
| 606 | + | for range events { | |||
| 607 | + | } | |||
| 608 | + | if got := <-authorization; got != "" { | |||
| 609 | + | t.Fatalf("Authorization = %q, want header omitted", got) | |||
| 610 | + | } | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | func TestCustomProviderExplicitKeylessIgnoresStoredKey(t *testing.T) { | |||
| 614 | + | for _, api := range []string{"openai", "openai-responses", "anthropic"} { | |||
| 615 | + | t.Run(api, func(t *testing.T) { | |||
| 616 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 617 | + | t.Setenv("UNSLOTH_API_KEY", "env-key") | |||
| 618 | + | authorization := make(chan string, 1) | |||
| 619 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 620 | + | if _, ok := r.Header["Authorization"]; ok { | |||
| 621 | + | authorization <- "Authorization present" | |||
| 622 | + | } else if _, ok := r.Header["X-Api-Key"]; ok { | |||
| 623 | + | authorization <- "X-Api-Key present" | |||
| 624 | + | } else { | |||
| 625 | + | authorization <- "" | |||
| 626 | + | } | |||
| 627 | + | w.Header().Set("Content-Type", "text/event-stream") | |||
| 628 | + | w.WriteHeader(http.StatusOK) | |||
| 629 | + | })) | |||
| 630 | + | t.Cleanup(srv.Close) | |||
| 631 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"`+api+`","auth":"none","models":[{"id":"qista"}]}}}`) | |||
| 632 | + | if err := AuthStoreFor().SetAPIKey("unsloth", "stored-key"); err != nil { | |||
| 633 | + | t.Fatal(err) | |||
| 634 | + | } | |||
| 635 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", APIKey: "cli-key", NoSkill: true}, true) | |||
| 636 | + | if err != nil { | |||
| 637 | + | t.Fatal(err) | |||
| 638 | + | } | |||
| 639 | + | if r.Credential != "" || !r.HasCredential() { | |||
| 640 | + | t.Fatalf("keyless resolved credential = %q, usable = %v", r.Credential, r.HasCredential()) | |||
| 641 | + | } | |||
| 642 | + | events, err := r.NewClient().Stream(context.Background(), provider.Request{ | |||
| 643 | + | Model: "qista", Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}}, | |||
| 644 | + | }) | |||
| 645 | + | if err != nil { | |||
| 646 | + | t.Fatal(err) | |||
| 647 | + | } | |||
| 648 | + | for range events { | |||
| 649 | + | } | |||
| 650 | + | if got := <-authorization; got != "" { | |||
| 651 | + | t.Fatalf("Authorization = %q, want omitted", got) | |||
| 652 | + | } | |||
| 653 | + | t.Setenv("UNSLOTH_API_KEY", "") | |||
| 654 | + | stored, _, _, err := ResolveCredentialFull("unsloth", "") | |||
| 655 | + | if err != nil || stored != "stored-key" { | |||
| 656 | + | t.Fatalf("stored credential changed: %q, %v", stored, err) | |||
| 657 | + | } | |||
| 658 | + | }) | |||
| 659 | + | } | |||
| 660 | + | } | |||
| 661 | + | ||||
| 662 | + | func TestModelPickerProvidersIncludeKeylessCustomEndpoint(t *testing.T) { | |||
| 663 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 664 | + | t.Setenv("UNSLOTH_API_KEY", "") | |||
| 665 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"http://127.0.0.1:8888/v1","api":"openai","models":[{"id":"qista"}]}}}`) | |||
| 666 | + | if CredentialAvailable("unsloth") { | |||
| 667 | + | t.Fatal("test requires no credential") | |||
| 668 | + | } | |||
| 669 | + | if !slices.Contains(modelPickerProviders(), "unsloth") { | |||
| 670 | + | t.Fatal("keyless custom endpoint missing from model picker") | |||
| 671 | + | } | |||
| 672 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"models":[{"id":"qista","baseUrl":"http://127.0.0.1:8888/v1"}]}}}`) | |||
| 673 | + | if !slices.Contains(modelPickerProviders(), "unsloth") { | |||
| 674 | + | t.Fatal("model-level keyless endpoint missing from model picker") | |||
| 675 | + | } | |||
| 676 | + | } | |||
| 677 | + | ||||
| 678 | + | func TestCustomProviderWithKeyStillSendsAuthorization(t *testing.T) { | |||
| 679 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 680 | + | t.Setenv("UNSLOTH_API_KEY", "") | |||
| 681 | + | authorization := make(chan string, 1) | |||
| 682 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 683 | + | authorization <- r.Header.Get("Authorization") | |||
| 684 | + | w.Header().Set("Content-Type", "text/event-stream") | |||
| 685 | + | w.WriteHeader(http.StatusOK) | |||
| 686 | + | })) | |||
| 687 | + | t.Cleanup(srv.Close) | |||
| 688 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`) | |||
| 689 | + | if err := AuthStoreFor().SetAPIKey("unsloth", "stored-key"); err != nil { | |||
| 690 | + | t.Fatal(err) | |||
| 691 | + | } | |||
| 692 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true) | |||
| 693 | + | if err != nil { | |||
| 694 | + | t.Fatal(err) | |||
| 695 | + | } | |||
| 696 | + | events, err := r.NewClient().Stream(context.Background(), provider.Request{ | |||
| 697 | + | Model: "qista", Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}}, | |||
| 698 | + | }) | |||
| 699 | + | if err != nil { | |||
| 700 | + | t.Fatal(err) | |||
| 701 | + | } | |||
| 702 | + | for range events { | |||
| 703 | + | } | |||
| 704 | + | if got := <-authorization; got != "Bearer stored-key" { | |||
| 705 | + | t.Fatalf("Authorization = %q, want stored key", got) | |||
| 706 | + | } | |||
| 707 | + | } | |||
| 708 | + | ||||
| 572 | func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) { | 709 | func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) { | ||
| 573 | t.Setenv("ZOT_HOME", t.TempDir()) | 710 | t.Setenv("ZOT_HOME", t.TempDir()) | ||
| 574 | t.Setenv("LOCAL_PROXY_API_KEY", "test-key") | 711 | t.Setenv("LOCAL_PROXY_API_KEY", "test-key") | ||
M packages/agent/cli.go +4 -25
| @@ -1437,31 +1437,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1437 | 1437 | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | |
| 1438 | 1438 | BuildAgentFor: buildAgentFor, | |
| 1439 | 1439 | BuildAgentForRescue: buildAgentForRescue, | |
| 1440 | - | LoggedInProviders: func() []string { | |
| 1441 | - | var out []string | |
| 1442 | - | seen := map[string]bool{} | |
| 1443 | - | for _, p := range knownProviders { | |
| 1444 | - | if CredentialAvailable(p) && !seen[p] { | |
| 1445 | - | out = append(out, p) | |
| 1446 | - | seen[p] = true | |
| 1447 | - | } | |
| 1448 | - | } | |
| 1449 | - | // Include custom providers that have credentials stored. | |
| 1450 | - | for p := range provider.CustomProviders() { | |
| 1451 | - | if CredentialAvailable(p) && !seen[p] { | |
| 1452 | - | out = append(out, p) | |
| 1453 | - | seen[p] = true | |
| 1454 | - | } | |
| 1455 | - | } | |
| 1456 | - | // Ollama models are always available (no auth needed). | |
| 1457 | - | if !seen["ollama"] { | |
| 1458 | - | out = append(out, "ollama") | |
| 1459 | - | } | |
| 1460 | - | return out | |
| 1461 | - | }, | |
| 1462 | - | NewSession: newSession, | |
| 1463 | - | LoadSession: loadSession, | |
| 1464 | - | ChangeCWD: changeCWD, | |
| 1440 | + | LoggedInProviders: modelPickerProviders, | |
| 1441 | + | NewSession: newSession, | |
| 1442 | + | LoadSession: loadSession, | |
| 1443 | + | ChangeCWD: changeCWD, | |
| 1465 | 1444 | CurrentSessionPath: func() string { | |
| 1466 | 1445 | if sess == nil { | |
| 1467 | 1446 | return "" | |
| @@ -1437,31 +1437,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1437 | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | 1437 | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | ||
| 1438 | BuildAgentFor: buildAgentFor, | 1438 | BuildAgentFor: buildAgentFor, | ||
| 1439 | BuildAgentForRescue: buildAgentForRescue, | 1439 | BuildAgentForRescue: buildAgentForRescue, | ||
| 1440 | - | LoggedInProviders: func() []string { | 1440 | + | LoggedInProviders: modelPickerProviders, |
| 1441 | - | var out []string | 1441 | + | NewSession: newSession, |
| 1442 | - | seen := map[string]bool{} | 1442 | + | LoadSession: loadSession, |
| 1443 | - | for _, p := range knownProviders { | 1443 | + | ChangeCWD: changeCWD, |
| 1444 | - | if CredentialAvailable(p) && !seen[p] { | |||
| 1445 | - | out = append(out, p) | |||
| 1446 | - | seen[p] = true | |||
| 1447 | - | } | |||
| 1448 | - | } | |||
| 1449 | - | // Include custom providers that have credentials stored. | |||
| 1450 | - | for p := range provider.CustomProviders() { | |||
| 1451 | - | if CredentialAvailable(p) && !seen[p] { | |||
| 1452 | - | out = append(out, p) | |||
| 1453 | - | seen[p] = true | |||
| 1454 | - | } | |||
| 1455 | - | } | |||
| 1456 | - | // Ollama models are always available (no auth needed). | |||
| 1457 | - | if !seen["ollama"] { | |||
| 1458 | - | out = append(out, "ollama") | |||
| 1459 | - | } | |||
| 1460 | - | return out | |||
| 1461 | - | }, | |||
| 1462 | - | NewSession: newSession, | |||
| 1463 | - | LoadSession: loadSession, | |||
| 1464 | - | ChangeCWD: changeCWD, | |||
| 1465 | CurrentSessionPath: func() string { | 1444 | CurrentSessionPath: func() string { | ||
| 1466 | if sess == nil { | 1445 | if sess == nil { | ||
| 1467 | return "" | 1446 | return "" | ||
M packages/agent/customdiscovery.go +3 -0
| @@ -43,6 +43,9 @@ func CustomDiscoveryConfigured() bool { | |||
| 43 | 43 | // unauthenticated requests. skip is true when the only key is command-backed | |
| 44 | 44 | // and mode forbids running the command. | |
| 45 | 45 | func customProviderKey(ctx context.Context, name string, mode apiKeyCommandMode) (key string, skip bool, err error) { | |
| 46 | + | if cfg, ok := provider.CustomProviders()[name]; ok && cfg.NoAuth && !isBuiltinProvider(name) { | |
| 47 | + | return "", false, nil | |
| 48 | + | } | |
| 46 | 49 | if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" { | |
| 47 | 50 | return v, false, nil | |
| 48 | 51 | } | |
| @@ -43,6 +43,9 @@ func CustomDiscoveryConfigured() bool { | |||||
| 43 | // unauthenticated requests. skip is true when the only key is command-backed | 43 | // unauthenticated requests. skip is true when the only key is command-backed | ||
| 44 | // and mode forbids running the command. | 44 | // and mode forbids running the command. | ||
| 45 | func customProviderKey(ctx context.Context, name string, mode apiKeyCommandMode) (key string, skip bool, err error) { | 45 | func customProviderKey(ctx context.Context, name string, mode apiKeyCommandMode) (key string, skip bool, err error) { | ||
| 46 | + | if cfg, ok := provider.CustomProviders()[name]; ok && cfg.NoAuth && !isBuiltinProvider(name) { | |||
| 47 | + | return "", false, nil | |||
| 48 | + | } | |||
| 46 | if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" { | 49 | if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" { | ||
| 47 | return v, false, nil | 50 | return v, false, nil | ||
| 48 | } | 51 | } | ||
M packages/agent/customdiscovery_test.go +17 -0
| @@ -136,6 +136,23 @@ func TestRefreshCustomProviderModelsUsesStoredKeyAndKeepsUserOverrides(t *testin | |||
| 136 | 136 | } | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | + | func TestRefreshCustomProviderModelsExplicitKeylessIgnoresStoredKey(t *testing.T) { | |
| 140 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 141 | + | t.Setenv("M4_API_KEY", "env-key") | |
| 142 | + | var requests atomic.Int32 | |
| 143 | + | server := newModelsServer(t, &requests, "", "qwen3-coder") | |
| 144 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers":{"m4":{"baseUrl":%q,"discover":true,"auth":"none"}}}`, server.URL+"/v1")) | |
| 145 | + | if err := AuthStoreFor().SetAPIKey("m4", "stored-key"); err != nil { | |
| 146 | + | t.Fatal(err) | |
| 147 | + | } | |
| 148 | + | if err := RefreshCustomProviderModels(context.Background()); err != nil { | |
| 149 | + | t.Fatal(err) | |
| 150 | + | } | |
| 151 | + | if requests.Load() != 1 { | |
| 152 | + | t.Fatalf("requests = %d, want 1", requests.Load()) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 139 | 156 | func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) { | |
| 140 | 157 | home := t.TempDir() | |
| 141 | 158 | t.Setenv("ZOT_HOME", home) | |
| @@ -136,6 +136,23 @@ func TestRefreshCustomProviderModelsUsesStoredKeyAndKeepsUserOverrides(t *testin | |||||
| 136 | } | 136 | } | ||
| 137 | } | 137 | } | ||
| 138 | 138 | ||||
| 139 | + | func TestRefreshCustomProviderModelsExplicitKeylessIgnoresStoredKey(t *testing.T) { | |||
| 140 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 141 | + | t.Setenv("M4_API_KEY", "env-key") | |||
| 142 | + | var requests atomic.Int32 | |||
| 143 | + | server := newModelsServer(t, &requests, "", "qwen3-coder") | |||
| 144 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers":{"m4":{"baseUrl":%q,"discover":true,"auth":"none"}}}`, server.URL+"/v1")) | |||
| 145 | + | if err := AuthStoreFor().SetAPIKey("m4", "stored-key"); err != nil { | |||
| 146 | + | t.Fatal(err) | |||
| 147 | + | } | |||
| 148 | + | if err := RefreshCustomProviderModels(context.Background()); err != nil { | |||
| 149 | + | t.Fatal(err) | |||
| 150 | + | } | |||
| 151 | + | if requests.Load() != 1 { | |||
| 152 | + | t.Fatalf("requests = %d, want 1", requests.Load()) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 139 | func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) { | 156 | func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) { | ||
| 140 | home := t.TempDir() | 157 | home := t.TempDir() | ||
| 141 | t.Setenv("ZOT_HOME", home) | 158 | t.Setenv("ZOT_HOME", home) | ||
A packages/agent/modelpicker_providers.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import "github.com/patriceckhart/zot/packages/provider" | |
| 4 | + | ||
| 5 | + | // modelPickerProviders returns providers whose models can be selected without | |
| 6 | + | // prompting for credentials. Keyless custom endpoints need a configured URL. | |
| 7 | + | func modelPickerProviders() []string { | |
| 8 | + | var out []string | |
| 9 | + | seen := map[string]bool{} | |
| 10 | + | for _, p := range knownProviders { | |
| 11 | + | if CredentialAvailable(p) && !seen[p] { | |
| 12 | + | out = append(out, p) | |
| 13 | + | seen[p] = true | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | for p, cfg := range provider.CustomProviders() { | |
| 17 | + | if seen[p] { | |
| 18 | + | continue | |
| 19 | + | } | |
| 20 | + | if CredentialAvailable(p) || (!isBuiltinProvider(p) && (cfg.BaseURL != "" || customProviderHasModelURL(p))) { | |
| 21 | + | out = append(out, p) | |
| 22 | + | seen[p] = true | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | if !seen["ollama"] { | |
| 26 | + | out = append(out, "ollama") | |
| 27 | + | } | |
| 28 | + | return out | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func customProviderHasModelURL(name string) bool { | |
| 32 | + | for _, m := range provider.ModelsForProvider(name) { | |
| 33 | + | if m.Source == "user" && m.BaseURL != "" { | |
| 34 | + | return true | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | return false | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import "github.com/patriceckhart/zot/packages/provider" | |||
| 4 | + | ||||
| 5 | + | // modelPickerProviders returns providers whose models can be selected without | |||
| 6 | + | // prompting for credentials. Keyless custom endpoints need a configured URL. | |||
| 7 | + | func modelPickerProviders() []string { | |||
| 8 | + | var out []string | |||
| 9 | + | seen := map[string]bool{} | |||
| 10 | + | for _, p := range knownProviders { | |||
| 11 | + | if CredentialAvailable(p) && !seen[p] { | |||
| 12 | + | out = append(out, p) | |||
| 13 | + | seen[p] = true | |||
| 14 | + | } | |||
| 15 | + | } | |||
| 16 | + | for p, cfg := range provider.CustomProviders() { | |||
| 17 | + | if seen[p] { | |||
| 18 | + | continue | |||
| 19 | + | } | |||
| 20 | + | if CredentialAvailable(p) || (!isBuiltinProvider(p) && (cfg.BaseURL != "" || customProviderHasModelURL(p))) { | |||
| 21 | + | out = append(out, p) | |||
| 22 | + | seen[p] = true | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | if !seen["ollama"] { | |||
| 26 | + | out = append(out, "ollama") | |||
| 27 | + | } | |||
| 28 | + | return out | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func customProviderHasModelURL(name string) bool { | |||
| 32 | + | for _, m := range provider.ModelsForProvider(name) { | |||
| 33 | + | if m.Source == "user" && m.BaseURL != "" { | |||
| 34 | + | return true | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | return false | |||
| 38 | + | } | |||
M packages/provider/anthropic.go +3 -1
| @@ -564,7 +564,9 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event | |||
| 564 | 564 | // Remove x-api-key entirely by NOT setting it. | |
| 565 | 565 | } else { | |
| 566 | 566 | httpReq.Header.Set("accept", "text/event-stream") | |
| 567 | - | httpReq.Header.Set("x-api-key", c.apiKey) | |
| 567 | + | if c.apiKey != "" { | |
| 568 | + | httpReq.Header.Set("x-api-key", c.apiKey) | |
| 569 | + | } | |
| 568 | 570 | } | |
| 569 | 571 | // Extra headers (set by anthropic-messages-compatible third parties | |
| 570 | 572 | // — kimi-coding's X-Msh-*, copilot's Editor-Plugin-Version, etc.). | |
| @@ -564,7 +564,9 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event | |||||
| 564 | // Remove x-api-key entirely by NOT setting it. | 564 | // Remove x-api-key entirely by NOT setting it. | ||
| 565 | } else { | 565 | } else { | ||
| 566 | httpReq.Header.Set("accept", "text/event-stream") | 566 | httpReq.Header.Set("accept", "text/event-stream") | ||
| 567 | - | httpReq.Header.Set("x-api-key", c.apiKey) | 567 | + | if c.apiKey != "" { |
| 568 | + | httpReq.Header.Set("x-api-key", c.apiKey) | |||
| 569 | + | } | |||
| 568 | } | 570 | } | ||
| 569 | // Extra headers (set by anthropic-messages-compatible third parties | 571 | // Extra headers (set by anthropic-messages-compatible third parties | ||
| 570 | // — kimi-coding's X-Msh-*, copilot's Editor-Plugin-Version, etc.). | 572 | // — kimi-coding's X-Msh-*, copilot's Editor-Plugin-Version, etc.). | ||
M packages/provider/openai.go +3 -1
| @@ -591,7 +591,9 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||
| 591 | 591 | } | |
| 592 | 592 | httpReq.Header.Set("content-type", "application/json") | |
| 593 | 593 | httpReq.Header.Set("accept", "text/event-stream") | |
| 594 | - | httpReq.Header.Set("authorization", "Bearer "+c.apiKey) | |
| 594 | + | if c.apiKey != "" { | |
| 595 | + | httpReq.Header.Set("authorization", "Bearer "+c.apiKey) | |
| 596 | + | } | |
| 595 | 597 | for k, v := range c.headers { | |
| 596 | 598 | httpReq.Header.Set(k, v) | |
| 597 | 599 | } | |
| @@ -591,7 +591,9 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||||
| 591 | } | 591 | } | ||
| 592 | httpReq.Header.Set("content-type", "application/json") | 592 | httpReq.Header.Set("content-type", "application/json") | ||
| 593 | httpReq.Header.Set("accept", "text/event-stream") | 593 | httpReq.Header.Set("accept", "text/event-stream") | ||
| 594 | - | httpReq.Header.Set("authorization", "Bearer "+c.apiKey) | 594 | + | if c.apiKey != "" { |
| 595 | + | httpReq.Header.Set("authorization", "Bearer "+c.apiKey) | |||
| 596 | + | } | |||
| 595 | for k, v := range c.headers { | 597 | for k, v := range c.headers { | ||
| 596 | httpReq.Header.Set(k, v) | 598 | httpReq.Header.Set(k, v) | ||
| 597 | } | 599 | } | ||
M packages/provider/openai_responses.go +5 -1
| @@ -38,7 +38,11 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response, | |||
| 38 | 38 | clone.Header.Del("chatgpt-account-id") | |
| 39 | 39 | clone.Header.Del("openai-beta") | |
| 40 | 40 | clone.Header.Del("originator") | |
| 41 | - | // Keep Authorization: Bearer <key> as set by the codex client. | |
| 41 | + | // The shared Codex client sets a Bearer header even with an empty key. | |
| 42 | + | // Keyless custom Responses endpoints need no Authorization header at all. | |
| 43 | + | if strings.TrimSpace(clone.Header.Get("authorization")) == "Bearer" { | |
| 44 | + | clone.Header.Del("authorization") | |
| 45 | + | } | |
| 42 | 46 | return t.inner.RoundTrip(clone) | |
| 43 | 47 | } | |
| 44 | 48 | ||
| @@ -38,7 +38,11 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response, | |||||
| 38 | clone.Header.Del("chatgpt-account-id") | 38 | clone.Header.Del("chatgpt-account-id") | ||
| 39 | clone.Header.Del("openai-beta") | 39 | clone.Header.Del("openai-beta") | ||
| 40 | clone.Header.Del("originator") | 40 | clone.Header.Del("originator") | ||
| 41 | - | // Keep Authorization: Bearer <key> as set by the codex client. | 41 | + | // The shared Codex client sets a Bearer header even with an empty key. |
| 42 | + | // Keyless custom Responses endpoints need no Authorization header at all. | |||
| 43 | + | if strings.TrimSpace(clone.Header.Get("authorization")) == "Bearer" { | |||
| 44 | + | clone.Header.Del("authorization") | |||
| 45 | + | } | |||
| 42 | return t.inner.RoundTrip(clone) | 46 | return t.inner.RoundTrip(clone) | ||
| 43 | } | 47 | } | ||
| 44 | 48 | ||||
M packages/provider/usermodels.go +14 -1
| @@ -37,7 +37,8 @@ type UserModelsFile struct { | |||
| 37 | 37 | // UserProvider groups models under a provider key. | |
| 38 | 38 | type UserProvider struct { | |
| 39 | 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 | + | Auth string `json:"auth,omitempty"` // "none" disables credentials, including stored keys | |
| 41 | 42 | // Discover enables live model listing from the provider-level | |
| 42 | 43 | // baseUrl's /models endpoint. Off by default so existing custom | |
| 43 | 44 | // providers never gain unexpected network traffic. | |
| @@ -50,6 +51,7 @@ type UserProvider struct { | |||
| 50 | 51 | type CustomProviderConfig struct { | |
| 51 | 52 | BaseURL string | |
| 52 | 53 | API string // "openai", "openai-responses", or "anthropic" | |
| 54 | + | NoAuth bool // explicitly configured for keyless access | |
| 53 | 55 | // Discover reports whether live model discovery is enabled. It is | |
| 54 | 56 | // only true when a provider-level BaseURL is present. | |
| 55 | 57 | Discover bool | |
| @@ -172,7 +174,17 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 172 | 174 | warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName)) | |
| 173 | 175 | discover = false | |
| 174 | 176 | } | |
| 177 | + | if prov.Auth != "" && prov.BaseURL == "" && !hasModelBaseURL { | |
| 178 | + | warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets auth without a baseUrl; ignored", providerName)) | |
| 179 | + | } | |
| 175 | 180 | if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL { | |
| 181 | + | auth := strings.ToLower(strings.TrimSpace(prov.Auth)) | |
| 182 | + | if auth != "" && auth != "none" { | |
| 183 | + | warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown auth %q; using credentials when available", providerName, prov.Auth)) | |
| 184 | + | } | |
| 185 | + | if prov.BaseURL == "" && !hasModelBaseURL { | |
| 186 | + | auth = "" | |
| 187 | + | } | |
| 176 | 188 | api := strings.ToLower(strings.TrimSpace(prov.API)) | |
| 177 | 189 | if api == "" { | |
| 178 | 190 | api = "openai" | |
| @@ -192,6 +204,7 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 192 | 204 | customProviders[normalized] = CustomProviderConfig{ | |
| 193 | 205 | BaseURL: prov.BaseURL, | |
| 194 | 206 | API: api, | |
| 207 | + | NoAuth: auth == "none", | |
| 195 | 208 | Discover: discover, | |
| 196 | 209 | } | |
| 197 | 210 | } | |
| @@ -37,7 +37,8 @@ type UserModelsFile struct { | |||||
| 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 | + | Auth string `json:"auth,omitempty"` // "none" disables credentials, including stored keys | |||
| 41 | // Discover enables live model listing from the provider-level | 42 | // Discover enables live model listing from the provider-level | ||
| 42 | // baseUrl's /models endpoint. Off by default so existing custom | 43 | // baseUrl's /models endpoint. Off by default so existing custom | ||
| 43 | // providers never gain unexpected network traffic. | 44 | // providers never gain unexpected network traffic. | ||
| @@ -50,6 +51,7 @@ type UserProvider struct { | |||||
| 50 | type CustomProviderConfig struct { | 51 | type CustomProviderConfig struct { | ||
| 51 | BaseURL string | 52 | BaseURL string | ||
| 52 | API string // "openai", "openai-responses", or "anthropic" | 53 | API string // "openai", "openai-responses", or "anthropic" | ||
| 54 | + | NoAuth bool // explicitly configured for keyless access | |||
| 53 | // Discover reports whether live model discovery is enabled. It is | 55 | // Discover reports whether live model discovery is enabled. It is | ||
| 54 | // only true when a provider-level BaseURL is present. | 56 | // only true when a provider-level BaseURL is present. | ||
| 55 | Discover bool | 57 | Discover bool | ||
| @@ -172,7 +174,17 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||||
| 172 | warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName)) | 174 | warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName)) | ||
| 173 | discover = false | 175 | discover = false | ||
| 174 | } | 176 | } | ||
| 177 | + | if prov.Auth != "" && prov.BaseURL == "" && !hasModelBaseURL { | |||
| 178 | + | warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets auth without a baseUrl; ignored", providerName)) | |||
| 179 | + | } | |||
| 175 | if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL { | 180 | if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL { | ||
| 181 | + | auth := strings.ToLower(strings.TrimSpace(prov.Auth)) | |||
| 182 | + | if auth != "" && auth != "none" { | |||
| 183 | + | warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown auth %q; using credentials when available", providerName, prov.Auth)) | |||
| 184 | + | } | |||
| 185 | + | if prov.BaseURL == "" && !hasModelBaseURL { | |||
| 186 | + | auth = "" | |||
| 187 | + | } | |||
| 176 | api := strings.ToLower(strings.TrimSpace(prov.API)) | 188 | api := strings.ToLower(strings.TrimSpace(prov.API)) | ||
| 177 | if api == "" { | 189 | if api == "" { | ||
| 178 | api = "openai" | 190 | api = "openai" | ||
| @@ -192,6 +204,7 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||||
| 192 | customProviders[normalized] = CustomProviderConfig{ | 204 | customProviders[normalized] = CustomProviderConfig{ | ||
| 193 | BaseURL: prov.BaseURL, | 205 | BaseURL: prov.BaseURL, | ||
| 194 | API: api, | 206 | API: api, | ||
| 207 | + | NoAuth: auth == "none", | |||
| 195 | Discover: discover, | 208 | Discover: discover, | ||
| 196 | } | 209 | } | ||
| 197 | } | 210 | } | ||
M packages/provider/usermodels_test.go +18 -0
| @@ -169,6 +169,24 @@ func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) { | |||
| 169 | 169 | } | |
| 170 | 170 | } | |
| 171 | 171 | ||
| 172 | + | func TestLoadUserModelsKeylessAuthRequiresEndpoint(t *testing.T) { | |
| 173 | + | path := filepath.Join(t.TempDir(), "models.json") | |
| 174 | + | if err := os.WriteFile(path, []byte(`{"providers":{ | |
| 175 | + | "keyless":{"baseUrl":"http://localhost:8888/v1","auth":"none","models":[{"id":"m"}]}, | |
| 176 | + | "unknown":{"baseUrl":"http://localhost:9999/v1","auth":"typo","models":[{"id":"m"}]}, | |
| 177 | + | "no-url":{"auth":"none","models":[{"id":"m"}]} | |
| 178 | + | }}`), 0o644); err != nil { | |
| 179 | + | t.Fatal(err) | |
| 180 | + | } | |
| 181 | + | _, warnings := LoadUserModelsWithWarnings(path) | |
| 182 | + | if !CustomProviders()["keyless"].NoAuth || CustomProviders()["unknown"].NoAuth || CustomProviders()["no-url"].NoAuth { | |
| 183 | + | t.Fatalf("invalid keyless configuration: %+v", CustomProviders()) | |
| 184 | + | } | |
| 185 | + | if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "unknown auth") || !strings.Contains(strings.Join(warnings, "\n"), "without a baseUrl") { | |
| 186 | + | t.Fatalf("warnings = %v, want unknown auth and missing URL warnings", warnings) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 172 | 190 | func TestLoadUserModelsDiscoverFlag(t *testing.T) { | |
| 173 | 191 | dir := t.TempDir() | |
| 174 | 192 | path := filepath.Join(dir, "models.json") | |
| @@ -169,6 +169,24 @@ func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) { | |||||
| 169 | } | 169 | } | ||
| 170 | } | 170 | } | ||
| 171 | 171 | ||||
| 172 | + | func TestLoadUserModelsKeylessAuthRequiresEndpoint(t *testing.T) { | |||
| 173 | + | path := filepath.Join(t.TempDir(), "models.json") | |||
| 174 | + | if err := os.WriteFile(path, []byte(`{"providers":{ | |||
| 175 | + | "keyless":{"baseUrl":"http://localhost:8888/v1","auth":"none","models":[{"id":"m"}]}, | |||
| 176 | + | "unknown":{"baseUrl":"http://localhost:9999/v1","auth":"typo","models":[{"id":"m"}]}, | |||
| 177 | + | "no-url":{"auth":"none","models":[{"id":"m"}]} | |||
| 178 | + | }}`), 0o644); err != nil { | |||
| 179 | + | t.Fatal(err) | |||
| 180 | + | } | |||
| 181 | + | _, warnings := LoadUserModelsWithWarnings(path) | |||
| 182 | + | if !CustomProviders()["keyless"].NoAuth || CustomProviders()["unknown"].NoAuth || CustomProviders()["no-url"].NoAuth { | |||
| 183 | + | t.Fatalf("invalid keyless configuration: %+v", CustomProviders()) | |||
| 184 | + | } | |||
| 185 | + | if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "unknown auth") || !strings.Contains(strings.Join(warnings, "\n"), "without a baseUrl") { | |||
| 186 | + | t.Fatalf("warnings = %v, want unknown auth and missing URL warnings", warnings) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 172 | func TestLoadUserModelsDiscoverFlag(t *testing.T) { | 190 | func TestLoadUserModelsDiscoverFlag(t *testing.T) { | ||
| 173 | dir := t.TempDir() | 191 | dir := t.TempDir() | ||
| 174 | path := filepath.Join(dir, "models.json") | 192 | path := filepath.Join(dir, "models.json") | ||