diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index b3e1463660..673495449d 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -39,8 +39,30 @@ const CHATGPT_USER_AGENT = const OAI_CLIENT_VERSION = "prod-81e0c5cdf6140e8c5db714d613337f4aeab94029"; const OAI_CLIENT_BUILD_NUMBER = "6128297"; -// Stable per-process device ID (matches the browser's persistent oai-did cookie behaviour). -const DEVICE_ID = randomUUID(); +// Per-cookie device ID. The browser stores a persistent `oai-did` cookie that +// uniquely identifies the device for OpenAI's risk model — we derive a stable +// UUID from a hash of the session cookie so that each account/connection gets +// its own device id, but it doesn't change between requests. +const deviceIdCache = new Map(); +function deviceIdFor(cookie: string): string { + const key = cookieKey(cookie); + let id = deviceIdCache.get(key); + if (!id) { + // Synthesize a UUID v4-shaped string from a SHA-256 of the cookie. Stable, + // deterministic per cookie, no PII (the cookie's already secret). + const h = createHash("sha256").update(cookie).digest("hex"); + id = + `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-` + + `${((parseInt(h.slice(16, 17), 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-` + + h.slice(20, 32); + if (deviceIdCache.size >= 200) { + const first = deviceIdCache.keys().next().value; + if (first) deviceIdCache.delete(first); + } + deviceIdCache.set(key, id); + } + return id; +} // OmniRoute model ID → ChatGPT internal slug. ChatGPT's web routes use // dash-separated IDs (e.g. "gpt-5-3" not "gpt-5.3-instant"). @@ -67,10 +89,10 @@ function browserHeaders(): Record { } /** Headers ChatGPT's web client sends on backend-api requests. */ -function oaiHeaders(sessionId: string): Record { +function oaiHeaders(sessionId: string, deviceId: string): Record { return { "OAI-Language": "en-US", - "OAI-Device-Id": DEVICE_ID, + "OAI-Device-Id": deviceId, "OAI-Client-Version": OAI_CLIENT_VERSION, "OAI-Client-Build-Number": OAI_CLIENT_BUILD_NUMBER, "OAI-Session-Id": sessionId, @@ -117,71 +139,11 @@ function tokenStore(cookie: string, entry: TokenEntry): void { } } -// ─── Conversation continuity cache ────────────────────────────────────────── -// Keyed by FNV hash of message history → { conversationId, lastMessageId }. -// Same pattern as perplexity-web.ts:50-99. - -interface ConvEntry { - conversationId: string; - lastMessageId: string; - ts: number; -} - -const CONV_TTL_MS = 60 * 60 * 1000; -const CONV_MAX = 200; -const convCache = new Map(); - -function historyKey(history: Array<{ role: string; content: string }>): string { - const parts = history.map((h) => `${h.role}:${h.content}`).join("\n"); - let hash = 0x811c9dc5; - for (let i = 0; i < parts.length; i++) { - hash ^= parts.charCodeAt(i); - hash = (hash * 0x01000193) >>> 0; - } - return hash.toString(16).padStart(8, "0"); -} - -function convLookup( - history: Array<{ role: string; content: string }> -): { conversationId: string; lastMessageId: string } | null { - if (history.length === 0) return null; - const key = historyKey(history); - const entry = convCache.get(key); - if (!entry) return null; - if (Date.now() - entry.ts > CONV_TTL_MS) { - convCache.delete(key); - return null; - } - return { conversationId: entry.conversationId, lastMessageId: entry.lastMessageId }; -} - -function convStore( - history: Array<{ role: string; content: string }>, - currentMsg: string, - responseText: string, - conversationId: string, - lastMessageId: string -): void { - if (!conversationId || !lastMessageId) return; - const full = [ - ...history, - { role: "user", content: currentMsg }, - { role: "assistant", content: responseText }, - ]; - const key = historyKey(full); - convCache.set(key, { conversationId, lastMessageId, ts: Date.now() }); - if (convCache.size > CONV_MAX) { - let oldestKey: string | null = null; - let oldestTs = Infinity; - for (const [k, v] of convCache) { - if (v.ts < oldestTs) { - oldestTs = v.ts; - oldestKey = k; - } - } - if (oldestKey) convCache.delete(oldestKey); - } -} +// Conversation continuity is intentionally not cached. The conversation body +// sets `history_and_training_disabled: true` (Temporary Chat mode), and +// chatgpt.com expires those conversation_ids quickly — re-using them returns +// 404. Open WebUI and most OpenAI-API-style clients send the full history +// each turn anyway, so each request just starts a fresh conversation. // ─── /api/auth/session — exchange cookie for JWT ──────────────────────────── @@ -305,11 +267,13 @@ interface ChatRequirements { const warmupCache = new Map(); const WARMUP_TTL_MS = 60_000; +const WARMUP_CACHE_MAX = 200; async function runSessionWarmup( accessToken: string, accountId: string | null, sessionId: string, + deviceId: string, cookie: string, signal: AbortSignal | null | undefined, log: { debug?: (tag: string, msg: string) => void } | null | undefined @@ -318,11 +282,17 @@ async function runSessionWarmup( const now = Date.now(); const last = warmupCache.get(key); if (last && now - last < WARMUP_TTL_MS) return; + // Bound the cache: drop the oldest entry once we hit the cap. Map iteration + // order is insertion order, so the first key is the oldest. + if (warmupCache.size >= WARMUP_CACHE_MAX && !warmupCache.has(key)) { + const first = warmupCache.keys().next().value; + if (first) warmupCache.delete(first); + } warmupCache.set(key, now); const headers: Record = { ...browserHeaders(), - ...oaiHeaders(sessionId), + ...oaiHeaders(sessionId, deviceId), Accept: "*/*", Authorization: `Bearer ${accessToken}`, Cookie: buildSessionCookieHeader(cookie), @@ -358,16 +328,17 @@ async function prepareChatRequirements( accessToken: string, accountId: string | null, sessionId: string, + deviceId: string, cookie: string, dplInfo: { dpl: string; scriptSrc: string }, signal: AbortSignal | null | undefined ): Promise { const config = buildPrekeyConfig(CHATGPT_USER_AGENT, dplInfo.dpl, dplInfo.scriptSrc); - const prekey = buildPrepareToken(config); + const prekey = await buildPrepareToken(config); const headers: Record = { ...browserHeaders(), - ...oaiHeaders(sessionId), + ...oaiHeaders(sessionId, deviceId), "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, Cookie: buildSessionCookieHeader(cookie), @@ -567,10 +538,22 @@ function buildPrekeyConfig(userAgent: string, dpl: string, scriptSrc: string): u * (difficulty "0fffff") mutating config[3] to find a hash whose hex prefix * is ≤ the difficulty. Mirrors chat2api / openai-sentinel. */ -function buildPrepareToken(config: unknown[]): string { +// PoW solvers run up to 100k–500k SHA3-512 hashes. To avoid blocking the +// Node event loop on a busy server, we yield with `setImmediate` every +// POW_YIELD_EVERY iterations — roughly every ~5ms of work — so concurrent +// requests and I/O still get scheduled. Wall time is approximately the same +// as the synchronous version; what changes is fairness, not throughput. +const POW_YIELD_EVERY = 1000; + +function yieldToEventLoop(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +async function buildPrepareToken(config: unknown[]): Promise { const target = "0fffff"; const cfg = [...config]; for (let i = 0; i < 100_000; i++) { + if (i > 0 && i % POW_YIELD_EVERY === 0) await yieldToEventLoop(); cfg[3] = i; const json = JSON.stringify(cfg); const b64 = Buffer.from(json).toString("base64"); @@ -584,12 +567,17 @@ function buildPrepareToken(config: unknown[]): string { return `gAAAAAC${b64}`; } -function solveProofOfWork(seed: string, difficulty: string, config: unknown[]): string { +async function solveProofOfWork( + seed: string, + difficulty: string, + config: unknown[] +): Promise { const target = (difficulty || "").toLowerCase(); const cfg = [...config]; const maxIter = 500_000; for (let i = 0; i < maxIter; i++) { + if (i > 0 && i % POW_YIELD_EVERY === 0) await yieldToEventLoop(); cfg[3] = i; const json = JSON.stringify(cfg); const b64 = Buffer.from(json).toString("base64"); @@ -972,10 +960,12 @@ function buildStreamingResponse( ) ); controller.enqueue(encoder.encode("data: [DONE]\n\n")); - - if (respConversationId && respMessageId) { - convStore(history, currentMsg, fullAnswer, respConversationId, respMessageId); - } + // Conversation continuity intentionally not persisted (Temporary Chat + // mode — see comment near the top of the file). + void respConversationId; + void respMessageId; + void history; + void currentMsg; } catch (err) { controller.enqueue( encoder.encode( @@ -1037,9 +1027,10 @@ async function buildNonStreamingResponse( if (chunk.answer) fullAnswer = chunk.answer; } - if (respConversationId && respMessageId) { - convStore(history, currentMsg, fullAnswer, respConversationId, respMessageId); - } + // Conversation continuity intentionally not persisted (Temporary Chat mode). + void respConversationId; + void respMessageId; + void history; fullAnswer = cleanChatGptText(fullAnswer); const promptTokens = Math.ceil(currentMsg.length / 4); @@ -1190,10 +1181,12 @@ export class ChatGptWebExecutor extends BaseExecutor { // browser does on page load. Failures here are non-fatal; the worst case // is Sentinel still escalates to Turnstile. const sessionId = randomUUID(); + const deviceId = deviceIdFor(cookie); await runSessionWarmup( tokenEntry.accessToken, tokenEntry.accountId, sessionId, + deviceId, cookie, signal, log @@ -1206,6 +1199,7 @@ export class ChatGptWebExecutor extends BaseExecutor { tokenEntry.accessToken, tokenEntry.accountId, sessionId, + deviceId, cookie, dplInfo, signal @@ -1258,7 +1252,11 @@ export class ChatGptWebExecutor extends BaseExecutor { let proofToken: string | null = null; if (reqs.proofofwork?.required && reqs.proofofwork.seed && reqs.proofofwork.difficulty) { const powConfig = buildPrekeyConfig(CHATGPT_USER_AGENT, dplInfo.dpl, dplInfo.scriptSrc); - proofToken = solveProofOfWork(reqs.proofofwork.seed, reqs.proofofwork.difficulty, powConfig); + proofToken = await solveProofOfWork( + reqs.proofofwork.seed, + reqs.proofofwork.difficulty, + powConfig + ); } // 4. Build conversation request @@ -1272,13 +1270,11 @@ export class ChatGptWebExecutor extends BaseExecutor { }; } - // Conversation continuity is intentionally disabled here. The conversation - // body sets `history_and_training_disabled: true` (Temporary Chat mode), - // and chatgpt.com expires those conversation_ids quickly — re-using them - // returns 404. Open WebUI and most OpenAI-API clients send the full - // history each turn anyway, so we just always start a fresh conversation. - // (The convCache is kept around in case we re-enable persistent chats - // later, but lookups are skipped here.) + // Conversation continuity is intentionally disabled. The body sets + // `history_and_training_disabled: true` (Temporary Chat mode) and + // chatgpt.com expires those conversation_ids quickly — re-using them + // returns 404. Each request starts a fresh conversation; clients (Open + // WebUI, OpenAI-API-style) send the full history each turn anyway. const conversationId: string | null = null; const parentMessageId = randomUUID(); @@ -1287,7 +1283,7 @@ export class ChatGptWebExecutor extends BaseExecutor { const headers: Record = { ...browserHeaders(), - ...oaiHeaders(sessionId), + ...oaiHeaders(sessionId, deviceId), "Content-Type": "application/json", Accept: "text/event-stream", Authorization: `Bearer ${tokenEntry.accessToken}`, @@ -1313,6 +1309,11 @@ export class ChatGptWebExecutor extends BaseExecutor { body: JSON.stringify(cgptBody), timeoutMs: 120_000, // generations can take a while signal, + // For real-time streaming, ask the TLS client to write the body to + // a temp file and surface it as a ReadableStream as it arrives — + // otherwise long generations buffer entirely before the client sees + // anything (and the downstream HTTP request can time out). + stream, }); } catch (err) { log?.error?.("CGPT-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); @@ -1331,10 +1332,10 @@ export class ChatGptWebExecutor extends BaseExecutor { if (response.status >= 400) { const status = response.status; - // Always log the upstream body on 4xx/5xx — error responses are small - // and the upstream message is much more useful than our wrapper. - - console.log(`[CGPT-WEB] conv ${status}: ${(response.text || "").slice(0, 400)}`); + // Log the upstream body on 4xx/5xx — error responses are small and the + // upstream message is much more useful than our wrapper. Goes through + // the executor logger so it respects the application's log config. + log?.warn?.("CGPT-WEB", `conv ${status}: ${(response.text || "").slice(0, 400)}`); let errMsg = `ChatGPT returned HTTP ${status}`; if (status === 401 || status === 403) { errMsg = @@ -1342,10 +1343,7 @@ export class ChatGptWebExecutor extends BaseExecutor { tokenCache.delete(cookieKey(cookie)); } else if (status === 404) { errMsg = - "ChatGPT returned 404 — usually a stale conversation_id or the model is no longer available on this account. The next request will start a fresh conversation."; - // Clear conv cache so any stale ids are dropped (defensive — we don't - // currently use the cache, but this also clears anything left over). - convCache.clear(); + "ChatGPT returned 404 — usually the model is no longer available on this account or the chat-requirements-token expired. Retry will start a fresh conversation."; } else if (status === 429) { errMsg = "ChatGPT rate limited. Wait a moment and retry."; } @@ -1358,9 +1356,16 @@ export class ChatGptWebExecutor extends BaseExecutor { }; } - // The TLS client buffers the full response body for non-streaming requests. - // Wrap it in a ReadableStream so the existing SSE parser can consume it. - if (!response.text) { + // For streaming requests the TLS client returns a ReadableStream that + // tails the temp file as it's written. For non-streaming requests, it + // returns the full body as text — wrap that in a one-shot stream so the + // existing SSE parser can consume it uniformly. + let bodyStream: ReadableStream; + if (response.body) { + bodyStream = response.body; + } else if (response.text) { + bodyStream = stringToStream(response.text); + } else { return { response: errorResponse(502, "ChatGPT returned empty response body"), url: CONV_URL, @@ -1369,8 +1374,6 @@ export class ChatGptWebExecutor extends BaseExecutor { }; } - const bodyStream = stringToStream(response.text); - const cid = `chatcmpl-cgpt-${crypto.randomUUID().slice(0, 12)}`; const created = Math.floor(Date.now() / 1000); @@ -1433,5 +1436,7 @@ function stringToStream(text: string): ReadableStream { // Test-only: clear caches between tests export function __resetChatGptWebCachesForTesting(): void { tokenCache.clear(); - convCache.clear(); + warmupCache.clear(); + deviceIdCache.clear(); + dplCache = null; } diff --git a/open-sse/services/chatgptTlsClient.ts b/open-sse/services/chatgptTlsClient.ts index a71aeedb11..c42a0dfbf8 100644 --- a/open-sse/services/chatgptTlsClient.ts +++ b/open-sse/services/chatgptTlsClient.ts @@ -61,14 +61,14 @@ async function getClient(): Promise<{ request: (url: string, opts: Record) => Promise; }; await client.start(); - + console.log("[CGPT-TLS] Native runtime ready (Firefox 148 fingerprint)."); installExitHook(); return client; } catch (err) { clientPromise = null; const msg = err instanceof Error ? err.message : String(err); - + console.log(`[CGPT-TLS] FAILED to start: ${msg}`); throw new TlsClientUnavailableError( `TLS impersonation client failed to start: ${msg}. ` + @@ -143,7 +143,18 @@ export async function tlsFetchChatGpt( options: TlsFetchOptions = {} ): Promise { if (testOverride) return testOverride(url, options); + // Honor abort signals up-front. tls-client-node's koffi binding doesn't + // accept an AbortSignal mid-flight (the binary call is opaque), so the best + // we can do is bail before issuing the call. We also re-check after — if + // the caller aborted while the upstream was running, throw rather than + // returning a stale response so the caller doesn't try to use it. + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } const client = await getClient(); + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } const requestOptions: Record = { method: options.method || "GET", @@ -156,10 +167,19 @@ export async function tlsFetchChatGpt( }; if (options.stream) { - return await tlsFetchStreaming(client, url, requestOptions, options.streamEofSymbol); + return await tlsFetchStreaming( + client, + url, + requestOptions, + options.streamEofSymbol, + options.signal ?? null + ); } const tlsResponse = await client.request(url, requestOptions); + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } return { status: tlsResponse.status, headers: toHeaders(tlsResponse.headers), @@ -168,6 +188,14 @@ export async function tlsFetchChatGpt( }; } +function makeAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + if (reason instanceof Error) return reason; + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + return err; +} + function toHeaders(raw: Record): Headers { const h = new Headers(); for (const [k, vs] of Object.entries(raw || {})) { @@ -185,7 +213,8 @@ async function tlsFetchStreaming( client: { request: (url: string, opts: Record) => Promise }, url: string, requestOptions: Record, - eofSymbol = "[DONE]" + eofSymbol = "[DONE]", + signal: AbortSignal | null = null ): Promise { const dir = await mkdtemp(join(tmpdir(), "cgpt-stream-")); const path = join(dir, `${randomUUID()}.sse`); @@ -197,14 +226,16 @@ async function tlsFetchStreaming( streamOutputEOFSymbol: eofSymbol, }; - // Kick off the request in the background — we don't await it because - // tls-client returns headers eagerly but the body keeps writing to the file. + // Kick off the request without awaiting — tls-client writes the body to + // `path` chunk-by-chunk while the call runs. The Promise resolves when the + // request fully completes (full body written). const requestPromise = client.request(url, streamOpts); - // Wait for the file to exist, then build a tailing reader. + // Wait briefly for the file to appear so we can detect early errors. const ready = await waitForFile(path, 5_000); if (!ready) { - // If the file never appeared, the request likely errored out — surface that. + // File never appeared — request must have errored out before any body + // bytes. Wait for it to settle and surface as a non-streaming response. const r = await requestPromise.catch( (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike ); @@ -216,29 +247,44 @@ async function tlsFetchStreaming( }; } - // Tail the file. requestPromise resolves with status/headers once Cloudflare - // accepts and the body finishes streaming. - const stream = tailFile( - path, - eofSymbol, - requestPromise.then((r) => r) - ); - // We don't have headers/status until the request resolves, so resolve - // asynchronously but expose a sentinel for callers that only care about body. - const meta = await Promise.race([ - requestPromise, - new Promise((_, rej) => setTimeout(() => rej(new Error("stream meta timeout")), 30_000)), - ]).catch((e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike); + // Peek the first bytes to distinguish a JSON error envelope from an SSE + // body. Errors typically come back as `{"detail":"..."}`; SSE bodies start + // with `data:` or empty lines. If it looks like an error, wait for the + // full body and return non-streaming so the executor can read response.text. + const peek = await readFirstBytes(path, 256); + const trimmedPeek = peek.replace(/^[\s\r\n]+/, ""); + if (trimmedPeek.startsWith("{")) { + const r = await requestPromise.catch( + (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike + ); + return { + status: r.status, + headers: toHeaders(r.headers), + text: r.body, + body: null, + }; + } - // Cleanup of the temp file/dir happens inside tailFile when EOF is reached. - void dir; // keep ref for type-checker + // Tail the file as a real-time stream. We assume HTTP 200 here — if the + // upstream errored, we'd have caught it via the JSON-peek above. The + // request promise is still tracked so cleanup can run after it settles. + const stream = tailFile(path, eofSymbol, requestPromise, signal); + const headers = new Headers({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }); + return { status: 200, headers, text: null, body: stream }; +} - return { - status: meta.status, - headers: toHeaders(meta.headers), - text: null, - body: stream, - }; +async function readFirstBytes(path: string, n: number): Promise { + const fd = await open(path, "r"); + try { + const buf = Buffer.alloc(n); + const { bytesRead } = await fd.read(buf, 0, n, 0); + return buf.subarray(0, bytesRead).toString("utf8"); + } finally { + await fd.close().catch(() => {}); + } } async function waitForFile(path: string, timeoutMs: number): Promise { @@ -257,7 +303,8 @@ async function waitForFile(path: string, timeoutMs: number): Promise { function tailFile( path: string, eofSymbol: string, - done: Promise + done: Promise, + signal: AbortSignal | null = null ): ReadableStream { return new ReadableStream({ async start(controller) { @@ -265,14 +312,24 @@ function tailFile( const buf = Buffer.alloc(64 * 1024); let offset = 0; let finished = false; + let aborted = false; // Mark when the request completes so we know to drain the rest. done.finally(() => { finished = true; }); + // If the caller aborts, stop tailing immediately. + const onAbort = () => { + aborted = true; + }; + if (signal) { + if (signal.aborted) aborted = true; + else signal.addEventListener("abort", onAbort, { once: true }); + } + try { - while (true) { + while (!aborted) { const { bytesRead } = await fd.read(buf, 0, buf.length, offset); if (bytesRead > 0) { const chunk = buf.subarray(0, bytesRead); @@ -294,6 +351,7 @@ function tailFile( } catch (err) { controller.error(err); } finally { + if (signal) signal.removeEventListener("abort", onAbort); await fd.close().catch(() => {}); await unlink(path).catch(() => {}); const dir = path.substring(0, path.lastIndexOf("/")); diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts index d06048638f..081e9b42fe 100644 --- a/tests/unit/chatgpt-web.test.ts +++ b/tests/unit/chatgpt-web.test.ts @@ -326,7 +326,8 @@ test("Sentinel: chat-requirements token forwarded on conv request", async () => signal: AbortSignal.timeout(10_000), log: null, }); - const convHeaders = m.calls.headers[2]; + const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); + const convHeaders = m.calls.headers[convIdx]; assert.equal(convHeaders["openai-sentinel-chat-requirements-token"], "REQ-TOKEN-XYZ"); } finally { m.restore(); @@ -354,7 +355,8 @@ test("PoW: when required, proof token is sent with valid prefix", async () => { signal: AbortSignal.timeout(15_000), log: null, }); - const convHeaders = m.calls.headers[2]; + const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); + const convHeaders = m.calls.headers[convIdx]; const proof = convHeaders["openai-sentinel-proof-token"]; assert.ok(proof, "proof token should be present"); assert.match(proof, /^[gw]AAAAAB/); @@ -797,7 +799,11 @@ test("Session continuity: each call starts a fresh conversation (Temporary Chat }); assert.equal(m.calls.conv, 2); - const secondBody = JSON.parse(m.calls.bodies[4]); + const convIndices = m.calls.urls + .map((u, i) => (u.endsWith("/backend-api/f/conversation") ? i : -1)) + .filter((i) => i >= 0); + assert.equal(convIndices.length, 2); + const secondBody = JSON.parse(m.calls.bodies[convIndices[1]]); assert.equal(secondBody.conversation_id, null, "should start a fresh conversation"); // The full history should be replayed in the messages array. const userMessages = secondBody.messages.filter((m) => m.author?.role === "user"); @@ -823,8 +829,9 @@ test("Request: conversation POST has correct browser-like headers", async () => log: null, }); - assert.equal(m.calls.urls[2], "https://chatgpt.com/backend-api/f/conversation"); - const convHeaders = m.calls.headers[2]; + const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); + assert.equal(m.calls.urls[convIdx], "https://chatgpt.com/backend-api/f/conversation"); + const convHeaders = m.calls.headers[convIdx]; assert.match(convHeaders["User-Agent"], /Mozilla/); assert.equal(convHeaders["Origin"], "https://chatgpt.com"); assert.equal(convHeaders["Sec-Fetch-Site"], "same-origin"); @@ -852,7 +859,8 @@ test("Request: payload has correct ChatGPT shape", async () => { signal: AbortSignal.timeout(10_000), log: null, }); - const body = JSON.parse(m.calls.bodies[2]); + const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); + const body = JSON.parse(m.calls.bodies[convIdx]); assert.equal(body.action, "next"); assert.equal(body.model, "gpt-5-3"); assert.equal(body.history_and_training_disabled, true);