detached cb823cb

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 {
6666 // Bookkeeping for MergeExtensionTools. Captured at Resolve time
6767 // so the system prompt can be rebuilt later without re-running
6868 // 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
7374 }
7475
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+}
7781
7882 // MergeExtensionTools folds every tool registered by an extension
7983 // into r's ToolRegistry and re-renders the system prompt's tool
@@ -594,13 +598,20 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
594598
595599 // If the model has a base URL, credentials are optional (local
596600 // models like ollama don't need real API keys).
601+ credentialOptional := false
597602 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+ }
604615 }
605616 credErr = nil
606617 requireCred = false
@@ -713,29 +724,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
713724 max := args.MaxSteps // 0 = unlimited
714725
715726 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),
739751 }, nil
740752 }
741753
@@ -844,11 +856,11 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string {
844856 }
845857
846858 // 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.
849861 func (r Resolved) NewClient() provider.Client {
850862 if !r.HasCredential() {
851- panic("NewClient called without credential; check HasCredential first")
863+ panic("NewClient called without credential or keyless endpoint; check HasCredential first")
852864 }
853865 wrap := r.withHTTPClient
854866 switch r.Provider {
@@ -66,14 +66,18 @@ type Resolved struct {
66 // Bookkeeping for MergeExtensionTools. Captured at Resolve time66 // Bookkeeping for MergeExtensionTools. Captured at Resolve time
67 // so the system prompt can be rebuilt later without re-running67 // so the system prompt can be rebuilt later without re-running
68 // resolve.68 // resolve.
69- systemAppend []string69+ credentialOptional bool
70- systemCustom string70+ systemAppend []string
71- systemCustomSet bool71+ systemCustom string
72- toolDescriptions map[string]string72+ 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 extension82 // MergeExtensionTools folds every tool registered by an extension
79 // into r's ToolRegistry and re-renders the system prompt's tool83 // 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 (local599 // 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-model606+ 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 = nil616 credErr = nil
606 requireCred = false617 requireCred = false
@@ -713,29 +724,30 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
713 max := args.MaxSteps // 0 = unlimited724 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 }, nil751 }, 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 mode858 // NewClient returns a provider.Client for r, choosing the auth mode
847-// based on r.AuthMethod. Panics if no credential is present; callers859+// 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.withHTTPClient865 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) {
569569 }
570570 }
571571
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+
572612 func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
573613 t.Setenv("ZOT_HOME", t.TempDir())
574614 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
591591 }
592592 httpReq.Header.Set("content-type", "application/json")
593593 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+ }
595597 for k, v := range c.headers {
596598 httpReq.Header.Set(k, v)
597599 }
@@ -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close