Files
magpie/internal/gateway/notapi.go
T
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

116 lines
4.3 KiB
Go

package gateway
import (
"bufio"
"bytes"
"encoding/json"
"html"
"io"
"net/http"
"regexp"
"strings"
"github.com/yetone/magpie/internal/provider"
)
// notAnAPIReply turns a 2xx reply that can't be a model API's answer into
// the 502 it stands for: a web page (a sign-in page, Cloudflare's
// challenge, a relay's own error page, served 200 text/html), a body that
// is empty, or one sent as JSON that doesn't begin as JSON. Each was taken
// for an answer: relayed to the agent as it came, logged as served with
// nothing in or out, the group's next member never asked — Codex was
// handed a text/html page from a relay and failed with "stream closed
// before response.completed" (#1012). Read as a 502, it is a failure like
// any other before the reply begins: the next key, account or member is
// asked, the try rests as a 502 does, and the log says why. Only the
// reply's first bytes are looked at, which a stream waits for anyway; a
// read that fails is left to the reader, as it was. name prefixes the
// reason, for a caller that doesn't put the provider's name before it.
func notAnAPIReply(res *http.Response, name string) *http.Response {
if res == nil || res.StatusCode < 200 || res.StatusCode >= 300 || res.StatusCode == http.StatusNoContent || res.Body == nil {
return res
}
ct := strings.ToLower(res.Header.Get("Content-Type"))
if strings.HasPrefix(ct, "text/html") {
return asBadGateway(res, name, webPage(res, ct))
}
br := bufio.NewReaderSize(res.Body, 4<<10)
res.Body = readCloser{br, res.Body}
if _, err := br.Peek(1); err == io.EOF {
return asBadGateway(res, name, "answered "+res.Status+" with nothing in it, not an API reply")
} else if err != nil {
return res
}
if ce := res.Header.Get("Content-Encoding"); ce != "" && ce != "identity" {
return res // compressed: its first bytes say nothing of what it is
}
head, _ := br.Peek(br.Buffered())
head = bytes.TrimLeft(head, " \t\r\n\ufeff")
switch {
case htmlStart.Match(head) && !strings.Contains(ct, "xml"):
return asBadGateway(res, name, webPage(res, ct))
case strings.Contains(ct, "json") && len(head) > 0 && head[0] != '{' && head[0] != '[':
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<10))
return asBadGateway(res, name, "answered "+res.Status+" with a reply that isn't JSON, not an API reply: "+clipRunes(string(bytes.TrimSpace(b)), 200))
}
return res
}
// htmlStart is how a web page begins.
var htmlStart = regexp.MustCompile(`(?i)^<(!doctype\s+html|html[\s>]|head[\s>]|body[\s>])`)
// pageTitle is a web page's title.
var pageTitle = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
// webPage says what a page served in place of a reply was, by its title,
// and for a firewall's block page what to do about it.
func webPage(res *http.Response, ct string) string {
b, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
if ct == "" {
ct = "no Content-Type"
}
why := "answered " + res.Status + " with a web page (" + strings.TrimSpace(strings.SplitN(ct, ";", 2)[0]) + "), not an API reply"
if m := pageTitle.FindSubmatch(b); m != nil {
if t := strings.Join(strings.Fields(html.UnescapeString(string(m[1]))), " "); t != "" {
why += ": “" + clipRunes(t, 120) + "”"
}
}
if provider.EdgeBlocked(b) {
why += " — " + provider.BlockedHint
}
return why
}
// asBadGateway is res as the 502 it stands for, saying why in an error
// body every reader of one finds its message in.
func asBadGateway(res *http.Response, name, why string) *http.Response {
io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
res.Body.Close()
msg := name + why
b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": msg, "type": "bad_gateway"}, "message": msg})
h := res.Header.Clone()
for _, k := range []string{"Content-Type", "Content-Length", "Content-Encoding", "Transfer-Encoding"} {
h.Del(k)
}
h.Set("Content-Type", "application/json")
out := *res
out.StatusCode, out.Status = http.StatusBadGateway, "502 Bad Gateway"
out.Header, out.Body, out.ContentLength = h, io.NopCloser(bytes.NewReader(b)), int64(len(b))
return &out
}
// readCloser reads through a buffer in front of a body, and closes the
// body.
type readCloser struct {
io.Reader
io.Closer
}
// clipRunes is s cut to at most n runes.
func clipRunes(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n]) + "…"
}
return s
}