Files
yetone b56bfb5a94 gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.
2026-10-06 18:26:31 +08:00

198 lines
9.5 KiB
Go

package gateway
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// cloudflarePage is a page a relay serves 200 text/html in place of a
// reply (#1012): Cloudflare's challenge, as it begins.
const cloudflarePage = `<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"></head><body><div class="main-wrapper" role="main"><div class="main-content"><noscript>Enable JavaScript and cookies to continue</noscript></div></div></body></html>`
// notAPIOn saves a provider speaking proto at s, its only model m.
func notAPIOn(t *testing.T, id string, proto provider.Protocol, s *scripted) *httptest.Server {
t.Helper()
up := httptest.NewServer(s)
t.Cleanup(up.Close)
p := provider.Provider{ID: id, Name: strings.ToUpper(id), Key: "k", Models: []string{"m"}}
switch proto {
case provider.Anthropic:
p.Anthropic = up.URL
case provider.Responses:
p.Responses = up.URL + "/v1"
default:
p.Chat = up.URL + "/v1"
}
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
return up
}
// goodReply is a whole reply saying "from b", streamed or not, in proto.
func goodReply(proto provider.Protocol, stream bool) reply {
switch {
case proto == provider.Chat && !stream:
return reply{200, "application/json", `{"id":"ok","choices":[{"index":0,"message":{"role":"assistant","content":"from b"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}`}
case proto == provider.Chat:
return reply{200, "text/event-stream", sse(`data: {"id":"ok","choices":[{"index":0,"delta":{"role":"assistant","content":"from b"}}]}`,
`data: {"id":"ok","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}`,
`data: [DONE]`)}
case proto == provider.Responses && !stream:
return reply{200, "application/json", `{"id":"resp_b","object":"response","status":"completed","model":"m","output":[{"type":"message","id":"msg_b","role":"assistant","status":"completed","content":[{"type":"output_text","text":"from b","annotations":[]}]}],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}`}
case proto == provider.Responses:
return reply{200, "text/event-stream", sse(`event: response.created`+"\n"+`data: {"type":"response.created","response":{"id":"resp_b","status":"in_progress","output":[]}}`,
`event: response.output_text.delta`+"\n"+`data: {"type":"response.output_text.delta","item_id":"msg_b","output_index":0,"content_index":0,"delta":"from b"}`,
`event: response.completed`+"\n"+`data: {"type":"response.completed","response":{"id":"resp_b","status":"completed","model":"m","output":[{"type":"message","id":"msg_b","role":"assistant","status":"completed","content":[{"type":"output_text","text":"from b","annotations":[]}]}],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}`)}
case !stream:
return reply{200, "application/json", `{"id":"msg_b","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"from b"}],"stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":2}}`}
}
return reply{200, "text/event-stream", sse(`event: message_start`+"\n"+`data: {"type":"message_start","message":{"id":"msg_b","role":"assistant","content":[],"usage":{"input_tokens":3}}}`,
`event: content_block_start`+"\n"+`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
`event: content_block_delta`+"\n"+`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"from b"}}`,
`event: content_block_stop`+"\n"+`data: {"type":"content_block_stop","index":0}`,
`event: message_delta`+"\n"+`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`,
`event: message_stop`+"\n"+`data: {"type":"message_stop"}`)}
}
// askOn sends a request for model in proto, streamed or not.
func askOn(s *Server, proto provider.Protocol, model string, stream bool) *httptest.ResponseRecorder {
st := "false"
if stream {
st = "true"
}
var path, body string
switch proto {
case provider.Responses:
path, body = "/v1/responses", `{"model":"`+model+`","stream":`+st+`,"input":[{"role":"user","content":"hi"}]}`
case provider.Anthropic:
path, body = "/v1/messages", `{"model":"`+model+`","max_tokens":10,"stream":`+st+`,"messages":[{"role":"user","content":"hi"}]}`
default:
path, body = "/v1/chat/completions", `{"model":"`+model+`","stream":`+st+`,"messages":[{"role":"user","content":"hi"}]}`
}
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
return rec
}
// A group member that answers 200 with a web page, with nothing at all or
// with something sent as JSON that isn't, has failed before its reply
// began: the next member answers, on every API, streamed or not, and the
// log says why the first one didn't (#1012: Codex on group/medium was
// handed a relay's text/html page, logged 200 with nothing in or out).
func TestNotAnAPIReplyFailsOver(t *testing.T) {
bad := []struct {
name string
r reply
why string
}{
{"html", reply{200, "text/html; charset=utf-8", cloudflarePage}, `with a web page (text/html), not an API reply: “Just a moment...”`},
{"html as json", reply{200, "application/json", cloudflarePage}, "with a web page (application/json)"},
{"html as sse", reply{200, "text/event-stream", "\n" + cloudflarePage}, "with a web page (text/event-stream)"},
{"empty", reply{200, "application/json", ""}, "with nothing in it"},
{"empty sse", reply{200, "text/event-stream", ""}, "with nothing in it"},
{"not json", reply{200, "application/json", "upstream connect error or disconnect/reset before headers"}, "with a reply that isn't JSON, not an API reply: upstream connect error"},
}
for _, proto := range []provider.Protocol{provider.Chat, provider.Responses, provider.Anthropic} {
for _, stream := range []bool{false, true} {
for _, x := range bad {
name := string(proto) + "/" + x.name
if stream {
name += "/stream"
}
t.Run(name, func(t *testing.T) {
fresh(t)
a := &scripted{replies: []reply{x.r}}
b := &scripted{replies: []reply{goodReply(proto, stream)}}
notAPIOn(t, "a", proto, a)
notAPIOn(t, "b", proto, b)
if err := provider.SaveGroup(provider.Group{Name: "G", Members: []string{"a/m", "b/m"}, Routing: provider.Ordered}); err != nil {
t.Fatal(err)
}
s := New()
rec := askOn(s, proto, "group/g", stream)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "from b") || strings.Contains(rec.Body.String(), "Just a moment") || a.n != 1 || b.n != 1 {
t.Fatalf("%d %s (a %d, b %d)", rec.Code, rec.Body.String(), a.n, b.n)
}
calls := s.Recent()
if len(calls) == 0 || calls[0].Status != 200 || !strings.Contains(calls[0].Fallback, x.why) {
t.Fatalf("log: %+v", calls)
}
})
}
}
}
}
// A provider with nobody after it hands the agent a 502 saying what came
// back, not the page as a reply, and the log has it as the error it was.
func TestNotAnAPIReplyAlone(t *testing.T) {
fresh(t)
a := &scripted{replies: []reply{{200, "text/html; charset=utf-8", cloudflarePage}}}
notAPIOn(t, "a", provider.Responses, a)
s := New()
rec := askOn(s, provider.Responses, "a/m", true)
body := rec.Body.String()
if strings.Contains(body, "<html") || !strings.Contains(body, "A: answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”") {
t.Fatalf("%d %s", rec.Code, body)
}
calls := s.Recent()
if len(calls) == 0 || calls[0].Status != http.StatusBadGateway || !strings.Contains(calls[0].Error, "with a web page") {
t.Fatalf("log: %+v", calls)
}
}
// What an API does answer goes as it came: a reply that begins with
// whitespace, a stream, a compressed body, and a 204.
func TestAPIReplyUntouched(t *testing.T) {
for _, x := range []struct {
ct, ce, body string
code int
}{
{"application/json", "", "\n {\"ok\":true}", 200},
{"text/event-stream", "", "data: {}\n\n", 200},
{"", "", "data: {}\n\n", 200},
{"text/plain", "", "event: x\ndata: {}\n\n", 200},
{"application/json", "gzip", "\x1f\x8b\x08", 200},
{"application/xml", "", "<?xml version=\"1.0\"?><a/>", 200},
{"application/json", "", "", 204},
{"text/html", "", "<html></html>", 404},
} {
res := &http.Response{StatusCode: x.code, Status: http.StatusText(x.code), Header: http.Header{}, Body: io.NopCloser(strings.NewReader(x.body))}
if x.ct != "" {
res.Header.Set("Content-Type", x.ct)
}
if x.ce != "" {
res.Header.Set("Content-Encoding", x.ce)
}
got := notAnAPIReply(res, "")
raw, _ := io.ReadAll(got.Body)
b := string(raw)
if got.StatusCode != x.code || b != x.body {
t.Errorf("%+v: %d %q", x, got.StatusCode, b)
}
}
}
// Codex's own sign-in, relayed to the ChatGPT backend, is told of a web
// page served 200 as the 502 it is, not handed the page as its stream.
func TestCodexBackendWebPage(t *testing.T) {
setup(t, provider.Chat, &fake{t: t})
chatgpt(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, cloudflarePage)
})
req := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":[]}`))
req.Header.Set("Authorization", "Bearer chatgpt-token")
rec := httptest.NewRecorder()
New().Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway || strings.Contains(rec.Body.String(), "<html") || !strings.Contains(rec.Body.String(), "OpenAI: answered 200 OK with a web page") {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
}