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 | |||
| 469 | 469 | ||
| 470 | 470 | #### 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 | 474 | ```json | |
| 475 | 475 | { | |
| @@ -469,7 +469,7 @@ User-defined models show `source: user` in `--list-models` and take precedence o | |||||
| 469 | 469 | ||||
| 470 | #### Custom providers | 470 | #### 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 | ```json | 474 | ```json | ||
| 475 | { | 475 | { | ||
M docs/providers.md +4 -4
| @@ -296,10 +296,10 @@ merged on top of the baked-in and live-discovered models. | |||
| 296 | 296 | ||
| 297 | 297 | A top-level provider key that is not a built-in id defines a custom provider. | |
| 298 | 298 | 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. | |
| 303 | 303 | ||
| 304 | 304 | ```json | |
| 305 | 305 | { | |
| @@ -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` for | 298 | Give it a provider-level `baseUrl` and an `api` wire format (`openai` for | ||
| 299 | - | OpenAI-compatible chat completions, the default, or `anthropic` for the | 299 | + | OpenAI-compatible Chat Completions, the default, `openai-responses` for the |
| 300 | - | Anthropic messages API). A model-level `baseUrl` overrides the provider-level | 300 | + | 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 a | 301 | + | 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 | ```json | 304 | ```json | ||
| 305 | { | 305 | { | ||
M packages/agent/build.go +2 -0
| @@ -864,6 +864,8 @@ func (r Resolved) NewClient() provider.Client { | |||
| 864 | 864 | // Custom providers: choose wire format from the models.json api field. | |
| 865 | 865 | if cfg, ok := provider.CustomProviders()[r.Provider]; ok { | |
| 866 | 866 | switch cfg.API { | |
| 867 | + | case provider.APIResponses: | |
| 868 | + | return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider)) | |
| 867 | 869 | case "anthropic": | |
| 868 | 870 | return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL)) | |
| 869 | 871 | 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 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 5 | 7 | "os" | |
| 6 | 8 | "path/filepath" | |
| 7 | 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 | 381 | func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) { | |
| 332 | 382 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 333 | 383 | t.Setenv("LOCAL_PROXY_API_KEY", "test-key") | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package agent | 1 | 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 { | |||
| 36 | 36 | // UserProvider groups models under a provider key. | |
| 37 | 37 | type UserProvider struct { | |
| 38 | 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 | 40 | Models []UserModel `json:"models"` | |
| 41 | 41 | } | |
| 42 | 42 | ||
| @@ -44,7 +44,7 @@ type UserProvider struct { | |||
| 44 | 44 | // that isn't part of the built-in catalog. | |
| 45 | 45 | type CustomProviderConfig struct { | |
| 46 | 46 | BaseURL string | |
| 47 | - | API string // "openai" or "anthropic" | |
| 47 | + | API string // "openai", "openai-responses", or "anthropic" | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | var customProviders = map[string]CustomProviderConfig{} | |
| @@ -138,6 +138,8 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 138 | 138 | switch api { | |
| 139 | 139 | case "openai-completions", "openai-chat", "chat", "openai": | |
| 140 | 140 | api = "openai" | |
| 141 | + | case "openai-responses", "responses": | |
| 142 | + | api = APIResponses | |
| 141 | 143 | case "anthropic-messages", "messages", "anthropic": | |
| 142 | 144 | api = "anthropic" | |
| 143 | 145 | 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 string | 46 | 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) { | |||
| 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 | 84 | func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) { | |
| 61 | 85 | dir := t.TempDir() | |
| 62 | 86 | 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") | ||