detached ee6f014

Support Responses API for custom providers

commit
ee6f01434b3035e5642d09c18f7dd8472b98d982
author
patriceckhart · · 2026-07-25 08:12 UTC
parent
4657b32
signature
unsigned
refs
tag: v0.3.7
6 files · +85 -7
M README.md +1 -1
@@ -469,7 +469,7 @@ User-defined models show `source: user` in `--list-models` and take precedence o
469469
470470 #### Custom providers
471471
472-A top-level provider key that is not a built-in id defines a custom provider. Give it a provider-level `baseUrl` and an `api` wire format (`openai` for OpenAI-compatible chat completions, the default, or `anthropic` for the Anthropic messages API). A model-level `baseUrl` overrides the provider-level one for that model; an unknown `api` value falls back to `openai` with a warning.
472+A top-level provider key that is not a built-in id defines a custom provider. Give it a provider-level `baseUrl` and an `api` wire format (`openai` for OpenAI-compatible Chat Completions, the default, `openai-responses` for the OpenAI Responses API, or `anthropic` for the Anthropic Messages API). A model-level `baseUrl` overrides the provider-level one for that model; an unknown `api` value falls back to `openai` with a warning.
473473
474474 ```json
475475 {
@@ -469,7 +469,7 @@ User-defined models show `source: user` in `--list-models` and take precedence o
469 469
470 #### Custom providers470 #### Custom providers
471 471
472-A top-level provider key that is not a built-in id defines a custom provider. Give it a provider-level `baseUrl` and an `api` wire format (`openai` for OpenAI-compatible chat completions, the default, or `anthropic` for the Anthropic messages API). A model-level `baseUrl` overrides the provider-level one for that model; an unknown `api` value falls back to `openai` with a warning.472+A top-level provider key that is not a built-in id defines a custom provider. Give it a provider-level `baseUrl` and an `api` wire format (`openai` for OpenAI-compatible Chat Completions, the default, `openai-responses` for the OpenAI Responses API, or `anthropic` for the Anthropic Messages API). A model-level `baseUrl` overrides the provider-level one for that model; an unknown `api` value falls back to `openai` with a warning.
473 473
474 ```json474 ```json
475 {475 {
M docs/providers.md +4 -4
@@ -296,10 +296,10 @@ merged on top of the baked-in and live-discovered models.
296296
297297 A top-level provider key that is not a built-in id defines a custom provider.
298298 Give it a provider-level `baseUrl` and an `api` wire format (`openai` for
299-OpenAI-compatible chat completions, the default, or `anthropic` for the
300-Anthropic messages API). A model-level `baseUrl` overrides the provider-level
301-one for that model, and an unknown `api` value falls back to `openai` with a
302-warning.
299+OpenAI-compatible Chat Completions, the default, `openai-responses` for the
300+OpenAI Responses API, or `anthropic` for the Anthropic Messages API). A
301+model-level `baseUrl` overrides the provider-level one for that model, and an
302+unknown `api` value falls back to `openai` with a warning.
303303
304304 ```json
305305 {
@@ -296,10 +296,10 @@ merged on top of the baked-in and live-discovered models.
296 296
297 A top-level provider key that is not a built-in id defines a custom provider.297 A top-level provider key that is not a built-in id defines a custom provider.
298 Give it a provider-level `baseUrl` and an `api` wire format (`openai` for298 Give it a provider-level `baseUrl` and an `api` wire format (`openai` for
299-OpenAI-compatible chat completions, the default, or `anthropic` for the299+OpenAI-compatible Chat Completions, the default, `openai-responses` for the
300-Anthropic messages API). A model-level `baseUrl` overrides the provider-level300+OpenAI Responses API, or `anthropic` for the Anthropic Messages API). A
301-one for that model, and an unknown `api` value falls back to `openai` with a301+model-level `baseUrl` overrides the provider-level one for that model, and an
302-warning.302+unknown `api` value falls back to `openai` with a warning.
303 303
304 ```json304 ```json
305 {305 {
M packages/agent/build.go +2 -0
@@ -864,6 +864,8 @@ func (r Resolved) NewClient() provider.Client {
864864 // Custom providers: choose wire format from the models.json api field.
865865 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
866866 switch cfg.API {
867+ case provider.APIResponses:
868+ return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
867869 case "anthropic":
868870 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
869871 default: // "openai"
@@ -864,6 +864,8 @@ func (r Resolved) NewClient() provider.Client {
864 // Custom providers: choose wire format from the models.json api field.864 // Custom providers: choose wire format from the models.json api field.
865 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {865 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
866 switch cfg.API {866 switch cfg.API {
867+ case provider.APIResponses:
868+ return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
867 case "anthropic":869 case "anthropic":
868 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))870 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
869 default: // "openai"871 default: // "openai"
M packages/agent/build_test.go +50 -0
@@ -1,7 +1,9 @@
11 package agent
22
33 import (
4+ "context"
45 "net/http"
6+ "net/http/httptest"
57 "os"
68 "path/filepath"
79 "strings"
@@ -328,6 +330,54 @@ func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
328330 }
329331 }
330332
333+func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
334+ requestPath := make(chan string, 1)
335+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
336+ requestPath <- r.URL.Path
337+ w.Header().Set("Content-Type", "text/event-stream")
338+ w.WriteHeader(http.StatusOK)
339+ }))
340+ t.Cleanup(srv.Close)
341+
342+ path := filepath.Join(t.TempDir(), "models.json")
343+ if err := os.WriteFile(path, []byte(`{
344+ "providers": {
345+ "company-responses": {
346+ "baseUrl": "`+srv.URL+`/v1",
347+ "api": "openai-responses",
348+ "models": [{"id": "reasoning-model", "reasoning": true}]
349+ }
350+ }
351+ }`), 0o644); err != nil {
352+ t.Fatal(err)
353+ }
354+ models, warnings := provider.LoadUserModelsWithWarnings(path)
355+ if len(warnings) != 0 {
356+ t.Fatalf("warnings = %v", warnings)
357+ }
358+ provider.SetLiveModels(nil)
359+ provider.SetUserModels(models)
360+ t.Cleanup(func() { provider.SetLiveModels(nil) })
361+
362+ r := Resolved{
363+ Provider: "company-responses",
364+ Credential: "test-key",
365+ BaseURL: srv.URL + "/v1",
366+ }
367+ events, err := r.NewClient().Stream(context.Background(), provider.Request{
368+ Model: "reasoning-model",
369+ Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
370+ })
371+ if err != nil {
372+ t.Fatal(err)
373+ }
374+ for range events {
375+ }
376+ if got := <-requestPath; got != "/v1/responses" {
377+ t.Fatalf("request path = %q, want /v1/responses", got)
378+ }
379+}
380+
331381 func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
332382 t.Setenv("ZOT_HOME", t.TempDir())
333383 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
@@ -1,7 +1,9 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "context"
4 "net/http"5 "net/http"
6+ "net/http/httptest"
5 "os"7 "os"
6 "path/filepath"8 "path/filepath"
7 "strings"9 "strings"
@@ -328,6 +330,54 @@ func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
328 }330 }
329 }331 }
330 332
333+func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
334+ requestPath := make(chan string, 1)
335+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
336+ requestPath <- r.URL.Path
337+ w.Header().Set("Content-Type", "text/event-stream")
338+ w.WriteHeader(http.StatusOK)
339+ }))
340+ t.Cleanup(srv.Close)
341+
342+ path := filepath.Join(t.TempDir(), "models.json")
343+ if err := os.WriteFile(path, []byte(`{
344+ "providers": {
345+ "company-responses": {
346+ "baseUrl": "`+srv.URL+`/v1",
347+ "api": "openai-responses",
348+ "models": [{"id": "reasoning-model", "reasoning": true}]
349+ }
350+ }
351+ }`), 0o644); err != nil {
352+ t.Fatal(err)
353+ }
354+ models, warnings := provider.LoadUserModelsWithWarnings(path)
355+ if len(warnings) != 0 {
356+ t.Fatalf("warnings = %v", warnings)
357+ }
358+ provider.SetLiveModels(nil)
359+ provider.SetUserModels(models)
360+ t.Cleanup(func() { provider.SetLiveModels(nil) })
361+
362+ r := Resolved{
363+ Provider: "company-responses",
364+ Credential: "test-key",
365+ BaseURL: srv.URL + "/v1",
366+ }
367+ events, err := r.NewClient().Stream(context.Background(), provider.Request{
368+ Model: "reasoning-model",
369+ Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
370+ })
371+ if err != nil {
372+ t.Fatal(err)
373+ }
374+ for range events {
375+ }
376+ if got := <-requestPath; got != "/v1/responses" {
377+ t.Fatalf("request path = %q, want /v1/responses", got)
378+ }
379+}
380+
331 func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {381 func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
332 t.Setenv("ZOT_HOME", t.TempDir())382 t.Setenv("ZOT_HOME", t.TempDir())
333 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")383 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
M packages/provider/usermodels.go +4 -2
@@ -36,7 +36,7 @@ type UserModelsFile struct {
3636 // UserProvider groups models under a provider key.
3737 type UserProvider struct {
3838 BaseURL string `json:"baseUrl,omitempty"`
39- API string `json:"api,omitempty"` // "openai" (default) or "anthropic"
39+ API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"
4040 Models []UserModel `json:"models"`
4141 }
4242
@@ -44,7 +44,7 @@ type UserProvider struct {
4444 // that isn't part of the built-in catalog.
4545 type CustomProviderConfig struct {
4646 BaseURL string
47- API string // "openai" or "anthropic"
47+ API string // "openai", "openai-responses", or "anthropic"
4848 }
4949
5050 var customProviders = map[string]CustomProviderConfig{}
@@ -138,6 +138,8 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
138138 switch api {
139139 case "openai-completions", "openai-chat", "chat", "openai":
140140 api = "openai"
141+ case "openai-responses", "responses":
142+ api = APIResponses
141143 case "anthropic-messages", "messages", "anthropic":
142144 api = "anthropic"
143145 default:
@@ -36,7 +36,7 @@ type UserModelsFile struct {
36 // UserProvider groups models under a provider key.36 // UserProvider groups models under a provider key.
37 type UserProvider struct {37 type UserProvider struct {
38 BaseURL string `json:"baseUrl,omitempty"`38 BaseURL string `json:"baseUrl,omitempty"`
39- API string `json:"api,omitempty"` // "openai" (default) or "anthropic"39+ API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"
40 Models []UserModel `json:"models"`40 Models []UserModel `json:"models"`
41 }41 }
42 42
@@ -44,7 +44,7 @@ type UserProvider struct {
44 // that isn't part of the built-in catalog.44 // that isn't part of the built-in catalog.
45 type CustomProviderConfig struct {45 type CustomProviderConfig struct {
46 BaseURL string46 BaseURL string
47- API string // "openai" or "anthropic"47+ API string // "openai", "openai-responses", or "anthropic"
48 }48 }
49 49
50 var customProviders = map[string]CustomProviderConfig{}50 var customProviders = map[string]CustomProviderConfig{}
@@ -138,6 +138,8 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
138 switch api {138 switch api {
139 case "openai-completions", "openai-chat", "chat", "openai":139 case "openai-completions", "openai-chat", "chat", "openai":
140 api = "openai"140 api = "openai"
141+ case "openai-responses", "responses":
142+ api = APIResponses
141 case "anthropic-messages", "messages", "anthropic":143 case "anthropic-messages", "messages", "anthropic":
142 api = "anthropic"144 api = "anthropic"
143 default:145 default:
M packages/provider/usermodels_test.go +24 -0
@@ -57,6 +57,30 @@ func TestLoadUserModelsRegistersModelLevelBaseURLCustomProvider(t *testing.T) {
5757 }
5858 }
5959
60+func TestLoadUserModelsAcceptsOpenAIResponsesAPI(t *testing.T) {
61+ dir := t.TempDir()
62+ path := filepath.Join(dir, "models.json")
63+ if err := os.WriteFile(path, []byte(`{
64+ "providers": {
65+ "company-proxy": {
66+ "baseUrl": "https://llm.example.com/v1",
67+ "api": "openai-responses",
68+ "models": [{"id": "reasoning-model"}]
69+ }
70+ }
71+ }`), 0o644); err != nil {
72+ t.Fatal(err)
73+ }
74+
75+ _, warnings := LoadUserModelsWithWarnings(path)
76+ if len(warnings) != 0 {
77+ t.Fatalf("warnings = %v, want none", warnings)
78+ }
79+ if cfg := CustomProviders()["company-proxy"]; cfg.API != APIResponses {
80+ t.Fatalf("api = %q, want %q", cfg.API, APIResponses)
81+ }
82+}
83+
6084 func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {
6185 dir := t.TempDir()
6286 path := filepath.Join(dir, "models.json")
@@ -57,6 +57,30 @@ func TestLoadUserModelsRegistersModelLevelBaseURLCustomProvider(t *testing.T) {
57 }57 }
58 }58 }
59 59
60+func TestLoadUserModelsAcceptsOpenAIResponsesAPI(t *testing.T) {
61+ dir := t.TempDir()
62+ path := filepath.Join(dir, "models.json")
63+ if err := os.WriteFile(path, []byte(`{
64+ "providers": {
65+ "company-proxy": {
66+ "baseUrl": "https://llm.example.com/v1",
67+ "api": "openai-responses",
68+ "models": [{"id": "reasoning-model"}]
69+ }
70+ }
71+ }`), 0o644); err != nil {
72+ t.Fatal(err)
73+ }
74+
75+ _, warnings := LoadUserModelsWithWarnings(path)
76+ if len(warnings) != 0 {
77+ t.Fatalf("warnings = %v, want none", warnings)
78+ }
79+ if cfg := CustomProviders()["company-proxy"]; cfg.API != APIResponses {
80+ t.Fatalf("api = %q, want %q", cfg.API, APIResponses)
81+ }
82+}
83+
60 func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {84 func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {
61 dir := t.TempDir()85 dir := t.TempDir()
62 path := filepath.Join(dir, "models.json")86 path := filepath.Join(dir, "models.json")

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