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) + } +}