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