fix(provider): support keyless custom OpenAI endpoints
- commit
- cb823cb8a4bbe7ac992286c7ad90500b4caf8dc2
- author
- Oscar-Williams · · 2026-09-28 18:48 UTC
- parent
- 2a19df1
- signature
- unsigned
3 files · +93 -39
M packages/agent/build.go +50 -38
| @@ -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 | |
| @@ -594,13 +598,20 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 594 | 598 | ||
| 595 | 599 | // If the model has a base URL, credentials are optional (local | |
| 596 | 600 | // models like ollama don't need real API keys). | |
| 601 | + | credentialOptional := false | |
| 597 | 602 | 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>" | |
| 603 | + | if _, isCustom := provider.CustomProviders()[provName]; isCustom { | |
| 604 | + | // A custom endpoint may be configured for keyless access. Keep the | |
| 605 | + | // credential empty so the provider does not send a placeholder token. | |
| 606 | + | credentialOptional = true | |
| 607 | + | } else { | |
| 608 | + | cred = "ollama" | |
| 609 | + | if provName == "amazon-bedrock" { | |
| 610 | + | // The default AWS CLI profile is resolved by the Bedrock client, | |
| 611 | + | // not the general credential resolver. Never pass the local-model | |
| 612 | + | // placeholder as a bearer token. | |
| 613 | + | cred = "<aws>" | |
| 614 | + | } | |
| 604 | 615 | } | |
| 605 | 616 | credErr = nil | |
| 606 | 617 | requireCred = false | |
| @@ -713,29 +724,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 713 | 724 | max := args.MaxSteps // 0 = unlimited | |
| 714 | 725 | ||
| 715 | 726 | 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), | |
| 727 | + | Provider: provName, | |
| 728 | + | Model: model, | |
| 729 | + | Credential: cred, | |
| 730 | + | AuthMethod: method, | |
| 731 | + | AccountID: accountID, | |
| 732 | + | BaseURL: args.BaseURL, | |
| 733 | + | InsecureTLS: insecureTLS, | |
| 734 | + | CWD: args.CWD, | |
| 735 | + | Reasoning: reasoning, | |
| 736 | + | Temperature: temperature, | |
| 737 | + | ToolRegistry: reg, | |
| 738 | + | ToolSummary: summaries, | |
| 739 | + | SystemPrompt: sys, | |
| 740 | + | MaxSteps: max, | |
| 741 | + | MaxOutput: resolvedModel.MaxOutput, | |
| 742 | + | Sandbox: sandbox, | |
| 743 | + | SkillTool: skillTool, | |
| 744 | + | SkillDiagnostics: skillDiagnostics, | |
| 745 | + | ContextFiles: contextFiles, | |
| 746 | + | credentialOptional: credentialOptional, | |
| 747 | + | systemAppend: append_, | |
| 748 | + | systemCustom: custom, | |
| 749 | + | systemCustomSet: customSet, | |
| 750 | + | toolDescriptions: descMapFromSummaries(summaries), | |
| 739 | 751 | }, nil | |
| 740 | 752 | } | |
| 741 | 753 | ||
| @@ -844,11 +856,11 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string { | |||
| 844 | 856 | } | |
| 845 | 857 | ||
| 846 | 858 | // 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. | |
| 859 | + | // based on r.AuthMethod. Panics if no credential or usable keyless | |
| 860 | + | // endpoint is present; callers must check HasCredential() first. | |
| 849 | 861 | func (r Resolved) NewClient() provider.Client { | |
| 850 | 862 | if !r.HasCredential() { | |
| 851 | - | panic("NewClient called without credential; check HasCredential first") | |
| 863 | + | panic("NewClient called without credential or keyless endpoint; check HasCredential first") | |
| 852 | 864 | } | |
| 853 | 865 | wrap := r.withHTTPClient | |
| 854 | 866 | 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 | ||
| @@ -594,13 +598,20 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 594 | 598 | ||||
| 595 | // If the model has a base URL, credentials are optional (local | 599 | // If the model has a base URL, credentials are optional (local | ||
| 596 | // models like ollama don't need real API keys). | 600 | // models like ollama don't need real API keys). | ||
| 601 | + | credentialOptional := false | |||
| 597 | if resolvedModel.BaseURL != "" && credErr != nil { | 602 | if resolvedModel.BaseURL != "" && credErr != nil { | ||
| 598 | - | cred = "ollama" | 603 | + | if _, isCustom := provider.CustomProviders()[provName]; isCustom { |
| 599 | - | if provName == "amazon-bedrock" { | 604 | + | // A custom endpoint may be configured for keyless access. Keep the |
| 600 | - | // The default AWS CLI profile is resolved by the Bedrock client, | 605 | + | // credential empty so the provider does not send a placeholder token. |
| 601 | - | // not the general credential resolver. Never pass the local-model | 606 | + | credentialOptional = true |
| 602 | - | // placeholder as a bearer token. | 607 | + | } else { |
| 603 | - | cred = "<aws>" | 608 | + | cred = "ollama" |
| 609 | + | if provName == "amazon-bedrock" { | |||
| 610 | + | // The default AWS CLI profile is resolved by the Bedrock client, | |||
| 611 | + | // not the general credential resolver. Never pass the local-model | |||
| 612 | + | // placeholder as a bearer token. | |||
| 613 | + | cred = "<aws>" | |||
| 614 | + | } | |||
| 604 | } | 615 | } | ||
| 605 | credErr = nil | 616 | credErr = nil | ||
| 606 | requireCred = false | 617 | requireCred = false | ||
| @@ -713,29 +724,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 713 | max := args.MaxSteps // 0 = unlimited | 724 | max := args.MaxSteps // 0 = unlimited | ||
| 714 | 725 | ||||
| 715 | return Resolved{ | 726 | return Resolved{ | ||
| 716 | - | Provider: provName, | 727 | + | Provider: provName, |
| 717 | - | Model: model, | 728 | + | Model: model, |
| 718 | - | Credential: cred, | 729 | + | Credential: cred, |
| 719 | - | AuthMethod: method, | 730 | + | AuthMethod: method, |
| 720 | - | AccountID: accountID, | 731 | + | AccountID: accountID, |
| 721 | - | BaseURL: args.BaseURL, | 732 | + | BaseURL: args.BaseURL, |
| 722 | - | InsecureTLS: insecureTLS, | 733 | + | InsecureTLS: insecureTLS, |
| 723 | - | CWD: args.CWD, | 734 | + | CWD: args.CWD, |
| 724 | - | Reasoning: reasoning, | 735 | + | Reasoning: reasoning, |
| 725 | - | Temperature: temperature, | 736 | + | Temperature: temperature, |
| 726 | - | ToolRegistry: reg, | 737 | + | ToolRegistry: reg, |
| 727 | - | ToolSummary: summaries, | 738 | + | ToolSummary: summaries, |
| 728 | - | SystemPrompt: sys, | 739 | + | SystemPrompt: sys, |
| 729 | - | MaxSteps: max, | 740 | + | MaxSteps: max, |
| 730 | - | MaxOutput: resolvedModel.MaxOutput, | 741 | + | MaxOutput: resolvedModel.MaxOutput, |
| 731 | - | Sandbox: sandbox, | 742 | + | Sandbox: sandbox, |
| 732 | - | SkillTool: skillTool, | 743 | + | SkillTool: skillTool, |
| 733 | - | SkillDiagnostics: skillDiagnostics, | 744 | + | SkillDiagnostics: skillDiagnostics, |
| 734 | - | ContextFiles: contextFiles, | 745 | + | ContextFiles: contextFiles, |
| 735 | - | systemAppend: append_, | 746 | + | credentialOptional: credentialOptional, |
| 736 | - | systemCustom: custom, | 747 | + | systemAppend: append_, |
| 737 | - | systemCustomSet: customSet, | 748 | + | systemCustom: custom, |
| 738 | - | toolDescriptions: descMapFromSummaries(summaries), | 749 | + | systemCustomSet: customSet, |
| 750 | + | toolDescriptions: descMapFromSummaries(summaries), | |||
| 739 | }, nil | 751 | }, nil | ||
| 740 | } | 752 | } | ||
| 741 | 753 | ||||
| @@ -844,11 +856,11 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string { | |||||
| 844 | } | 856 | } | ||
| 845 | 857 | ||||
| 846 | // NewClient returns a provider.Client for r, choosing the auth mode | 858 | // NewClient returns a provider.Client for r, choosing the auth mode | ||
| 847 | - | // based on r.AuthMethod. Panics if no credential is present; callers | 859 | + | // based on r.AuthMethod. Panics if no credential or usable keyless |
| 848 | - | // must check HasCredential() first. | 860 | + | // endpoint is present; callers must check HasCredential() first. |
| 849 | func (r Resolved) NewClient() provider.Client { | 861 | func (r Resolved) NewClient() provider.Client { | ||
| 850 | if !r.HasCredential() { | 862 | if !r.HasCredential() { | ||
| 851 | - | panic("NewClient called without credential; check HasCredential first") | 863 | + | panic("NewClient called without credential or keyless endpoint; check HasCredential first") |
| 852 | } | 864 | } | ||
| 853 | wrap := r.withHTTPClient | 865 | wrap := r.withHTTPClient | ||
| 854 | switch r.Provider { | 866 | switch r.Provider { | ||
M packages/agent/build_test.go +40 -0
| @@ -569,6 +569,46 @@ func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) { | |||
| 569 | 569 | } | |
| 570 | 570 | } | |
| 571 | 571 | ||
| 572 | + | func TestCustomProviderKeylessOpenAICompatOmitsAuthorization(t *testing.T) { | |
| 573 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 574 | + | authorization := make(chan string, 1) | |
| 575 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 576 | + | authorization <- r.Header.Get("Authorization") | |
| 577 | + | if got := r.Header.Get("Authorization"); got != "" { | |
| 578 | + | http.Error(w, `{"error":"unexpected authorization"}`, http.StatusUnauthorized) | |
| 579 | + | return | |
| 580 | + | } | |
| 581 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 582 | + | w.WriteHeader(http.StatusOK) | |
| 583 | + | })) | |
| 584 | + | t.Cleanup(srv.Close) | |
| 585 | + | ||
| 586 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`) | |
| 587 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true) | |
| 588 | + | if err != nil { | |
| 589 | + | t.Fatalf("Resolve failed: %v", err) | |
| 590 | + | } | |
| 591 | + | if r.Credential != "" { | |
| 592 | + | t.Fatalf("credential = %q, want empty for a keyless endpoint", r.Credential) | |
| 593 | + | } | |
| 594 | + | if !r.HasCredential() { | |
| 595 | + | t.Fatal("keyless custom endpoint should still be usable") | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | events, err := r.NewClient().Stream(context.Background(), provider.Request{ | |
| 599 | + | Model: "qista", | |
| 600 | + | Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}}, | |
| 601 | + | }) | |
| 602 | + | if err != nil { | |
| 603 | + | t.Fatalf("Stream failed: %v", err) | |
| 604 | + | } | |
| 605 | + | for range events { | |
| 606 | + | } | |
| 607 | + | if got := <-authorization; got != "" { | |
| 608 | + | t.Fatalf("Authorization = %q, want header omitted", got) | |
| 609 | + | } | |
| 610 | + | } | |
| 611 | + | ||
| 572 | 612 | func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) { | |
| 573 | 613 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 574 | 614 | t.Setenv("LOCAL_PROXY_API_KEY", "test-key") | |
| @@ -569,6 +569,46 @@ func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) { | |||||
| 569 | } | 569 | } | ||
| 570 | } | 570 | } | ||
| 571 | 571 | ||||
| 572 | + | func TestCustomProviderKeylessOpenAICompatOmitsAuthorization(t *testing.T) { | |||
| 573 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 574 | + | authorization := make(chan string, 1) | |||
| 575 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 576 | + | authorization <- r.Header.Get("Authorization") | |||
| 577 | + | if got := r.Header.Get("Authorization"); got != "" { | |||
| 578 | + | http.Error(w, `{"error":"unexpected authorization"}`, http.StatusUnauthorized) | |||
| 579 | + | return | |||
| 580 | + | } | |||
| 581 | + | w.Header().Set("Content-Type", "text/event-stream") | |||
| 582 | + | w.WriteHeader(http.StatusOK) | |||
| 583 | + | })) | |||
| 584 | + | t.Cleanup(srv.Close) | |||
| 585 | + | ||||
| 586 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`) | |||
| 587 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true) | |||
| 588 | + | if err != nil { | |||
| 589 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 590 | + | } | |||
| 591 | + | if r.Credential != "" { | |||
| 592 | + | t.Fatalf("credential = %q, want empty for a keyless endpoint", r.Credential) | |||
| 593 | + | } | |||
| 594 | + | if !r.HasCredential() { | |||
| 595 | + | t.Fatal("keyless custom endpoint should still be usable") | |||
| 596 | + | } | |||
| 597 | + | ||||
| 598 | + | events, err := r.NewClient().Stream(context.Background(), provider.Request{ | |||
| 599 | + | Model: "qista", | |||
| 600 | + | Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}}, | |||
| 601 | + | }) | |||
| 602 | + | if err != nil { | |||
| 603 | + | t.Fatalf("Stream failed: %v", err) | |||
| 604 | + | } | |||
| 605 | + | for range events { | |||
| 606 | + | } | |||
| 607 | + | if got := <-authorization; got != "" { | |||
| 608 | + | t.Fatalf("Authorization = %q, want header omitted", got) | |||
| 609 | + | } | |||
| 610 | + | } | |||
| 611 | + | ||||
| 572 | func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) { | 612 | func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) { | ||
| 573 | t.Setenv("ZOT_HOME", t.TempDir()) | 613 | t.Setenv("ZOT_HOME", t.TempDir()) | ||
| 574 | t.Setenv("LOCAL_PROXY_API_KEY", "test-key") | 614 | t.Setenv("LOCAL_PROXY_API_KEY", "test-key") | ||
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 | } | ||