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 <provider> 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.
This commit is contained in:
1 parent
2cdae80a10
commit
9bf20dbfc1
5 files changed
+182
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user