mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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 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<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;
|
||||
}
|
||||
|
||||
@@ -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("/"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user