From 9bf20dbfc14e446b2cd5072bb7c0ee6546e0b33a Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 8 Oct 2026 20:30:58 +0800 Subject: [PATCH] gateway: a model a relay serves with tools on Responses alone goes to the provider's Responses URL (#1308, Stevenzc888) A custom provider with the same relay as its OpenAI compatible and OpenAI Responses URL: magpie asks a GPT on a relay on Chat Completions first (Pi is wired on openai-completions for it), and the relay turned Pi's tools away with 400 "gpt-6.1-sol requires Responses for tool calls; this account only supports Chat Completions". wrongEndpoint didn't know that wording, so the 400 reached Pi instead of the request going on to the Responses URL. "requires responses" is now one of its phrases: the model is remembered as not served on that relay's Chat, and the request (relayed as Pi sent it, or translated from Claude Code's Messages) is spoken as Responses there, later turns at once. Where the provider has no Responses URL, the error now says so ("gpt-6.1-sol is served on /v1/responses, which has no URL for") rather than the relay's word alone (wrongAPINote reads the error body). Red before: TestToolsThatNeedResponsesGoThere failed with the user's exact error ("status 400: ... requires Responses for tool calls; this account only supports Chat Completions"); TestTranslatedToolsThatNeedResponsesGoThere, TestToolsThatNeedResponsesNameTheMissingURL and TestWrongEndpoint's new case failed too. With the phrase but without the note, the missing-URL test fails. go test -tags nogui ./internal/gateway ./internal/provider pass, and ./internal/agent as a non-root user (as root in Docker its chmod-based aside_offline tests fail, unrelated); go vet -tags nogui ./internal/gateway for darwin, linux and windows, and gofmt -l pass (linux, golang:1.26.8 in Docker: this Mac's syspolicyd is pegged and new binaries hang at launch). Not tried against the reporter's real relay. --- internal/gateway/continuation.go | 2 +- internal/gateway/gateway.go | 30 +++- internal/gateway/gateway_test.go | 1 + internal/gateway/opencode_wire_test.go | 4 +- internal/gateway/tools_need_responses_test.go | 152 ++++++++++++++++++ 5 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 internal/gateway/tools_need_responses_test.go diff --git a/internal/gateway/continuation.go b/internal/gateway/continuation.go index 4426017b..b76fcb3f 100644 --- a/internal/gateway/continuation.go +++ b/internal/gateway/continuation.go @@ -323,7 +323,7 @@ func (s *Server) streamTranslated(w http.ResponseWriter, r *http.Request, p prov res.Body.Close() failed, failedStatus = p.Explain(p.Name+": "+provider.APIError(b, res.Status), res.StatusCode, b), res.StatusCode if wrongEndpoint(res.StatusCode, b) { - failed += wrongAPINote(p, model, res) + failed += wrongAPINote(p, model, res, b) } if res.StatusCode == http.StatusTooManyRequests && accountAgent(p) == "antigravity" && antigravityTurnsAway(request.System) { failed += " — " + antigravityTurnedAwayHint diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 0926ce4f..805f5588 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -3141,7 +3141,7 @@ func (s *Server) passthrough(w http.ResponseWriter, r *http.Request, p provider. u.RequestID = "" // another endpoint answers, with its own id return res.StatusCode, msg, false } - msg += wrongAPINote(p, model, res) + msg += wrongAPINote(p, model, res, b) } if p.Preset == "openrouter" && openRouterSharedPool(b) { markOpenRouterSharedPool(w) @@ -3862,6 +3862,10 @@ func wrongEndpoint(status int, body []byte) bool { "no model endpoints available given user constraints", "is not supported for format", // OpenCode: "Model grok-4.7 is not supported for format anthropic" "does not support this protocol", // OpenCode, with a key: "Model does not support this protocol" + // a relay serving a model with tools on Responses alone (#1308: + // "gpt-6.1-sol requires Responses for tool calls; this account + // only supports Chat Completions") + "requires responses", } { if strings.Contains(msg, phrase) { return true @@ -3875,7 +3879,7 @@ func wrongEndpoint(status int, body []byte) bool { // ask: the URL asked, and the APIs the vendor's list serves the model on // that the provider has no URL for, so "Model does not support this // protocol" says which protocol, and what to set (#1215). -func wrongAPINote(p provider.Provider, model string, res *http.Response) string { +func wrongAPINote(p provider.Provider, model string, res *http.Response, body []byte) string { note := "" if res != nil && res.Request != nil && res.Request.URL != nil { u := *res.Request.URL @@ -3884,7 +3888,12 @@ func wrongAPINote(p provider.Provider, model string, res *http.Response) string } paths := map[provider.Protocol]string{provider.Chat: "/v1/chat/completions", provider.Responses: "/v1/responses", provider.Anthropic: "/v1/messages"} var missing []string - for _, a := range p.APIs(model) { + apis := p.APIs(model) + if asksResponses(body) && !slices.Contains(apis, provider.Responses) { + // the vendor's error names the API, where its list doesn't (#1308) + apis = append(apis, provider.Responses) + } + for _, a := range apis { if paths[a] != "" && p.Base(a) == "" { missing = append(missing, paths[a]) } @@ -3903,6 +3912,19 @@ func wrongAPINote(p provider.Provider, model string, res *http.Response) string return note } +// asksResponses is an upstream's word that the model is served, or served +// with what was asked, on OpenAI's Responses API: where a provider has no +// URL for it, wrongAPINote says to set one. +func asksResponses(body []byte) bool { + msg := strings.ToLower(string(body)) + for _, phrase := range []string{"requires responses", "use v1/responses", "use /v1/responses", "only supported in v1/responses", "only supported in /v1/responses"} { + if strings.Contains(msg, phrase) { + return true + } + } + return false +} + // translate serves a client API the provider lacks by speaking another // one to it. The provider is always streamed; the client gets whichever // it asked for. @@ -3961,7 +3983,7 @@ func (s *Server) translate(w http.ResponseWriter, r *http.Request, p provider.Pr b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) msg := p.Explain(p.Name+": "+provider.APIError(b, res.Status), res.StatusCode, b) if wrongEndpoint(res.StatusCode, b) { - msg += wrongAPINote(p, model, res) + msg += wrongAPINote(p, model, res, b) } if res.StatusCode == http.StatusTooManyRequests && accountAgent(p) == "antigravity" && antigravityTurnsAway(request.System) { msg += " — " + antigravityTurnedAwayHint diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index dcfa7880..e361c3c6 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -370,6 +370,7 @@ func TestWrongEndpoint(t *testing.T) { {400, `{"type":"error","error":{"type":"ModelError","message":"Model grok-4.7 is not supported for format anthropic"}}`, true}, {400, `{"type":"error","error":{"type":"invalid_request_error","message":"Model does not support this protocol."}}`, true}, {400, "no model endpoints available given user constraints\n", true}, // Copilot's /v1/messages (#754) + {400, toolsOnChatRefused, true}, // a relay (#1308) {400, `{"error":{"message":"The requested model is not supported.","code":"model_not_supported"}}`, false}, {429, `rate limit`, false}, {500, `use v1/responses`, false}, diff --git a/internal/gateway/opencode_wire_test.go b/internal/gateway/opencode_wire_test.go index d22a3725..3c646a56 100644 --- a/internal/gateway/opencode_wire_test.go +++ b/internal/gateway/opencode_wire_test.go @@ -152,7 +152,7 @@ func TestWrongAPINote(t *testing.T) { p := provider.Provider{ID: "opencode-go", Name: "OpenCode Go", Catalog: "opencode-go", Chat: "https://opencode.ai/zen/go/v1"} u, _ := url.Parse("https://opencode.ai/zen/go/v1/chat/completions?key=secret") res := &http.Response{Request: &http.Request{URL: u}} - got := wrongAPINote(p, "gpt-6-luna", res) + got := wrongAPINote(p, "gpt-6-luna", res, nil) for _, want := range []string{"asked at https://opencode.ai/zen/go/v1/chat/completions", "gpt-6-luna is served on /v1/responses, which OpenCode Go has no URL for"} { if !strings.Contains(got, want) { t.Errorf("note %q lacks %q", got, want) @@ -161,7 +161,7 @@ func TestWrongAPINote(t *testing.T) { if strings.Contains(got, "secret") { t.Errorf("note repeats the query: %q", got) } - if got := wrongAPINote(p, "glm-5.3", res); got != " (asked at https://opencode.ai/zen/go/v1/chat/completions)" { + if got := wrongAPINote(p, "glm-5.3", res, nil); got != " (asked at https://opencode.ai/zen/go/v1/chat/completions)" { t.Errorf("glm note %q", got) } } diff --git a/internal/gateway/tools_need_responses_test.go b/internal/gateway/tools_need_responses_test.go new file mode 100644 index 00000000..75a8fc97 --- /dev/null +++ b/internal/gateway/tools_need_responses_test.go @@ -0,0 +1,152 @@ +package gateway + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// toolsOnChatRefused is what a relay answers a Chat Completions request +// with tools for a model it serves with tools on Responses alone (#1308, +// Stevenzc888's relay, as the issue quotes it). +const toolsOnChatRefused = `{"error":{"message":"status_code=400, gpt-6.1-sol requires Responses for tool calls; this account only supports Chat Completions","type":"invalid_request_error"}}` + +// toolsRelay is a relay at one base URL serving Chat Completions and +// Responses, which takes gpt-6.1-sol with tools on Responses alone. +type toolsRelay struct { + mu sync.Mutex + chatCalls, resCalls int + resBody []byte +} + +func (f *toolsRelay) ServeHTTP(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + f.mu.Lock() + defer f.mu.Unlock() + switch r.URL.Path { + case "/v1/chat/completions": + f.chatCalls++ + var req struct { + Tools []any `json:"tools"` + } + json.Unmarshal(b, &req) + if len(req.Tools) > 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, toolsOnChatRefused) + return + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, sse( + `data: {"id":"c1","model":"gpt-6.1-sol","choices":[{"delta":{"role":"assistant","content":"chat ok"}}]}`, + `data: {"id":"c1","choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}`, + `data: [DONE]`)) + case "/v1/responses": + f.resCalls++ + f.resBody = b + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, sse( + `event: response.created`+"\n"+`data: {"type":"response.created","response":{"id":"r1","model":"gpt-6.1-sol"}}`, + `event: response.output_text.delta`+"\n"+`data: {"type":"response.output_text.delta","delta":"responses ok"}`, + `event: response.completed`+"\n"+`data: {"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"input_tokens":9,"output_tokens":2}}}`)) + default: + http.NotFound(w, r) + } +} + +// piChatTools is a turn as Pi's openai-completions API sends it (pi-ai's +// openai-completions.js), the API magpie wires Pi on for a custom +// provider with a Chat URL: streamed, its tools, a thinking level. +const piChatTools = `{"model":"relay/gpt-6.1-sol","stream":true,"stream_options":{"include_usage":true},"store":false, + "max_completion_tokens":32000,"reasoning_effort":"high", + "messages":[{"role":"developer","content":"You are Pi."},{"role":"user","content":"list the files"}], + "tools":[{"type":"function","function":{"name":"ls","description":"List files","parameters":{"type":"object","properties":{"path":{"type":"string"}}},"strict":false}}]}` + +// A custom provider whose Chat and Responses URLs are the same relay +// (#1308): Pi asks on Chat Completions with tools, the relay says the model +// takes tools on Responses alone, and the request goes to the provider's +// Responses URL instead of the relay's 400 reaching Pi — and later turns +// go there at once. +func TestToolsThatNeedResponsesGoThere(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + f := &toolsRelay{} + up := httptest.NewServer(f) + t.Cleanup(up.Close) + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Models: []string{"gpt-6.1-sol"}, + Chat: up.URL + "/v1", Responses: up.URL + "/v1"}); err != nil { + t.Fatal(err) + } + if n := mustFind(t, "relay").Native("gpt-6.1-sol"); n != provider.Chat { + t.Fatalf("Pi is wired on %q for the relay's model, the test assumes chat", n) + } + handler := New().Handler() + for i, want := range []int{1, 2} { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(piChatTools))) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "responses ok") { + t.Fatalf("call %d: status %d: %s", i, rec.Code, rec.Body.String()) + } + f.mu.Lock() + chat, res, body := f.chatCalls, f.resCalls, string(f.resBody) + f.mu.Unlock() + if chat != 1 || res != want { + t.Fatalf("call %d: chat=%d responses=%d", i, chat, res) + } + if !strings.Contains(body, `"name":"ls"`) || !strings.Contains(body, `"model":"gpt-6.1-sol"`) { + t.Errorf("call %d: Responses request lost the tools or model: %s", i, body) + } + } +} + +// The sibling on a translated request: Claude Code's Messages turn with +// tools, spoken to the relay as Chat Completions, goes on to Responses too. +func TestTranslatedToolsThatNeedResponsesGoThere(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + f := &toolsRelay{} + up := httptest.NewServer(f) + t.Cleanup(up.Close) + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Models: []string{"gpt-6.1-sol"}, + Chat: up.URL + "/v1", Responses: up.URL + "/v1"}); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + New().Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/messages", strings.NewReader(`{"model":"relay/gpt-6.1-sol","max_tokens":100,"stream":true, + "messages":[{"role":"user","content":"list the files"}], + "tools":[{"name":"ls","description":"List files","input_schema":{"type":"object","properties":{"path":{"type":"string"}}}}]}`))) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "responses ok") { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + if f.chatCalls != 1 || f.resCalls != 1 || !strings.Contains(string(f.resBody), `"name":"ls"`) { + t.Fatalf("chat=%d responses=%d: %s", f.chatCalls, f.resCalls, f.resBody) + } +} + +// The same relay with no Responses URL set: nothing else to ask, and the +// error tells the user which URL to set rather than the relay's word alone. +func TestToolsThatNeedResponsesNameTheMissingURL(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + f := &toolsRelay{} + up := httptest.NewServer(f) + t.Cleanup(up.Close) + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Models: []string{"gpt-6.1-sol"}, + Chat: up.URL + "/v1"}); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + New().Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(piChatTools))) + if rec.Code != 400 || !strings.Contains(rec.Body.String(), "gpt-6.1-sol is served on /v1/responses, which Relay has no URL for") { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + if f.resCalls != 0 { + t.Errorf("Responses asked with no URL for it: %d", f.resCalls) + } +}