Skip to content

Commit ceb90b5

Browse files
committed
fix(warmup): surface upstream error detail and retry fallback models on 400
- Read response body on non-2xx warmup and extract structured JSON error messages (never raw text — may contain tokens/credentials) - Add upstreamDetail field to CodexWarmupError, surfaced in reason string - auth-api.ts now shows upstream detail in user-facing error message instead of generic 'Reauthenticate' when structured detail is available - Retry with fallback model (gpt-5.5) when default model (gpt-5.4-mini) returns 400 — accounts that lack mini access still warm up - Add tests for body-reading, error propagation, and fallback retry Addresses reported issue: second account add fails with http_status:400 on Windows/Vision Pro — users now see the actual backend rejection reason and the retry may succeed with a different model.
1 parent 60340da commit ceb90b5

3 files changed

Lines changed: 160 additions & 9 deletions

File tree

src/codex/auth-api.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota
2727
import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
2828
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
2929
import { maskEmail } from "../lib/privacy";
30-
import { codexWarmupFailureReason, warmCodexAccount } from "./warmup";
30+
import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup";
3131
export { maskEmail } from "../lib/privacy";
3232
import type { CodexAccount, OcxConfig } from "../types";
3333

@@ -149,12 +149,16 @@ async function verifyCodexAccountWarmup(
149149
await warmCodexAccount({ accessToken, chatgptAccountId });
150150
return { ok: true, validatedAt: Date.now() };
151151
} catch (err) {
152+
const reason = codexWarmupFailureReason(err);
153+
const upstream = err instanceof CodexWarmupError ? err.upstreamDetail : undefined;
152154
return {
153155
ok: false,
154156
response: jsonResponse({
155-
error: "Codex account warmup failed. Reauthenticate the account and try again.",
157+
error: upstream
158+
? `Codex account warmup failed: ${upstream}`
159+
: "Codex account warmup failed. Reauthenticate the account and try again.",
156160
code: "codex_warmup_failed",
157-
reason: codexWarmupFailureReason(err),
161+
reason,
158162
accountId,
159163
}, 401),
160164
};

src/codex/warmup.ts

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
export class CodexWarmupError extends Error {
22
code: "http_status" | "missing_body" | "stream_failed" | "stream_incomplete" | "stream_error" | "invalid_sse" | "no_terminal" | "transport";
33
status?: number;
4+
/** Upstream error detail extracted from the response body (truncated to 512 chars). */
5+
upstreamDetail?: string;
46

57
constructor(
68
code: CodexWarmupError["code"],
79
message = "Codex warmup failed",
8-
options: { status?: number; cause?: unknown } = {},
10+
options: { status?: number; cause?: unknown; upstreamDetail?: string } = {},
911
) {
1012
super(message);
1113
this.name = "CodexWarmupError";
1214
this.code = code;
1315
this.status = options.status;
16+
this.upstreamDetail = options.upstreamDetail;
1417
if (options.cause !== undefined) this.cause = options.cause;
1518
}
1619
}
@@ -24,11 +27,39 @@ export interface CodexWarmupOptions {
2427

2528
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
2629
const DEFAULT_MODEL = "gpt-5.4-mini";
30+
const FALLBACK_MODELS = ["gpt-5.5"];
2731
const DEFAULT_TIMEOUT_MS = 30_000;
32+
const MAX_ERROR_BODY_BYTES = 2048;
33+
34+
/** Read the first MAX_ERROR_BODY_BYTES of a response body and extract an error message. */
35+
async function readErrorDetail(res: Response): Promise<string | undefined> {
36+
try {
37+
const text = await res.text();
38+
const trimmed = text.slice(0, MAX_ERROR_BODY_BYTES);
39+
try {
40+
const json = JSON.parse(trimmed) as Record<string, unknown>;
41+
// ChatGPT backend error shape: { error: { message: "..." } } or { detail: "..." }
42+
const nested = json.error;
43+
if (nested && typeof nested === "object" && typeof (nested as Record<string, unknown>).message === "string") {
44+
return ((nested as Record<string, unknown>).message as string).slice(0, 512);
45+
}
46+
if (typeof json.detail === "string") return json.detail.slice(0, 512);
47+
if (typeof json.error === "string") return (json.error as string).slice(0, 512);
48+
if (typeof json.message === "string") return json.message.slice(0, 512);
49+
} catch {
50+
// Non-JSON response body may contain sensitive data (tokens, credentials).
51+
// Only surface structured error messages, never raw text.
52+
}
53+
return undefined;
54+
} catch {
55+
return undefined;
56+
}
57+
}
2858

2959
function safeWarmupReason(err: unknown): string {
3060
if (err instanceof CodexWarmupError) {
31-
return err.status ? `${err.code}:${err.status}` : err.code;
61+
const base = err.status ? `${err.code}:${err.status}` : err.code;
62+
return err.upstreamDetail ? `${base}${err.upstreamDetail}` : base;
3263
}
3364
return "transport";
3465
}
@@ -99,7 +130,7 @@ async function drainWarmupSse(body: ReadableStream<Uint8Array>): Promise<void> {
99130
}
100131
}
101132

102-
export async function warmCodexAccount(options: CodexWarmupOptions): Promise<void> {
133+
async function tryWarmup(options: CodexWarmupOptions, model: string): Promise<void> {
103134
let res: Response;
104135
try {
105136
res = await fetch(CODEX_RESPONSES_URL, {
@@ -110,7 +141,7 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<voi
110141
"Content-Type": "application/json",
111142
},
112143
body: JSON.stringify({
113-
model: options.model?.trim() || DEFAULT_MODEL,
144+
model,
114145
instructions: "Reply with OK.",
115146
input: "hi",
116147
stream: true,
@@ -123,8 +154,11 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<voi
123154
}
124155

125156
if (!res.ok) {
126-
await res.body?.cancel().catch(() => {});
127-
throw new CodexWarmupError("http_status", "Codex warmup was rejected", { status: res.status });
157+
const upstreamDetail = await readErrorDetail(res);
158+
throw new CodexWarmupError("http_status", "Codex warmup was rejected", {
159+
status: res.status,
160+
upstreamDetail,
161+
});
128162
}
129163
if (!res.body) throw new CodexWarmupError("missing_body");
130164

@@ -135,3 +169,25 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<voi
135169
}
136170
}
137171

172+
export async function warmCodexAccount(options: CodexWarmupOptions): Promise<void> {
173+
const primaryModel = options.model?.trim() || DEFAULT_MODEL;
174+
try {
175+
await tryWarmup(options, primaryModel);
176+
return;
177+
} catch (err) {
178+
// Retry with fallback models on 400 (model may not be available for this account).
179+
if (!(err instanceof CodexWarmupError) || err.status !== 400) throw err;
180+
let lastErr = err;
181+
for (const fallback of FALLBACK_MODELS) {
182+
if (fallback === primaryModel) continue;
183+
try {
184+
await tryWarmup(options, fallback);
185+
return;
186+
} catch (retryErr) {
187+
if (retryErr instanceof CodexWarmupError) lastErr = retryErr;
188+
}
189+
}
190+
throw lastErr;
191+
}
192+
}
193+

tests/warmup.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
2+
import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "../src/codex/warmup";
3+
4+
const originalFetch = globalThis.fetch;
5+
6+
function sseResponse(frame = 'data: {"type":"response.completed"}\n\n'): Response {
7+
const encoder = new TextEncoder();
8+
return new Response(new ReadableStream<Uint8Array>({
9+
start(controller) {
10+
controller.enqueue(encoder.encode(frame));
11+
controller.close();
12+
},
13+
}), { status: 200, headers: { "Content-Type": "text/event-stream" } });
14+
}
15+
16+
afterEach(() => {
17+
globalThis.fetch = originalFetch;
18+
});
19+
20+
describe("codex warmup improvements", () => {
21+
test("CodexWarmupError exposes upstreamDetail", () => {
22+
const err = new CodexWarmupError("http_status", "Codex warmup was rejected", {
23+
status: 400,
24+
upstreamDetail: "model is not enabled",
25+
});
26+
27+
expect(err.upstreamDetail).toBe("model is not enabled");
28+
});
29+
30+
test("codexWarmupFailureReason includes upstream detail when present", () => {
31+
const err = new CodexWarmupError("http_status", "Codex warmup was rejected", {
32+
status: 400,
33+
upstreamDetail: "model is not enabled",
34+
});
35+
36+
expect(codexWarmupFailureReason(err)).toBe("http_status:400 — model is not enabled");
37+
});
38+
39+
test("codexWarmupFailureReason preserves the old format without upstream detail", () => {
40+
const err = new CodexWarmupError("http_status", "Codex warmup was rejected", {
41+
status: 400,
42+
});
43+
44+
expect(codexWarmupFailureReason(err)).toBe("http_status:400");
45+
});
46+
47+
test("warmCodexAccount reports detail parsed from JSON error bodies", async () => {
48+
const fetchMock = mock(async () =>
49+
new Response(JSON.stringify({ error: { message: "model gpt-5.4-mini is unavailable" } }), {
50+
status: 401,
51+
headers: { "Content-Type": "application/json" },
52+
}));
53+
globalThis.fetch = fetchMock as unknown as typeof fetch;
54+
55+
try {
56+
await warmCodexAccount({ accessToken: "access-test", chatgptAccountId: "acct-test" });
57+
throw new Error("expected warmup to reject");
58+
} catch (err) {
59+
expect(err).toBeInstanceOf(CodexWarmupError);
60+
expect((err as CodexWarmupError).code).toBe("http_status");
61+
expect((err as CodexWarmupError).status).toBe(401);
62+
expect((err as CodexWarmupError).upstreamDetail).toBe("model gpt-5.4-mini is unavailable");
63+
expect(codexWarmupFailureReason(err)).toBe("http_status:401 — model gpt-5.4-mini is unavailable");
64+
}
65+
});
66+
67+
test("warmCodexAccount retries FALLBACK_MODELS when the default model returns 400", async () => {
68+
const parsedBodies: Record<string, unknown>[] = [];
69+
const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
70+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
71+
parsedBodies.push(body);
72+
73+
if (body.model === "gpt-5.4-mini") {
74+
return new Response(JSON.stringify({ detail: "unknown model" }), { status: 400 });
75+
}
76+
77+
if (body.model === "gpt-5.5") return sseResponse();
78+
return new Response("unexpected model", { status: 500 });
79+
});
80+
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
81+
82+
try {
83+
await warmCodexAccount({ accessToken: "access-test", chatgptAccountId: "acct-test" });
84+
} finally {
85+
fetchSpy.mockRestore();
86+
}
87+
88+
expect(fetchMock).toHaveBeenCalledTimes(2);
89+
expect(parsedBodies.map(body => body.model)).toEqual(["gpt-5.4-mini", "gpt-5.5"]);
90+
});
91+
});

0 commit comments

Comments
 (0)