diff --git a/internal/gateway/draw.go b/internal/gateway/draw.go index 748ce113..6db113ea 100644 --- a/internal/gateway/draw.go +++ b/internal/gateway/draw.go @@ -68,6 +68,10 @@ var codexDrawers = []catalog.Model{ {ID: "gpt-image-2.5", Name: "GPT Image 2.5", Released: "2026-06-01"}, } +// codexDrawer is the image model Codex CLI asks a ChatGPT account for, the +// one Automatic picks there. +const codexDrawer = "gpt-image-2" + // grokDrawers are the image models a Grok subscription (SuperGrok, X Premium+) // draws with, at the Imagine API of the backend Grok Build's image_gen tool // calls: cli-chat-proxy.grok.com/v1/images/generations. @@ -146,6 +150,11 @@ func AutoDrawer() string { continue } for _, m := range Drawers(p) { + if drawsCodex(p) && m.ID != codexDrawer { + // Codex CLI draws with gpt-image-2 only; a plan may be + // refused the others (chatgpt.com: 403, #545) + continue + } cost := 1e9 switch { case drawsCodex(p), drawsGrok(p): @@ -553,6 +562,7 @@ func (s *Server) sendWith(ctx context.Context, p provider.Provider, method, url, // signing asks of its Responses; the id is Codex CLI's own req.Header.Set("Accept", "application/json") req.Header.Set("x-codex-imagegen-request-id", newUUID()) + req.Header.Set("x-codex-image-turn-id", newUUID()) } } else { // Google's API keys go in their own header @@ -577,7 +587,11 @@ func (s *Server) sendWith(ctx context.Context, p provider.Provider, method, url, return nil, 502, err } if res.StatusCode >= 300 { - return b, res.StatusCode, fmt.Errorf("%s: %d %s%s", provider.HostOf(url), res.StatusCode, http.StatusText(res.StatusCode), vendorSaid(vendorMessage(b))) + hint := "" + if drawsCodex(p) && res.StatusCode == http.StatusForbidden { + hint = fmt.Sprintf(" — ChatGPT turned %s's images away: its plan or workspace may not draw, or not with this model (Codex CLI draws with %s)", p.Name, codexDrawer) + } + return b, res.StatusCode, fmt.Errorf("%s: %d %s%s%s", provider.HostOf(url), res.StatusCode, http.StatusText(res.StatusCode), vendorSaid(vendorMessage(b)), hint) } return b, res.StatusCode, nil } diff --git a/internal/gateway/draw_test.go b/internal/gateway/draw_test.go index 8ade6b7a..da409343 100644 --- a/internal/gateway/draw_test.go +++ b/internal/gateway/draw_test.go @@ -298,7 +298,8 @@ func TestCodexAccountDraws(t *testing.T) { if ds := Drawers(*p); len(ds) != 2 || ds[0].ID != "gpt-image-2" || ds[1].ID != "gpt-image-2.5" { t.Fatalf("drawers %v", ds) } - if m, ok := drawer(); !ok || m != "codex/gpt-image-2.5" { + // Automatic asks for what Codex CLI asks for: a plan may be refused 2.5 + if m, ok := drawer(); !ok || m != "codex/gpt-image-2" { t.Fatalf("drawer = %q %v", m, ok) } s := New() @@ -311,7 +312,7 @@ func TestCodexAccountDraws(t *testing.T) { if paths[0] != "/backend-api/codex/images/generations" || !strings.Contains(bodies[0], `"model":"gpt-image-2"`) { t.Fatalf("asked %v %v", paths, bodies) } - if head.Get("chatgpt-account-id") != "acct-1" || head.Get("Accept") != "application/json" || head.Get("originator") != "codex_cli_rs" || head.Get("x-codex-imagegen-request-id") == "" { + if head.Get("chatgpt-account-id") != "acct-1" || head.Get("Accept") != "application/json" || head.Get("originator") != "codex_cli_rs" || head.Get("x-codex-imagegen-request-id") == "" || head.Get("x-codex-image-turn-id") == "" { t.Fatalf("headers %v", head) } // an edit goes as JSON, the image a data URL: the backend turns multipart away @@ -324,6 +325,32 @@ func TestCodexAccountDraws(t *testing.T) { } } +// chatgpt.com's 403 for a ChatGPT account's images says what it likely +// means, and Automatic asks for the model Codex CLI does (#545). +func TestCodexDrawRefused(t *testing.T) { + codexSignedIn(t) + var models []string + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct{ Model string } + json.NewDecoder(r.Body).Decode(&req) + models = append(models, req.Model) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, `{"detail":"Forbidden"}`) + })) + defer up.Close() + was := provider.CodexBase + provider.CodexBase = up.URL + "/backend-api/codex" + defer func() { provider.CodexBase = was }() + code, _, raw := postImages(t, New(), "/v1/images/generations", "application/json", `{"prompt":"a magpie"}`) + if code != 403 || !strings.Contains(raw, "Forbidden") || !strings.Contains(raw, "plan or workspace may not draw") { + t.Fatalf("%d %s", code, raw) + } + if len(models) != 1 || models[0] != "gpt-image-2" { + t.Fatalf("asked for %v", models) + } +} + // grokSignedIn gives the test's HOME the Grok CLI's sign-in, and a CLI to find. func grokSignedIn(t *testing.T) { t.Helper()