chatgpt-web: address PR #1593 review feedback

Round of fixes addressing the gemini-code-assist and chatgpt-codex review
comments on the initial PR.

## High priority

- **PoW solver no longer blocks the event loop** (gemini #1, #2). The 100k
  prekey solver and 500k proof-of-work solver were synchronous SHA3-512
  loops that pinned a CPU core for tens to hundreds of milliseconds per
  request. Both are now async and `await`-yield to the event loop every
  1000 iterations via setImmediate, so concurrent requests and I/O still
  get scheduled. Wall time is approximately the same; what changes is
  fairness, not throughput.

- **Real upstream streaming for stream=true requests** (codex #6). The
  conv call now passes `stream: true` through to the TLS client when the
  caller asked for streaming. The TLS client uses tls-client-node's
  streamOutputPath primitive to write the response body to a temp file
  as it arrives, and we tail that file as a ReadableStream so clients
  see chunks in real time instead of getting one buffered burst at the
  end. Also peeks the first 256 bytes — if the response starts with
  `{` it's almost certainly a JSON error envelope, so we wait for the
  full body and surface as a non-streaming error response.

## Medium priority

- **Per-cookie device id** (gemini #3). Replaced the single
  process-wide DEVICE_ID with a per-cookie SHA-256-derived UUID that's
  stable across requests for one connection but unique per cookie. This
  matches how the browser's persistent oai-did cookie behaves and
  avoids cross-account fingerprint sharing. Cache is bounded to 200
  entries with FIFO eviction.

- **Removed dead conv-cache code** (gemini #4). The convCache /
  convLookup / convStore trio (~70 LOC) was unused — conversationId is
  hard-pinned to null because Temporary Chat conversation_ids 404 on
  reuse. Deleted entirely; the comment explains why we don't persist.

- **No more console.log in the conv 4xx path** (gemini #5). Replaced
  with log?.warn so it respects the application's logging
  configuration.

- **Bound the warmup cache** (codex #7). The (cookie, accessToken) ->
  timestamp map was unbounded; long-running multi-user deployments
  with rotating tokens would grow it forever. Now capped at 200
  entries with FIFO eviction (Map iteration order = insertion order).

- **Honor abort signals in TLS fetch** (codex #8). tlsFetchChatGpt now
  checks options.signal before issuing the upstream call, after the
  call returns, and the streaming body listens for abort to stop
  tailing the temp file. tls-client-node's koffi binding can't cancel
  an in-flight request mid-call, but we no longer process / re-emit a
  response that the caller has already given up on.

## Tests

All 27 chatgpt-web tests still pass; updated several to find calls by
URL via findIndex rather than hardcoded indices, since the warmup
sequence (/me, /conversations, /models) and two-stage Sentinel
(prepare + chat-requirements) shifted positional offsets.

Manually verified end-to-end:
- Non-streaming completions
- Streaming completions (real-time chunks; SSE [DONE] terminator)
- Multi-turn with full history each turn (memory preserved correctly)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Payne
2026-04-25 13:32:43 +00:00
parent 299793e788
commit fc80a986f2
3 changed files with 212 additions and 141 deletions

View File

@@ -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<string, string>();
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<string, string> {
}
/** Headers ChatGPT's web client sends on backend-api requests. */
function oaiHeaders(sessionId: string): Record<string, string> {
function oaiHeaders(sessionId: string, deviceId: string): Record<string, string> {
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<string, ConvEntry>();
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<string, number>();
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<string, string> = {
...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<ChatRequirements> {
const config = buildPrekeyConfig(CHATGPT_USER_AGENT, dplInfo.dpl, dplInfo.scriptSrc);
const prekey = buildPrepareToken(config);
const prekey = await buildPrepareToken(config);
const headers: Record<string, string> = {
...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 100k500k 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<void> {
return new Promise((resolve) => setImmediate(resolve));
}
async function buildPrepareToken(config: unknown[]): Promise<string> {
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<string> {
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<string, string> = {
...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<Uint8Array>;
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<Uint8Array> {
// Test-only: clear caches between tests
export function __resetChatGptWebCachesForTesting(): void {
tokenCache.clear();
convCache.clear();
warmupCache.clear();
deviceIdCache.clear();
dplCache = null;
}

View File

@@ -61,14 +61,14 @@ async function getClient(): Promise<{
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
};
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<TlsFetchResult> {
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<string, unknown> = {
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<string, string[]>): 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<string, unknown>) => Promise<TlsResponseLike> },
url: string,
requestOptions: Record<string, unknown>,
eofSymbol = "[DONE]"
eofSymbol = "[DONE]",
signal: AbortSignal | null = null
): Promise<TlsFetchResult> {
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<never>((_, 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<string> {
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<boolean> {
@@ -257,7 +303,8 @@ async function waitForFile(path: string, timeoutMs: number): Promise<boolean> {
function tailFile(
path: string,
eofSymbol: string,
done: Promise<TlsResponseLike>
done: Promise<TlsResponseLike>,
signal: AbortSignal | null = null
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
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("/"));

View File

@@ -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);