From 57f1d53fd39ffc3d5a2411feba349224ad270fb2 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 1 Oct 2026 17:00:11 +0800 Subject: [PATCH] =?UTF-8?q?gateway:=20a=20Codex=20model=20of=20OpenAI's=20?= =?UTF-8?q?that=20fails=20with=20nothing=20said=20says=20where=20it=20went?= =?UTF-8?q?=20(#409:=20Codex=20showed=20"unexpected=20status=20502=20Bad?= =?UTF-8?q?=20Gateway:=20Unknown=20error"=20with=20DeepSeek=20picked=20in?= =?UTF-8?q?=20magpie)=20=E2=80=94=20the=20thread=20was=20still=20on=20Code?= =?UTF-8?q?x's=20own=20gpt-5.6-terra=20("5.6=20Terra"=20in=20the=20compose?= =?UTF-8?q?r),=20which=20goes=20to=20OpenAI=20with=20Codex's=20sign-in,=20?= =?UTF-8?q?and=20OpenAI=20answered=20502=20with=20an=20empty=20body=20that?= =?UTF-8?q?=20magpie=20passed=20through,=20which=20Codex=20shows=20as=20"U?= =?UTF-8?q?nknown=20error".=20An=20error=20status=20with=20a=20blank=20bod?= =?UTF-8?q?y=20on=20/responses=20is=20now=20answered=20with=20that=20statu?= =?UTF-8?q?s=20and=20a=20message:=20OpenAI=20answered=20it=20and=20said=20?= =?UTF-8?q?nothing=20more,=20the=20model=20is=20one=20of=20Codex's=20own?= =?UTF-8?q?=20that=20goes=20to=20OpenAI=20rather=20than=20a=20provider=20i?= =?UTF-8?q?n=20magpie,=20pick=20one=20of=20magpie's=20models;=20the=20requ?= =?UTF-8?q?est=20log=20and=20the=20trace=20say=20the=20same.=20An=20error?= =?UTF-8?q?=20with=20a=20body=20still=20passes=20unchanged.=20TestCodexOwn?= =?UTF-8?q?ModelEmptyError=20fails=20without=20the=20change;=20TestCodexOw?= =?UTF-8?q?nModelErrorBodyKept=20and=20TestCodexMagpieModelEmptyError=20gu?= =?UTF-8?q?ard=20the=20rest.=20go=20vet,=20go=20build=20-tags=20nogui=20(d?= =?UTF-8?q?arwin,=20GOOS=3Dlinux),=20GOOS=3Dwindows=20go=20build=20pass;?= =?UTF-8?q?=20gateway=20TestCodex*=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/codex_backend.go | 25 ++++++++ internal/gateway/codex_empty_error_test.go | 67 ++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 internal/gateway/codex_empty_error_test.go diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index 34adf663..d8e184d6 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -320,6 +320,21 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri end(res.StatusCode, res.Status, 0, 0) return } + if rest == "/responses" && res.StatusCode >= 400 { + msg, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + res.Body = io.NopCloser(bytes.NewReader(msg)) + if len(bytes.TrimSpace(msg)) == 0 { + // a failure with nothing said of it, which Codex shows as + // "Unknown error" alone (#409): said where the turn went + said := codexFailedEmpty(modelOf(body), res.Status) + writeError(w, provider.Responses, res.StatusCode, said) + s.record(Call{Time: start, From: provider.Responses, To: provider.Responses, Model: modelOf(body), + Provider: "openai", Agent: agentOf(r), Kind: callKind(r.Header), Status: res.StatusCode, + Millis: time.Since(start).Milliseconds(), Error: said}) + end(res.StatusCode, said, 0, 0) + return + } + } for k, vs := range res.Header { if !hopHeader(k) { w.Header()[k] = vs @@ -453,6 +468,16 @@ func codexUnreached(model string, err error) string { "(provider/model, or set it in magpie's Agents view), or let Codex reach OpenAI" } +// codexFailedEmpty says why a model of Codex's own failed when OpenAI gave +// an error status and nothing else: where it went, and what to do — a +// Codex left on its own model while magpie's Agents view picked another +// for it sends the turn to OpenAI, not to that one (#409). +func codexFailedEmpty(model, status string) string { + return "OpenAI answered " + status + " and said nothing more. " + model + " is one of Codex's own models, " + + "which goes to OpenAI with Codex's own sign-in, not to a provider in magpie: pick one of magpie's models in Codex " + + "(provider/model, or set it in magpie's Agents view), or try again later" +} + // apiKey reports whether Codex signed in with an API key rather than a // ChatGPT account. func apiKey(h http.Header) bool { diff --git a/internal/gateway/codex_empty_error_test.go b/internal/gateway/codex_empty_error_test.go new file mode 100644 index 00000000..fefc1192 --- /dev/null +++ b/internal/gateway/codex_empty_error_test.go @@ -0,0 +1,67 @@ +package gateway + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// One of Codex's own models, failed by the ChatGPT backend with an error +// status and nothing in its body, fails saying where it went and what to do: +// relayed as it came, Codex said only "unexpected status 502 Bad Gateway: +// Unknown error" (#409, Codex's app left on GPT-5.6 Terra while magpie's +// Agents view had DeepSeek picked for it). +func TestCodexOwnModelEmptyError(t *testing.T) { + setup(t, provider.Chat, &fake{t: t}) + chatgpt(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + }) + code, body := codexPost(t, `{"model":"gpt-5.6-terra","stream":true,"input":"今天几号"}`) + var e struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if code != 502 || json.Unmarshal([]byte(body), &e) != nil { + t.Fatalf("%d %q", code, body) + } + for _, want := range []string{"502 Bad Gateway", "gpt-5.6-terra is one of Codex's own models", "pick one of magpie's models"} { + if !strings.Contains(e.Error.Message, want) { + t.Errorf("no %q in %q", want, e.Error.Message) + } + } +} + +// What the ChatGPT backend said of a failure still goes to Codex as it said +// it. +func TestCodexOwnModelErrorBodyKept(t *testing.T) { + setup(t, provider.Chat, &fake{t: t}) + said := `{"error":{"message":"The model is overloaded","type":"server_error"}}` + chatgpt(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + io.WriteString(w, said) + }) + if code, body := codexPost(t, `{"model":"gpt-5.6-terra","stream":true,"input":"hi"}`); code != 502 || body != said { + t.Fatalf("%d %s", code, body) + } +} + +// A magpie model whose vendor fails with an empty body is never relayed +// empty: the agent is told the vendor and its status. +func TestCodexMagpieModelEmptyError(t *testing.T) { + setup(t, provider.Responses, &fake{t: t, refuse: func([]byte) (int, string) { return http.StatusBadGateway, " " }}) + code, body := codexPost(t, `{"model":"fake/m1","stream":true,"input":"hi"}`) + var e struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if code != 502 || json.Unmarshal([]byte(body), &e) != nil || !strings.Contains(e.Error.Message, "502") { + t.Fatalf("%d %q", code, body) + } +}