diff --git a/next.config.mjs b/next.config.mjs index db1017ae84..e58ea64bd9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -38,6 +38,9 @@ const nextConfig = { "keytar", "wreq-js", "zod", + "tls-client-node", + "koffi", + "tough-cookie", "child_process", "fs", "path", diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index b48b3dc9d9..7b65cbbc91 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1025,6 +1025,17 @@ export const REGISTRY: Record = { ], }, + "chatgpt-web": { + id: "chatgpt-web", + alias: "cgpt-web", + format: "openai", + executor: "chatgpt-web", + baseUrl: "https://chatgpt.com/backend-api/conversation", + authType: "apikey", + authHeader: "cookie", + models: [{ id: "gpt-5.3-instant", name: "GPT-5.3 Instant (via ChatGPT Web)" }], + }, + "grok-web": { id: "grok-web", alias: "grok-web", diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts new file mode 100644 index 0000000000..b3e1463660 --- /dev/null +++ b/open-sse/executors/chatgpt-web.ts @@ -0,0 +1,1437 @@ +/** + * ChatGptWebExecutor — ChatGPT Web Session Provider + * + * Routes requests through chatgpt.com's internal SSE API using a Plus/Pro + * subscription session cookie, translating between OpenAI chat completions + * format and ChatGPT's internal protocol. + * + * Auth pipeline (per request): + * 1. exchangeSession() GET /api/auth/session cookie → JWT accessToken (cached ~5min) + * 2. prepareChatRequirements() POST /backend-api/sentinel/chat-requirements + * → { proofofwork.seed, difficulty, persona } + * 3. solveProofOfWork() SHA3-512 hash loop → "gAAAAAB…" sentinel proof token + * 4. fetch /backend-api/conversation with Bearer + sentinel-proof-token + browser UA + * + * Response is the standard ChatGPT SSE format (cumulative `parts[0]` strings, not deltas). + */ + +import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; +import { createHash, randomUUID, randomBytes } from "node:crypto"; +import { + tlsFetchChatGpt, + TlsClientUnavailableError, + type TlsFetchOptions, + type TlsFetchResult, +} from "../services/chatgptTlsClient.ts"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +const CHATGPT_BASE = "https://chatgpt.com"; +const SESSION_URL = `${CHATGPT_BASE}/api/auth/session`; +const SENTINEL_PREPARE_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements/prepare`; +const SENTINEL_CR_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements`; +const CONV_URL = `${CHATGPT_BASE}/backend-api/f/conversation`; + +const CHATGPT_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0"; + +// Captured from a real chatgpt.com browser session (April 2026). +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(); + +// OmniRoute model ID → ChatGPT internal slug. ChatGPT's web routes use +// dash-separated IDs (e.g. "gpt-5-3" not "gpt-5.3-instant"). +const MODEL_MAP: Record = { + "gpt-5.3-instant": "gpt-5-3", + "gpt-5-3": "gpt-5-3", +}; + +// ─── Browser-like default headers ────────────────────────────────────────── + +function browserHeaders(): Record { + return { + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + Origin: CHATGPT_BASE, + Pragma: "no-cache", + Referer: `${CHATGPT_BASE}/`, + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": CHATGPT_USER_AGENT, + }; +} + +/** Headers ChatGPT's web client sends on backend-api requests. */ +function oaiHeaders(sessionId: string): Record { + return { + "OAI-Language": "en-US", + "OAI-Device-Id": DEVICE_ID, + "OAI-Client-Version": OAI_CLIENT_VERSION, + "OAI-Client-Build-Number": OAI_CLIENT_BUILD_NUMBER, + "OAI-Session-Id": sessionId, + }; +} + +// ─── Session token cache ──────────────────────────────────────────────────── + +interface TokenEntry { + accessToken: string; + accountId: string | null; + expiresAt: number; + refreshedCookie?: string; +} + +const TOKEN_TTL_MS = 5 * 60 * 1000; // 5min — accessTokens are short-lived +const tokenCache = new Map(); + +function cookieKey(cookie: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < cookie.length; i++) { + hash ^= cookie.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +function tokenLookup(cookie: string): TokenEntry | null { + const entry = tokenCache.get(cookieKey(cookie)); + if (!entry) return null; + if (Date.now() >= entry.expiresAt) { + tokenCache.delete(cookieKey(cookie)); + return null; + } + return entry; +} + +function tokenStore(cookie: string, entry: TokenEntry): void { + tokenCache.set(cookieKey(cookie), entry); + // Trim to 200 entries (matches Perplexity executor's session cache) + if (tokenCache.size > 200) { + const firstKey = tokenCache.keys().next().value; + if (firstKey) tokenCache.delete(firstKey); + } +} + +// ─── 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); + } +} + +// ─── /api/auth/session — exchange cookie for JWT ──────────────────────────── + +interface SessionResponse { + accessToken?: string; + expires?: string; + user?: { id?: string }; +} + +function extractRefreshedCookie(setCookieHeader: string | null): string | null { + if (!setCookieHeader) return null; + // Set-Cookie can rotate either an unchunked token or any of the chunks. + // Capture all relevant chunks and re-emit them as a single Cookie header value. + const matches = Array.from( + setCookieHeader.matchAll(/(__Secure-next-auth\.session-token(?:\.\d+)?)=([^;,\s]+)/g) + ); + if (matches.length === 0) return null; + return matches.map((m) => `${m[1]}=${m[2]}`).join("; "); +} + +/** + * Build the Cookie header value from whatever the user pasted. + * + * Accepts: + * - A bare value: "eyJhbGc..." → prepended with __Secure-next-auth.session-token= + * - An unchunked cookie line: "__Secure-next-auth.session-token=eyJ..." + * - A chunked cookie line: "__Secure-next-auth.session-token.0=...; __Secure-next-auth.session-token.1=..." + * - The full DevTools cookie header: "Cookie: __Secure-next-auth.session-token.0=...; cf_clearance=..." + * + * If the user pastes a chunked token, we pass the cookies through verbatim — + * NextAuth's server reassembles them on its side. + */ +function buildSessionCookieHeader(rawInput: string): string { + let s = rawInput.trim(); + if (/^cookie\s*:\s*/i.test(s)) s = s.replace(/^cookie\s*:\s*/i, ""); + if (/__Secure-next-auth\.session-token(?:\.\d+)?\s*=/.test(s)) { + return s; + } + return `__Secure-next-auth.session-token=${s}`; +} + +async function exchangeSession( + cookie: string, + signal: AbortSignal | null | undefined +): Promise { + const cached = tokenLookup(cookie); + if (cached) return cached; + + const headers: Record = { + ...browserHeaders(), + Accept: "application/json", + Cookie: buildSessionCookieHeader(cookie), + }; + + const response = await tlsFetchChatGpt(SESSION_URL, { + method: "GET", + headers, + timeoutMs: 30_000, + signal, + }); + + if (response.status === 401 || response.status === 403) { + throw new SessionAuthError("Invalid session cookie"); + } + if (response.status >= 400) { + throw new Error(`Session exchange failed (HTTP ${response.status})`); + } + + const refreshed = extractRefreshedCookie(response.headers.get("set-cookie")); + let data: SessionResponse = {}; + try { + data = JSON.parse(response.text || "{}"); + } catch { + /* empty body or non-JSON */ + } + if (!data.accessToken) { + throw new SessionAuthError("Session response missing accessToken — cookie likely expired"); + } + + const expiresAt = data.expires ? new Date(data.expires).getTime() : Date.now() + TOKEN_TTL_MS; + const entry: TokenEntry = { + accessToken: data.accessToken, + accountId: data.user?.id ?? null, + expiresAt: Math.min(expiresAt, Date.now() + TOKEN_TTL_MS), + refreshedCookie: refreshed ?? undefined, + }; + tokenStore(cookie, entry); + return entry; +} + +class SessionAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "SessionAuthError"; + } +} + +// ─── /backend-api/sentinel/chat-requirements ──────────────────────────────── + +interface ChatRequirements { + /** Returned by /chat-requirements (the "real" chat requirements token). */ + token?: string; + /** Returned by /chat-requirements/prepare (sent as a prerequisite header). */ + prepare_token?: string; + persona?: string; + proofofwork?: { + required?: boolean; + seed?: string; + difficulty?: string; + }; + turnstile?: { + required?: boolean; + dx?: string; + }; +} + +// ─── Session warmup ──────────────────────────────────────────────────────── +// Mimics chatgpt.com's page-load fetch sequence so Sentinel sees a "warm" +// browsing session. Cached per (cookie, access-token) pair for 60s to avoid +// hammering the warmup endpoints on every chat completion. + +const warmupCache = new Map(); +const WARMUP_TTL_MS = 60_000; + +async function runSessionWarmup( + accessToken: string, + accountId: string | null, + sessionId: string, + cookie: string, + signal: AbortSignal | null | undefined, + log: { debug?: (tag: string, msg: string) => void } | null | undefined +): Promise { + const key = cookieKey(cookie) + ":" + accessToken.slice(-8); + const now = Date.now(); + const last = warmupCache.get(key); + if (last && now - last < WARMUP_TTL_MS) return; + warmupCache.set(key, now); + + const headers: Record = { + ...browserHeaders(), + ...oaiHeaders(sessionId), + Accept: "*/*", + Authorization: `Bearer ${accessToken}`, + Cookie: buildSessionCookieHeader(cookie), + Priority: "u=1, i", + }; + if (accountId) headers["chatgpt-account-id"] = accountId; + + const urls = [ + `${CHATGPT_BASE}/backend-api/me`, + `${CHATGPT_BASE}/backend-api/conversations?offset=0&limit=28&order=updated`, + `${CHATGPT_BASE}/backend-api/models?history_and_training_disabled=false`, + ]; + + for (const url of urls) { + try { + const r = await tlsFetchChatGpt(url, { + method: "GET", + headers, + timeoutMs: 15_000, + signal, + }); + log?.debug?.("CGPT-WEB", `warmup ${url.split("/backend-api/")[1]} → ${r.status}`); + } catch (err) { + log?.debug?.( + "CGPT-WEB", + `warmup ${url} failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + } +} + +async function prepareChatRequirements( + accessToken: string, + accountId: string | null, + sessionId: 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 headers: Record = { + ...browserHeaders(), + ...oaiHeaders(sessionId), + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + Cookie: buildSessionCookieHeader(cookie), + Priority: "u=1, i", + }; + if (accountId) headers["chatgpt-account-id"] = accountId; + + // Stage 1: POST /chat-requirements/prepare → { prepare_token, ... } + const prepResp = await tlsFetchChatGpt(SENTINEL_PREPARE_URL, { + method: "POST", + headers, + body: JSON.stringify({ p: prekey }), + timeoutMs: 30_000, + signal, + }); + if (prepResp.status === 401 || prepResp.status === 403) { + throw new SentinelBlockedError(`Sentinel /prepare blocked (HTTP ${prepResp.status})`); + } + if (prepResp.status >= 400) { + throw new Error(`Sentinel /prepare failed (HTTP ${prepResp.status})`); + } + let prepData: ChatRequirements = {}; + try { + prepData = JSON.parse(prepResp.text || "{}") as ChatRequirements; + } catch { + /* keep empty */ + } + // Stage 2: POST /chat-requirements with the prepare_token in the body. This + // is the call that actually returns the chat-requirements-token used on the + // conversation request. + if (!prepData.prepare_token) { + return prepData; // pass through whatever we got — caller handles missing fields + } + + const crBody: Record = { p: prekey, prepare_token: prepData.prepare_token }; + const crResp = await tlsFetchChatGpt(SENTINEL_CR_URL, { + method: "POST", + headers, + body: JSON.stringify(crBody), + timeoutMs: 30_000, + signal, + }); + if (crResp.status === 401 || crResp.status === 403) { + throw new SentinelBlockedError(`Sentinel /chat-requirements blocked (HTTP ${crResp.status})`); + } + if (crResp.status >= 400) { + // Fall back to whatever /prepare returned — some accounts may not need stage 2. + return prepData; + } + try { + const crData = JSON.parse(crResp.text || "{}") as ChatRequirements; + // Merge: prepare_token from stage 1, everything else from stage 2. + return { ...crData, prepare_token: prepData.prepare_token }; + } catch { + return prepData; + } +} + +class SentinelBlockedError extends Error { + constructor(message: string) { + super(message); + this.name = "SentinelBlockedError"; + } +} + +// ─── Proof-of-work solver ────────────────────────────────────────────────── +// Mimics the openai-sentinel / chat2api algorithm. The browser sends a base64-encoded +// JSON config string; the server combines it with a seed and expects a SHA3-512 hash +// whose hex-prefix is ≤ the difficulty target. +// +// Reference: github.com/leetanshaj/openai-sentinel, github.com/lanqian528/chat2api +// Returns "gAAAAAB" + base64 of the winning config (server-recognised prefix). + +// ─── DPL / script-src cache (warmup) ──────────────────────────────────────── +// Sentinel's prekey check inspects whether config[5]/config[6] reference a real +// chatgpt.com deployment (DPL hash + a script URL from the HTML). We GET / once +// per hour to scrape these — same trick chat2api uses. + +interface DplInfo { + dpl: string; + scriptSrc: string; + expiresAt: number; +} +let dplCache: DplInfo | null = null; +const DPL_TTL_MS = 60 * 60 * 1000; + +async function fetchDpl( + cookie: string, + signal: AbortSignal | null | undefined +): Promise<{ dpl: string; scriptSrc: string }> { + if (dplCache && Date.now() < dplCache.expiresAt) { + return { dpl: dplCache.dpl, scriptSrc: dplCache.scriptSrc }; + } + const headers: Record = { + ...browserHeaders(), + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + Cookie: buildSessionCookieHeader(cookie), + }; + const response = await tlsFetchChatGpt(`${CHATGPT_BASE}/`, { + method: "GET", + headers, + timeoutMs: 20_000, + signal, + }); + const html = response.text || ""; + const dplMatch = html.match(/data-build="([^"]+)"/); + const dpl = dplMatch ? `dpl=${dplMatch[1]}` : `dpl=${OAI_CLIENT_VERSION.replace(/^prod-/, "")}`; + const scriptMatch = html.match(/]+src="(https?:\/\/[^"]*\.js[^"]*)"/); + const scriptSrc = + scriptMatch?.[1] ?? `${CHATGPT_BASE}/_next/static/chunks/webpack-${randomHex(16)}.js`; + dplCache = { dpl, scriptSrc, expiresAt: Date.now() + DPL_TTL_MS }; + return { dpl, scriptSrc }; +} + +function randomHex(n: number): string { + return randomBytes(Math.ceil(n / 2)) + .toString("hex") + .slice(0, n); +} + +// ─── Browser fingerprint key lists (used in prekey config[10..12]) ───────── +// Chosen to look like real navigator/document/window inspection. The unicode +// MINUS SIGN (U+2212) in the navigator strings matches what `Object.toString()` +// produces in real browsers — Sentinel checks for it. + +const NAVIGATOR_KEYS = [ + "webdriver−false", + "geolocation", + "languages", + "language", + "platform", + "userAgent", + "vendor", + "hardwareConcurrency", + "deviceMemory", + "permissions", + "plugins", + "mediaDevices", +]; + +const DOCUMENT_KEYS = [ + "_reactListeningkfj3eavmks", + "_reactListeningo743lnnpvdg", + "location", + "scrollingElement", + "documentElement", +]; + +const WINDOW_KEYS = [ + "webpackChunk_N_E", + "__NEXT_DATA__", + "chrome", + "history", + "screen", + "navigation", + "scrollX", + "scrollY", +]; + +function pick(arr: readonly T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function buildPrekeyConfig(userAgent: string, dpl: string, scriptSrc: string): unknown[] { + const screenSizes = [3000, 4000, 3120, 4160] as const; + const cores = [8, 16, 24, 32] as const; + const dateStr = new Date().toString(); + const perfNow = performance.now(); + const epochOffset = Date.now() - perfNow; + + return [ + pick(screenSizes), + dateStr, + 4294705152, + 0, // mutated by solver + userAgent, + scriptSrc, + dpl, + "en-US", + "en-US,en", + 0, // mutated by solver + pick(NAVIGATOR_KEYS), + pick(DOCUMENT_KEYS), + pick(WINDOW_KEYS), + perfNow, + randomUUID(), + "", + pick(cores), + epochOffset, + ]; +} + +/** + * Build the `p` (prekey) value sent in the chat-requirements POST body. + * + * Format: "gAAAAAC" + base64(JSON(config)), with a brief PoW solver loop + * (difficulty "0fffff") mutating config[3] to find a hash whose hex prefix + * is ≤ the difficulty. Mirrors chat2api / openai-sentinel. + */ +function buildPrepareToken(config: unknown[]): string { + const target = "0fffff"; + const cfg = [...config]; + for (let i = 0; i < 100_000; i++) { + cfg[3] = i; + const json = JSON.stringify(cfg); + const b64 = Buffer.from(json).toString("base64"); + const hash = createHash("sha3-512").update(b64).digest("hex"); + if (hash.slice(0, target.length) <= target) { + return `gAAAAAC${b64}`; + } + } + // Fallback — submit unsolved; some clients do this and it still works. + const b64 = Buffer.from(JSON.stringify(cfg)).toString("base64"); + return `gAAAAAC${b64}`; +} + +function solveProofOfWork(seed: string, difficulty: string, config: unknown[]): string { + const target = (difficulty || "").toLowerCase(); + const cfg = [...config]; + const maxIter = 500_000; + + for (let i = 0; i < maxIter; i++) { + cfg[3] = i; + const json = JSON.stringify(cfg); + const b64 = Buffer.from(json).toString("base64"); + const hash = createHash("sha3-512") + .update(seed + b64) + .digest("hex"); + if (target && hash.slice(0, target.length) <= target) { + return `gAAAAAB${b64}`; + } + } + + // Fallback: submit unsolved with the gAAAAAB prefix; some clients do this + // and the request still goes through on legacy/low-friction prompts. + const b64 = Buffer.from(JSON.stringify(cfg)).toString("base64"); + return `gAAAAAB${b64}`; +} + +// ─── OpenAI → ChatGPT message translation ─────────────────────────────────── + +interface ParsedMessages { + systemMsg: string; + history: Array<{ role: string; content: string }>; + currentMsg: string; +} + +function parseOpenAIMessages(messages: Array>): ParsedMessages { + let systemMsg = ""; + const history: Array<{ role: string; content: string }> = []; + + for (const msg of messages) { + let role = String(msg.role || "user"); + if (role === "developer") role = "system"; + + let content = ""; + if (typeof msg.content === "string") { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = (msg.content as Array>) + .filter((c) => c.type === "text") + .map((c) => String(c.text || "")) + .join(" "); + } + if (!content.trim()) continue; + + if (role === "system") { + systemMsg += (systemMsg ? "\n" : "") + content; + } else if (role === "user" || role === "assistant") { + history.push({ role, content }); + } + } + + let currentMsg = ""; + if (history.length > 0 && history[history.length - 1].role === "user") { + currentMsg = history.pop()!.content; + } + + return { systemMsg, history, currentMsg }; +} + +interface ChatGptMessage { + id: string; + author: { role: string }; + content: { content_type: "text"; parts: string[] }; +} + +function buildConversationBody( + parsed: ParsedMessages, + modelSlug: string, + conversationId: string | null, + parentMessageId: string +): Record { + const messages: ChatGptMessage[] = []; + + if (parsed.systemMsg.trim() && !conversationId) { + messages.push({ + id: randomUUID(), + author: { role: "system" }, + content: { content_type: "text", parts: [parsed.systemMsg.trim()] }, + }); + } + + // Replay history only on a brand-new conversation; ChatGPT remembers prior turns + // server-side once a conversation_id exists. + if (!conversationId) { + for (const h of parsed.history) { + messages.push({ + id: randomUUID(), + author: { role: h.role }, + content: { content_type: "text", parts: [h.content] }, + }); + } + } + + messages.push({ + id: randomUUID(), + author: { role: "user" }, + content: { content_type: "text", parts: [parsed.currentMsg || ""] }, + }); + + return { + action: "next", + messages, + model: modelSlug, + conversation_id: conversationId, + parent_message_id: parentMessageId, + timezone_offset_min: -new Date().getTimezoneOffset(), + history_and_training_disabled: true, + suggestions: [], + websocket_request_id: randomUUID(), + }; +} + +// ─── ChatGPT SSE parsing ──────────────────────────────────────────────────── + +interface ChatGptStreamEvent { + message?: { + id?: string; + author?: { role?: string }; + content?: { content_type?: string; parts?: unknown[] }; + status?: string; + metadata?: Record; + }; + conversation_id?: string; + error?: string | { message?: string; code?: string }; + type?: string; + v?: unknown; +} + +async function* readChatGptSseEvents( + body: ReadableStream, + signal?: AbortSignal | null +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let dataLines: string[] = []; + + function flush(): ChatGptStreamEvent | null | "done" { + if (dataLines.length === 0) return null; + const payload = dataLines.join("\n"); + dataLines = []; + const trimmed = payload.trim(); + if (!trimmed || trimmed === "[DONE]") return "done"; + try { + return JSON.parse(trimmed) as ChatGptStreamEvent; + } catch { + return null; + } + } + + try { + while (true) { + if (signal?.aborted) return; + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + while (true) { + const idx = buffer.indexOf("\n"); + if (idx < 0) break; + const rawLine = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + + if (line === "") { + const parsed = flush(); + if (parsed === "done") return; + if (parsed) yield parsed; + continue; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + } + + buffer += decoder.decode(); + if (buffer.trim().startsWith("data:")) { + dataLines.push(buffer.trim().slice(5).trimStart()); + } + const tail = flush(); + if (tail && tail !== "done") yield tail; + } finally { + reader.releaseLock(); + } +} + +// ─── Content extraction ───────────────────────────────────────────────────── +// ChatGPT SSE chunks contain CUMULATIVE content (full text so far in `parts[0]`), +// not deltas. Diff against `seenLen` to emit incremental tokens — same pattern +// perplexity-web.ts uses for markdown blocks (lines 386-397). + +interface ContentChunk { + delta?: string; + answer?: string; + conversationId?: string; + messageId?: string; + error?: string; + done?: boolean; +} + +async function* extractContent( + eventStream: ReadableStream, + signal?: AbortSignal | null +): AsyncGenerator { + let fullAnswer = ""; + let seenLen = 0; + let conversationId: string | null = null; + let messageId: string | null = null; + // ChatGPT may echo prior messages in the stream for context before sending + // the new assistant turn. Track which message id we're currently consuming + // so we reset the accumulator when a new turn starts, and so "finished_*" + // on an old echoed message doesn't end the stream prematurely. + + for await (const event of readChatGptSseEvents(eventStream, signal)) { + if (event.error) { + const msg = + typeof event.error === "string" + ? event.error + : event.error.message || "ChatGPT stream error"; + yield { error: msg, done: true }; + return; + } + + if (event.conversation_id) conversationId = event.conversation_id; + + const m = event.message; + if (!m) continue; + + const role = m.author?.role ?? ""; + if (role !== "assistant") continue; + + const id = m.id ?? null; + if (id && id !== messageId) { + // A new assistant message has started — reset accumulator. The previous + // message was either an echo of prior context or an aside; we only emit + // content for the latest message. + messageId = id; + fullAnswer = ""; + seenLen = 0; + } + + const parts = m.content?.parts ?? []; + if (parts.length === 0) continue; + const cumulative = parts.map((p) => (typeof p === "string" ? p : "")).join(""); + + if (cumulative.length > seenLen) { + const delta = cumulative.slice(seenLen); + fullAnswer = cumulative; + seenLen = cumulative.length; + yield { + delta, + answer: fullAnswer, + conversationId: conversationId ?? undefined, + messageId: messageId ?? undefined, + }; + } + // Don't break on finished_successfully here — the server may still send + // the new turn after echoing prior messages. Let the stream end naturally + // (when the upstream closes or sends [DONE]). + } + + yield { + delta: "", + answer: fullAnswer, + conversationId: conversationId ?? undefined, + messageId: messageId ?? undefined, + done: true, + }; +} + +// ─── OpenAI SSE format ────────────────────────────────────────────────────── + +function sseChunk(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +function buildStreamingResponse( + eventStream: ReadableStream, + model: string, + cid: string, + created: number, + history: Array<{ role: string; content: string }>, + currentMsg: string, + signal?: AbortSignal | null +): ReadableStream { + const encoder = new TextEncoder(); + + return new ReadableStream({ + async start(controller) { + try { + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }, + ], + }) + ) + ); + + let fullAnswer = ""; + let respConversationId: string | null = null; + let respMessageId: string | null = null; + + for await (const chunk of extractContent(eventStream, signal)) { + if (chunk.conversationId) respConversationId = chunk.conversationId; + if (chunk.messageId) respMessageId = chunk.messageId; + + if (chunk.error) { + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { content: `[Error: ${chunk.error}]` }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); + break; + } + + if (chunk.done) { + fullAnswer = chunk.answer || fullAnswer; + break; + } + + if (chunk.delta) { + const cleaned = cleanChatGptText(chunk.delta); + if (cleaned) { + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { content: cleaned }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); + } + } + if (chunk.answer) fullAnswer = chunk.answer; + } + + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], + }) + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + + if (respConversationId && respMessageId) { + convStore(history, currentMsg, fullAnswer, respConversationId, respMessageId); + } + } catch (err) { + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { + content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`, + }, + finish_reason: "stop", + logprobs: null, + }, + ], + }) + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + } finally { + controller.close(); + } + }, + }); +} + +async function buildNonStreamingResponse( + eventStream: ReadableStream, + model: string, + cid: string, + created: number, + history: Array<{ role: string; content: string }>, + currentMsg: string, + signal?: AbortSignal | null +): Promise { + let fullAnswer = ""; + let respConversationId: string | null = null; + let respMessageId: string | null = null; + + for await (const chunk of extractContent(eventStream, signal)) { + if (chunk.conversationId) respConversationId = chunk.conversationId; + if (chunk.messageId) respMessageId = chunk.messageId; + if (chunk.error) { + return new Response( + JSON.stringify({ + error: { message: chunk.error, type: "upstream_error", code: "CHATGPT_ERROR" }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ); + } + if (chunk.done) { + fullAnswer = chunk.answer || fullAnswer; + break; + } + if (chunk.answer) fullAnswer = chunk.answer; + } + + if (respConversationId && respMessageId) { + convStore(history, currentMsg, fullAnswer, respConversationId, respMessageId); + } + + fullAnswer = cleanChatGptText(fullAnswer); + const promptTokens = Math.ceil(currentMsg.length / 4); + const completionTokens = Math.ceil(fullAnswer.length / 4); + + return new Response( + JSON.stringify({ + id: cid, + object: "chat.completion", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + message: { role: "assistant", content: fullAnswer }, + finish_reason: "stop", + logprobs: null, + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} + +// ─── Error response helpers ───────────────────────────────────────────────── + +function errorResponse(status: number, message: string, code?: string): Response { + return new Response( + JSON.stringify({ error: { message, type: "upstream_error", ...(code ? { code } : {}) } }), + { status, headers: { "Content-Type": "application/json" } } + ); +} + +// ─── Executor ─────────────────────────────────────────────────────────────── + +export class ChatGptWebExecutor extends BaseExecutor { + constructor() { + super("chatgpt-web", { id: "chatgpt-web", baseUrl: CONV_URL }); + } + + async execute({ + model, + body, + stream, + credentials, + signal, + log, + onCredentialsRefreshed, + }: ExecuteInput) { + const messages = (body as Record | null)?.messages as + | Array> + | undefined; + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return { + response: errorResponse(400, "Missing or empty messages array"), + url: CONV_URL, + headers: {}, + transformedBody: body, + }; + } + + if (!credentials.apiKey) { + return { + response: errorResponse( + 401, + "ChatGPT auth failed — paste your __Secure-next-auth.session-token cookie value." + ), + url: CONV_URL, + headers: {}, + transformedBody: body, + }; + } + + // Pass the user's pasted cookie blob through to exchangeSession; the helper + // accepts bare values, unchunked cookies, chunked (.0/.1) cookies, and full + // "Cookie: ..." DevTools lines. + const cookie = credentials.apiKey; + + // 1. Token exchange + let tokenEntry: TokenEntry; + try { + tokenEntry = await exchangeSession(cookie, signal); + } catch (err) { + if (err instanceof SessionAuthError) { + log?.warn?.("CGPT-WEB", err.message); + return { + response: errorResponse( + 401, + "ChatGPT auth failed — re-paste your __Secure-next-auth.session-token cookie from chatgpt.com.", + "HTTP_401" + ), + url: SESSION_URL, + headers: {}, + transformedBody: body, + }; + } + log?.error?.( + "CGPT-WEB", + `Session exchange failed: ${err instanceof Error ? err.message : String(err)}` + ); + return { + response: errorResponse( + 502, + `ChatGPT session exchange failed: ${err instanceof Error ? err.message : String(err)}` + ), + url: SESSION_URL, + headers: {}, + transformedBody: body, + }; + } + + // Surface any rotated cookie back to the caller so the DB credential is refreshed. + if (tokenEntry.refreshedCookie && tokenEntry.refreshedCookie !== cookie) { + const updated: ProviderCredentials = { ...credentials, apiKey: tokenEntry.refreshedCookie }; + try { + await onCredentialsRefreshed?.(updated); + } catch (err) { + log?.warn?.( + "CGPT-WEB", + `Failed to persist refreshed cookie: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + // 2a. Warmup — GET / to scrape DPL + script src so the prekey looks legit. + let dplInfo: { dpl: string; scriptSrc: string }; + try { + dplInfo = await fetchDpl(cookie, signal); + } catch (err) { + log?.warn?.( + "CGPT-WEB", + `DPL warmup failed (continuing with fallback): ${err instanceof Error ? err.message : String(err)}` + ); + dplInfo = { + dpl: `dpl=${OAI_CLIENT_VERSION.replace(/^prod-/, "")}`, + scriptSrc: `${CHATGPT_BASE}/_next/static/chunks/webpack-${randomHex(16)}.js`, + }; + } + + // 2a'. Browser-like session warmup. Sentinel scores the session by whether + // the client recently hit /me, /conversations, /models — same as a real + // browser does on page load. Failures here are non-fatal; the worst case + // is Sentinel still escalates to Turnstile. + const sessionId = randomUUID(); + await runSessionWarmup( + tokenEntry.accessToken, + tokenEntry.accountId, + sessionId, + cookie, + signal, + log + ); + + // 2b. Sentinel chat-requirements + let reqs: ChatRequirements; + try { + reqs = await prepareChatRequirements( + tokenEntry.accessToken, + tokenEntry.accountId, + sessionId, + cookie, + dplInfo, + signal + ); + } catch (err) { + if (err instanceof SentinelBlockedError) { + log?.warn?.("CGPT-WEB", err.message); + return { + response: errorResponse( + 403, + "ChatGPT blocked the request (Sentinel/Turnstile required). Try again later or open chatgpt.com in a browser to refresh state.", + "SENTINEL_BLOCKED" + ), + url: SENTINEL_PREPARE_URL, + headers: {}, + transformedBody: body, + }; + } + log?.error?.( + "CGPT-WEB", + `Sentinel failed: ${err instanceof Error ? err.message : String(err)}` + ); + return { + response: errorResponse( + 502, + `ChatGPT sentinel failed: ${err instanceof Error ? err.message : String(err)}` + ), + url: SENTINEL_PREPARE_URL, + headers: {}, + transformedBody: body, + }; + } + + log?.debug?.( + "CGPT-WEB", + `sentinel: token=${reqs.token ? "y" : "n"} pow=${reqs.proofofwork?.required ? "y" : "n"} turnstile=${reqs.turnstile?.required ? "y" : "n"}` + ); + + // Optional: if a turnstile token was supplied via providerSpecificData, + // pass it through. Otherwise, send the request anyway — sometimes Sentinel + // reports turnstile.required even when the conversation endpoint accepts + // requests without it. + const turnstileToken = + typeof credentials.providerSpecificData?.turnstileToken === "string" + ? credentials.providerSpecificData.turnstileToken + : null; + + // 3. Solve PoW (if required) — reuses the same browser-fingerprint config + // shape as the prekey, just with the server-provided seed + difficulty. + 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); + } + + // 4. Build conversation request + const parsed = parseOpenAIMessages(messages); + if (!parsed.currentMsg.trim() && parsed.history.length === 0) { + return { + response: errorResponse(400, "Empty user message"), + url: CONV_URL, + headers: {}, + transformedBody: body, + }; + } + + // 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.) + const conversationId: string | null = null; + const parentMessageId = randomUUID(); + + const modelSlug = MODEL_MAP[model] ?? model; + const cgptBody = buildConversationBody(parsed, modelSlug, conversationId, parentMessageId); + + const headers: Record = { + ...browserHeaders(), + ...oaiHeaders(sessionId), + "Content-Type": "application/json", + Accept: "text/event-stream", + Authorization: `Bearer ${tokenEntry.accessToken}`, + Cookie: buildSessionCookieHeader(cookie), + }; + if (tokenEntry.accountId) headers["chatgpt-account-id"] = tokenEntry.accountId; + if (reqs.token) headers["openai-sentinel-chat-requirements-token"] = reqs.token; + if (reqs.prepare_token) + headers["openai-sentinel-chat-requirements-prepare-token"] = reqs.prepare_token; + if (proofToken) headers["openai-sentinel-proof-token"] = proofToken; + if (turnstileToken) headers["openai-sentinel-turnstile-token"] = turnstileToken; + + log?.info?.( + "CGPT-WEB", + `Conversation request → ${modelSlug} (continue=${!!conversationId}, pow=${!!proofToken})` + ); + + let response: TlsFetchResult; + try { + response = await tlsFetchChatGpt(CONV_URL, { + method: "POST", + headers, + body: JSON.stringify(cgptBody), + timeoutMs: 120_000, // generations can take a while + signal, + }); + } catch (err) { + log?.error?.("CGPT-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); + const code = err instanceof TlsClientUnavailableError ? "TLS_UNAVAILABLE" : undefined; + return { + response: errorResponse( + 502, + `ChatGPT connection failed: ${err instanceof Error ? err.message : String(err)}`, + code + ), + url: CONV_URL, + headers, + transformedBody: cgptBody, + }; + } + + 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)}`); + let errMsg = `ChatGPT returned HTTP ${status}`; + if (status === 401 || status === 403) { + errMsg = + "ChatGPT auth failed — session may have expired. Re-paste your __Secure-next-auth.session-token."; + 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(); + } else if (status === 429) { + errMsg = "ChatGPT rate limited. Wait a moment and retry."; + } + log?.warn?.("CGPT-WEB", errMsg); + return { + response: errorResponse(status, errMsg, `HTTP_${status}`), + url: CONV_URL, + headers, + transformedBody: cgptBody, + }; + } + + // 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) { + return { + response: errorResponse(502, "ChatGPT returned empty response body"), + url: CONV_URL, + headers, + transformedBody: cgptBody, + }; + } + + const bodyStream = stringToStream(response.text); + + const cid = `chatcmpl-cgpt-${crypto.randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + let finalResponse: Response; + if (stream) { + const sseStream = buildStreamingResponse( + bodyStream, + model, + cid, + created, + parsed.history, + parsed.currentMsg, + signal + ); + finalResponse = new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + }); + } else { + finalResponse = await buildNonStreamingResponse( + bodyStream, + model, + cid, + created, + parsed.history, + parsed.currentMsg, + signal + ); + } + + return { response: finalResponse, url: CONV_URL, headers, transformedBody: cgptBody }; + } +} + +// Strip ChatGPT's internal entity markup. The browser renders these as proper +// inline citations / chips via JS; for a plain text completion we just want +// the human-readable form. +// entity["city","Paris","capital of France"] → Paris +// entity["…","value", …] → value +const ENTITY_RE = /entity\["[^"]*","([^"]*)"[^\]]*\]/g; + +function cleanChatGptText(text: string): string { + return text.replace(ENTITY_RE, "$1"); +} + +function stringToStream(text: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); +} + +// Test-only: clear caches between tests +export function __resetChatGptWebCachesForTesting(): void { + tokenCache.clear(); + convCache.clear(); +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index d6386eb066..936eb5a90c 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -14,6 +14,7 @@ import { VertexExecutor } from "./vertex.ts"; import { CliproxyapiExecutor } from "./cliproxyapi.ts"; import { PerplexityWebExecutor } from "./perplexity-web.ts"; import { GrokWebExecutor } from "./grok-web.ts"; +import { ChatGptWebExecutor } from "./chatgpt-web.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -38,6 +39,8 @@ const executors = { "perplexity-web": new PerplexityWebExecutor(), "pplx-web": new PerplexityWebExecutor(), // Alias "grok-web": new GrokWebExecutor(), + "chatgpt-web": new ChatGptWebExecutor(), + "cgpt-web": new ChatGptWebExecutor(), // Alias }; const defaultCache = new Map(); @@ -69,3 +72,4 @@ export { CliproxyapiExecutor } from "./cliproxyapi.ts"; export { VertexExecutor } from "./vertex.ts"; export { PerplexityWebExecutor } from "./perplexity-web.ts"; export { GrokWebExecutor } from "./grok-web.ts"; +export { ChatGptWebExecutor } from "./chatgpt-web.ts"; diff --git a/open-sse/services/chatgptTlsClient.ts b/open-sse/services/chatgptTlsClient.ts new file mode 100644 index 0000000000..a71aeedb11 --- /dev/null +++ b/open-sse/services/chatgptTlsClient.ts @@ -0,0 +1,309 @@ +/** + * Browser-TLS-impersonating HTTP client for chatgpt.com. + * + * Why this exists: ChatGPT's Cloudflare config pins `cf_clearance` to the + * client's TLS fingerprint (JA3/JA4) + HTTP/2 SETTINGS frame ordering. + * Node's Undici fetch presents an obvious "not a browser" handshake and + * gets challenged with `cf-mitigated: challenge` — even with all the right + * cookies. This module wraps `tls-client-node` (native shared library + * built from bogdanfinn/tls-client) to send a Firefox handshake instead. + * + * The first call lazily starts the managed sidecar; subsequent calls reuse + * a singleton TLSClient. Process exit hooks stop the sidecar cleanly. + */ + +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtemp, open, unlink, rmdir, stat } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; + +let clientPromise: Promise | null = null; +let exitHookInstalled = false; + +const CHATGPT_PROFILE = "firefox_148"; // matches the Firefox 150 UA we send +const DEFAULT_TIMEOUT_MS = 60_000; + +function installExitHook(): void { + if (exitHookInstalled) return; + exitHookInstalled = true; + const stop = async () => { + if (!clientPromise) return; + try { + const c = (await clientPromise) as { stop?: () => Promise }; + await c.stop?.(); + } catch { + // ignore + } + }; + process.once("beforeExit", stop); + process.once("SIGINT", () => { + void stop(); + }); + process.once("SIGTERM", () => { + void stop(); + }); +} + +async function getClient(): Promise<{ + request: (url: string, opts: Record) => Promise; +}> { + if (!clientPromise) { + clientPromise = (async () => { + try { + const mod = await import("tls-client-node"); + const TLSClient = (mod as { TLSClient: new (opts?: Record) => unknown }) + .TLSClient; + // Native mode loads the shared library directly via koffi, avoiding the + // managed sidecar's localhost HTTP calls that OmniRoute's global fetch + // proxy patch interferes with. + const client = new TLSClient({ runtimeMode: "native" }) as { + start: () => 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}. ` + + `Verify tls-client-node is installed and its native binary downloaded.` + ); + } + })(); + } + return clientPromise as Promise<{ + request: (url: string, opts: Record) => Promise; + }>; +} + +interface TlsResponseLike { + status: number; + headers: Record; + body: string; // for non-streaming requests, the full response body + cookies?: Record; + text: () => Promise; + bytes: () => Promise; + json: () => Promise; +} + +export class TlsClientUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientUnavailableError"; + } +} + +export interface TlsFetchOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: Record; + body?: string; + timeoutMs?: number; + signal?: AbortSignal | null; + /** + * If true, the response body is streamed to a temp file and exposed as a + * ReadableStream. Use for SSE responses (the conversation + * endpoint). Otherwise, the full body is read into memory. + */ + stream?: boolean; + /** EOF marker the upstream sends to signal end of stream (default: "[DONE]"). */ + streamEofSymbol?: string; +} + +export interface TlsFetchResult { + status: number; + headers: Headers; + /** Full response body as text — only populated for non-streaming requests. */ + text: string | null; + /** Streaming body — only populated when options.stream === true. */ + body: ReadableStream | null; +} + +// Test-only injection point. Tests call __setTlsFetchOverrideForTesting() +// to replace the real TLS client with a mock; production never touches this. +let testOverride: ((url: string, options: TlsFetchOptions) => Promise) | null = + null; + +export function __setTlsFetchOverrideForTesting(fn: typeof testOverride): void { + testOverride = fn; +} + +/** + * Make a single HTTP request to chatgpt.com with a Firefox-like TLS fingerprint. + * + * Throws TlsClientUnavailableError if the native binary failed to load. + */ +export async function tlsFetchChatGpt( + url: string, + options: TlsFetchOptions = {} +): Promise { + if (testOverride) return testOverride(url, options); + const client = await getClient(); + + const requestOptions: Record = { + method: options.method || "GET", + headers: options.headers || {}, + body: options.body, + tlsClientIdentifier: CHATGPT_PROFILE, + timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + followRedirects: true, + withRandomTLSExtensionOrder: true, + }; + + if (options.stream) { + return await tlsFetchStreaming(client, url, requestOptions, options.streamEofSymbol); + } + + const tlsResponse = await client.request(url, requestOptions); + return { + status: tlsResponse.status, + headers: toHeaders(tlsResponse.headers), + text: tlsResponse.body, + body: null, + }; +} + +function toHeaders(raw: Record): Headers { + const h = new Headers(); + for (const [k, vs] of Object.entries(raw || {})) { + for (const v of vs) h.append(k, v); + } + return h; +} + +// ─── Streaming via temp file ──────────────────────────────────────────────── +// tls-client-node's streaming primitive writes the response body chunk-by-chunk +// to a file path, terminating when the upstream sends `streamOutputEOFSymbol`. +// We tail the file from a worker and surface the bytes as a ReadableStream. + +async function tlsFetchStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + eofSymbol = "[DONE]" +): Promise { + const dir = await mkdtemp(join(tmpdir(), "cgpt-stream-")); + const path = join(dir, `${randomUUID()}.sse`); + + const streamOpts = { + ...requestOptions, + streamOutputPath: path, + streamOutputBlockSize: 1024, + 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. + const requestPromise = client.request(url, streamOpts); + + // Wait for the file to exist, then build a tailing reader. + const ready = await waitForFile(path, 5_000); + if (!ready) { + // If the file never appeared, the request likely errored out — surface that. + 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, + }; + } + + // 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); + + // Cleanup of the temp file/dir happens inside tailFile when EOF is reached. + void dir; // keep ref for type-checker + + return { + status: meta.status, + headers: toHeaders(meta.headers), + text: null, + body: stream, + }; +} + +async function waitForFile(path: string, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await stat(path); + return true; + } catch { + await sleep(25); + } + } + return false; +} + +function tailFile( + path: string, + eofSymbol: string, + done: Promise +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const fd = await open(path, "r"); + const buf = Buffer.alloc(64 * 1024); + let offset = 0; + let finished = false; + + // Mark when the request completes so we know to drain the rest. + done.finally(() => { + finished = true; + }); + + try { + while (true) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offset); + if (bytesRead > 0) { + const chunk = buf.subarray(0, bytesRead); + offset += bytesRead; + const text = chunk.toString("utf8"); + if (text.includes(eofSymbol)) { + const cutAt = text.indexOf(eofSymbol) + eofSymbol.length; + controller.enqueue(new Uint8Array(chunk.subarray(0, cutAt))); + break; + } + controller.enqueue(new Uint8Array(chunk)); + } else if (finished) { + // No more data and request completed — drain done. + break; + } else { + await sleep(25); + } + } + } catch (err) { + controller.error(err); + } finally { + await fd.close().catch(() => {}); + await unlink(path).catch(() => {}); + const dir = path.substring(0, path.lastIndexOf("/")); + await rmdir(dir).catch(() => {}); + controller.close(); + } + }, + }); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/package-lock.json b/package-lock.json index bb28534564..d89da95488 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "react-dom": "19.2.5", "recharts": "^3.7.0", "selfsigned": "^5.5.0", + "tls-client-node": "^0.1.13", "tsx": "^4.21.0", "undici": "^8.1.0", "uuid": "^13.0.0", @@ -11187,6 +11188,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -13237,6 +13239,16 @@ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", "peer": true }, + "node_modules/koffi": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz", + "integrity": "sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/langium": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", @@ -19328,7 +19340,6 @@ "version": "7.0.27", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", - "dev": true, "license": "MIT", "dependencies": { "tldts-core": "^7.0.27" @@ -19341,9 +19352,26 @@ "version": "7.0.27", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", - "dev": true, "license": "MIT" }, + "node_modules/tls-client-node": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tls-client-node/-/tls-client-node-0.1.13.tgz", + "integrity": "sha512-0jy0epw05aPiprbaPe+/ClsT6FB0bR1eS3u2uBPLQ3RWrqoYAv4ql2pSHm3BTcygxWurYdSrmXU4ggV67sBe5g==", + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "koffi": "^2.8.9", + "tough-cookie": "^6.0.1" + }, + "engines": { + "node": ">=18.17" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fatihkabakk" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -19383,7 +19411,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" diff --git a/package.json b/package.json index 6b2cb413e8..5c7ae05b9d 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "react-dom": "19.2.5", "recharts": "^3.7.0", "selfsigned": "^5.5.0", + "tls-client-node": "^0.1.13", "tsx": "^4.21.0", "undici": "^8.1.0", "uuid": "^13.0.0", diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index d1af78a9b6..04d62d86fd 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -1171,6 +1171,114 @@ async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: an } } +async function validateChatGptWebProvider({ apiKey, providerSpecificData = {} }: any) { + try { + // Accept bare value, unchunked cookie, chunked (.0/.1) cookies, or full + // "Cookie: ..." DevTools line. Pass through verbatim once recognised. + let cookieHeader = String(apiKey || "").trim(); + if (/^cookie\s*:\s*/i.test(cookieHeader)) { + cookieHeader = cookieHeader.replace(/^cookie\s*:\s*/i, ""); + } + if (!/__Secure-next-auth\.session-token(?:\.\d+)?\s*=/.test(cookieHeader)) { + cookieHeader = `__Secure-next-auth.session-token=${cookieHeader}`; + } + + // Use the TLS-impersonating client — Cloudflare on chatgpt.com pins + // cf_clearance to JA3/JA4 + HTTP/2 SETTINGS, so plain Node fetch always + // gets cf-mitigated: challenge regardless of cookies. + const { tlsFetchChatGpt, TlsClientUnavailableError } = + await import("@omniroute/open-sse/services/chatgptTlsClient.ts"); + + let response; + try { + response = await tlsFetchChatGpt("https://chatgpt.com/api/auth/session", { + method: "GET", + headers: applyCustomUserAgent( + { + Accept: "application/json", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + Cookie: cookieHeader, + Origin: "https://chatgpt.com", + Pragma: "no-cache", + Referer: "https://chatgpt.com/", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + }, + providerSpecificData + ), + timeoutMs: 30_000, + }); + } catch (err: any) { + if (err instanceof TlsClientUnavailableError) { + return { + valid: false, + error: `${err.message} (chatgpt-web requires this — without it, Cloudflare blocks every request)`, + }; + } + throw err; + } + + const contentType = response.headers.get("content-type") || ""; + const cfRay = response.headers.get("cf-ray"); + const cfMitigated = response.headers.get("cf-mitigated"); + + if (response.status === 401 || response.status === 403) { + const bodyText = response.text || ""; + if (cfMitigated || /just a moment|cloudflare|cf-chl|attention required/i.test(bodyText)) { + return { + valid: false, + error: + "Cloudflare blocked the validator — open chatgpt.com in your browser, then copy the FULL Cookie line from DevTools (Network → request → Cookie) including cf_clearance, __cf_bm, _cfuvid, and the session-token chunks.", + }; + } + return { + valid: false, + error: + "Invalid ChatGPT session cookie — re-paste __Secure-next-auth.session-token from chatgpt.com DevTools → Cookies", + }; + } + + if (response.status >= 500) { + return { valid: false, error: `ChatGPT unavailable (${response.status})` }; + } + + if (response.status >= 400) { + return { valid: false, error: `Validation failed: ${response.status}` }; + } + + if (!contentType.includes("json")) { + return { + valid: false, + error: `ChatGPT returned non-JSON (${contentType || "no content-type"}${cfRay ? `, cf-ray=${cfRay}` : ""}) — paste the FULL Cookie line including cf_clearance, __cf_bm, _cfuvid alongside the session-token chunks.`, + }; + } + + let data: any = {}; + try { + data = JSON.parse(response.text || "{}"); + } catch { + return { + valid: false, + error: + "ChatGPT session response was not JSON — paste the FULL Cookie line including cf_clearance and __cf_bm.", + }; + } + if (!data?.accessToken) { + return { + valid: false, + error: "ChatGPT session expired — log into chatgpt.com and copy a fresh cookie", + }; + } + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + async function validatePerplexityWebProvider({ apiKey, providerSpecificData = {} }: any) { try { let sessionToken = apiKey; @@ -1292,6 +1400,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi gigachat: validateGigachatProvider, "grok-web": validateGrokWebProvider, "perplexity-web": validatePerplexityWebProvider, + "chatgpt-web": validateChatGptWebProvider, vertex: async ({ apiKey }: any) => { try { const { parseSAFromApiKey, getAccessToken } = diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 535477becb..f9238ea11e 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -73,6 +73,16 @@ export const OAUTH_PROVIDERS = { // Web / Cookie Providers export const WEB_COOKIE_PROVIDERS = { + "chatgpt-web": { + id: "chatgpt-web", + alias: "cgpt-web", + name: "ChatGPT Web (Plus/Pro)", + icon: "auto_awesome", + color: "#10A37F", + textIcon: "CG", + website: "https://chatgpt.com", + authHint: "Paste your __Secure-next-auth.session-token cookie value from chatgpt.com", + }, "grok-web": { id: "grok-web", alias: "gw", diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts new file mode 100644 index 0000000000..d06048638f --- /dev/null +++ b/tests/unit/chatgpt-web.test.ts @@ -0,0 +1,879 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = + await import("../../open-sse/executors/chatgpt-web.ts"); +const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); +const { __setTlsFetchOverrideForTesting } = + await import("../../open-sse/services/chatgptTlsClient.ts"); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function mockChatGptStreamText(events) { + const chunks = []; + for (const evt of events) { + chunks.push(`data: ${JSON.stringify(evt)}\r\n\r\n`); + } + chunks.push("data: [DONE]\r\n\r\n"); + return chunks.join(""); +} + +function makeHeaders(map = {}) { + const h = new Headers(); + for (const [k, v] of Object.entries(map)) h.set(k, String(v)); + return h; +} + +/** Dispatch the TLS-impersonating fetch by URL pathname. + * Default: session 200 with accessToken, sentinel 200 no PoW, conv 200 empty stream. */ +function installMockFetch({ session, sentinel, conv, dpl, onSession, onSentinel, onConv } = {}) { + const calls = { + session: 0, + dpl: 0, + sentinel: 0, + conv: 0, + urls: [], + headers: [], + bodies: [], + }; + + __setTlsFetchOverrideForTesting(async (url, opts = {}) => { + const u = String(url); + calls.urls.push(u); + calls.headers.push(opts.headers || {}); + calls.bodies.push(opts.body); + + // DPL warmup — GET https://chatgpt.com/ (root). Match before /api/auth/session. + if ( + (u === "https://chatgpt.com/" || u === "https://chatgpt.com") && + (opts.method || "GET") === "GET" + ) { + calls.dpl++; + const cfg = dpl ?? { + status: 200, + body: '', + }; + return { + status: cfg.status, + headers: makeHeaders({ "Content-Type": "text/html" }), + text: cfg.body, + body: null, + }; + } + + if (u.includes("/api/auth/session")) { + calls.session++; + if (onSession) onSession(opts); + const cfg = session ?? { + status: 200, + body: { + accessToken: "jwt-abc", + expires: new Date(Date.now() + 3600_000).toISOString(), + user: { id: "user-1" }, + }, + }; + const headers = makeHeaders({ "Content-Type": "application/json" }); + if (cfg.setCookie) headers.set("set-cookie", cfg.setCookie); + return { + status: cfg.status, + headers, + text: typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}), + body: null, + }; + } + + if (u.includes("/sentinel/chat-requirements")) { + calls.sentinel++; + if (onSentinel) onSentinel(opts); + const cfg = sentinel ?? { + status: 200, + body: { token: "req-token", proofofwork: { required: false } }, + }; + return { + status: cfg.status, + headers: makeHeaders({ "Content-Type": "application/json" }), + text: JSON.stringify(cfg.body || {}), + body: null, + }; + } + + // Match only the exact conversation endpoint, not /conversations (plural — warmup). + if ( + u.endsWith("/backend-api/f/conversation") || + u.endsWith("/backend-api/conversation") || + /\/backend-api\/(f\/)?conversation\?/.test(u) + ) { + calls.conv++; + if (onConv) onConv(opts); + const cfg = conv ?? { + status: 200, + events: [ + { + conversation_id: "conv-1", + message: { + id: "msg-1", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Hello, world!"] }, + status: "in_progress", + }, + }, + { + conversation_id: "conv-1", + message: { + id: "msg-1", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Hello, world!"] }, + status: "finished_successfully", + }, + }, + ], + }; + if (cfg.error) { + return { + status: cfg.status, + headers: makeHeaders({ "Content-Type": "application/json" }), + text: JSON.stringify({ detail: cfg.error }), + body: null, + }; + } + return { + status: cfg.status, + headers: makeHeaders({ "Content-Type": "text/event-stream" }), + text: mockChatGptStreamText(cfg.events || []), + body: null, + }; + } + + return { + status: 404, + headers: makeHeaders(), + text: "not mocked", + body: null, + }; + }); + + return { + calls, + restore() { + __setTlsFetchOverrideForTesting(null); + }, + }; +} + +function reset() { + __resetChatGptWebCachesForTesting(); +} + +// ─── Registration ─────────────────────────────────────────────────────────── + +test("ChatGptWebExecutor is registered in executor index", () => { + assert.ok(hasSpecializedExecutor("chatgpt-web")); + assert.ok(hasSpecializedExecutor("cgpt-web")); + const executor = getExecutor("chatgpt-web"); + assert.ok(executor instanceof ChatGptWebExecutor); +}); + +test("ChatGptWebExecutor alias resolves to same type", () => { + const a = getExecutor("chatgpt-web"); + const b = getExecutor("cgpt-web"); + assert.ok(a instanceof ChatGptWebExecutor); + assert.ok(b instanceof ChatGptWebExecutor); +}); + +test("ChatGptWebExecutor sets correct provider name", () => { + const executor = new ChatGptWebExecutor(); + assert.equal(executor.getProvider(), "chatgpt-web"); +}); + +// ─── Token exchange path ──────────────────────────────────────────────────── + +test("Token exchange: cookie sent to /api/auth/session, accessToken used as Bearer on later calls", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "my-cookie-value" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + assert.equal(m.calls.session, 1); + assert.equal(m.calls.sentinel, 1); + assert.equal(m.calls.conv, 1); + + // Find headers by call type instead of by index — call order is + // session → dpl → sentinel → conv but indices shift if any call is cached. + const sessionIdx = m.calls.urls.findIndex((u) => u.includes("/api/auth/session")); + const sentinelIdx = m.calls.urls.findIndex((u) => u.includes("/sentinel/chat-requirements")); + const convIdx = m.calls.urls.findIndex((u) => u.includes("/backend-api/f/conversation")); + + const sessionHeaders = m.calls.headers[sessionIdx]; + assert.equal(sessionHeaders.Cookie, "__Secure-next-auth.session-token=my-cookie-value"); + + const sentinelHeaders = m.calls.headers[sentinelIdx]; + assert.equal(sentinelHeaders.Authorization, "Bearer jwt-abc"); + assert.equal(sentinelHeaders["chatgpt-account-id"], "user-1"); + + const convHeaders = m.calls.headers[convIdx]; + assert.equal(convHeaders.Authorization, "Bearer jwt-abc"); + } finally { + m.restore(); + } +}); + +test("Token cache: two calls within TTL only hit /api/auth/session once", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + const opts = { + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "cookie-v1" }, + signal: AbortSignal.timeout(10_000), + log: null, + }; + await executor.execute(opts); + await executor.execute(opts); + + assert.equal(m.calls.session, 1, "session exchange should only happen once"); + assert.equal(m.calls.conv, 2); + } finally { + m.restore(); + } +}); + +test("Refreshed cookie: surfaced via onCredentialsRefreshed callback", async () => { + reset(); + const m = installMockFetch({ + session: { + status: 200, + body: { + accessToken: "jwt-abc", + expires: new Date(Date.now() + 3600_000).toISOString(), + user: { id: "user-1" }, + }, + setCookie: "__Secure-next-auth.session-token=ROTATED-VALUE; Path=/; HttpOnly; Secure", + }, + }); + try { + let refreshed = null; + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "old-cookie" }, + signal: AbortSignal.timeout(10_000), + log: null, + onCredentialsRefreshed: (creds) => { + refreshed = creds; + }, + }); + + assert.ok(refreshed, "callback should have fired"); + // Refreshed cookie is stored as a full cookie line so it round-trips through + // buildSessionCookieHeader on the next request (works for chunked tokens too). + assert.equal(refreshed.apiKey, "__Secure-next-auth.session-token=ROTATED-VALUE"); + } finally { + m.restore(); + } +}); + +// ─── Sentinel + PoW ───────────────────────────────────────────────────────── + +test("Sentinel: chat-requirements is hit before /backend-api/conversation", async () => { + reset(); + const order = []; + const m = installMockFetch({ + onSession: () => order.push("session"), + onSentinel: () => order.push("sentinel"), + onConv: () => order.push("conv"), + }); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.deepEqual(order, ["session", "sentinel", "conv"]); + } finally { + m.restore(); + } +}); + +test("Sentinel: chat-requirements token forwarded on conv request", async () => { + reset(); + const m = installMockFetch({ + sentinel: { status: 200, body: { token: "REQ-TOKEN-XYZ", proofofwork: { required: false } } }, + }); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + const convHeaders = m.calls.headers[2]; + assert.equal(convHeaders["openai-sentinel-chat-requirements-token"], "REQ-TOKEN-XYZ"); + } finally { + m.restore(); + } +}); + +test("PoW: when required, proof token is sent with valid prefix", async () => { + reset(); + const m = installMockFetch({ + sentinel: { + status: 200, + body: { + token: "req-token", + proofofwork: { required: true, seed: "deadbeef", difficulty: "00fff" }, + }, + }, + }); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(15_000), + log: null, + }); + const convHeaders = m.calls.headers[2]; + const proof = convHeaders["openai-sentinel-proof-token"]; + assert.ok(proof, "proof token should be present"); + assert.match(proof, /^[gw]AAAAAB/); + } finally { + m.restore(); + } +}); + +test("Turnstile: required flag does NOT block — conv endpoint accepts requests", async () => { + // ChatGPT's Sentinel often reports turnstile.required: true even on requests + // the conversation endpoint will accept without a Turnstile token. We pass + // through and let /f/conversation decide. + reset(); + const m = installMockFetch({ + sentinel: { + status: 200, + body: { + token: "x", + turnstile: { required: true, dx: "challenge-data" }, + proofofwork: { required: false }, + }, + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(result.response.status, 200); + assert.equal(m.calls.conv, 1, "should reach conversation endpoint despite turnstile.required"); + } finally { + m.restore(); + } +}); + +// ─── Streaming / non-streaming ────────────────────────────────────────────── + +test("Non-streaming: returns OpenAI chat.completion JSON", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + assert.equal(result.response.status, 200); + const json = await result.response.json(); + assert.equal(json.object, "chat.completion"); + assert.equal(json.choices[0].message.role, "assistant"); + assert.equal(json.choices[0].message.content, "Hello, world!"); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.ok(json.id.startsWith("chatcmpl-cgpt-")); + assert.ok(json.usage.total_tokens > 0); + } finally { + m.restore(); + } +}); + +test("Streaming: produces valid SSE chunks ending with [DONE]", async () => { + reset(); + const m = installMockFetch({ + conv: { + status: 200, + events: [ + { + conversation_id: "c1", + message: { + id: "m1", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Hello "] }, + status: "in_progress", + }, + }, + { + conversation_id: "c1", + message: { + id: "m1", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Hello world!"] }, + status: "finished_successfully", + }, + }, + ], + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }], stream: true }, + stream: true, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + assert.equal(result.response.status, 200); + assert.equal(result.response.headers.get("Content-Type"), "text/event-stream"); + + const text = await result.response.text(); + const lines = text.split("\n").filter((l) => l.startsWith("data: ")); + assert.ok(lines.length >= 3); + + const first = JSON.parse(lines[0].slice(6)); + assert.equal(first.choices[0].delta.role, "assistant"); + + const lastLine = text.trim().split("\n").filter(Boolean).pop(); + assert.equal(lastLine, "data: [DONE]"); + } finally { + m.restore(); + } +}); + +test("Streaming: cumulative parts are diffed into non-overlapping deltas", async () => { + reset(); + const m = installMockFetch({ + conv: { + status: 200, + events: [ + { + conversation_id: "c1", + message: { + id: "m1", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Foo"] }, + status: "in_progress", + }, + }, + { + conversation_id: "c1", + message: { + id: "m1", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Foo bar"] }, + status: "in_progress", + }, + }, + { + conversation_id: "c1", + message: { + id: "m1", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Foo bar baz"] }, + status: "finished_successfully", + }, + }, + ], + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }], stream: true }, + stream: true, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + const text = await result.response.text(); + const contentDeltas = text + .split("\n") + .filter((l) => l.startsWith("data: ") && l !== "data: [DONE]") + .map((l) => { + try { + return JSON.parse(l.slice(6)); + } catch { + return null; + } + }) + .filter((j) => j?.choices?.[0]?.delta?.content) + .map((j) => j.choices[0].delta.content); + + assert.deepEqual(contentDeltas, ["Foo", " bar", " baz"]); + } finally { + m.restore(); + } +}); + +// ─── Errors ───────────────────────────────────────────────────────────────── + +test("Error: 401 on /api/auth/session returns 401 with re-paste hint", async () => { + reset(); + const m = installMockFetch({ session: { status: 401, body: {} } }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "expired-cookie" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(result.response.status, 401); + const json = await result.response.json(); + assert.match(json.error.message, /session-token/); + } finally { + m.restore(); + } +}); + +test("Error: 200 with no accessToken returns 401", async () => { + reset(); + const m = installMockFetch({ session: { status: 200, body: {} } }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "stale-cookie" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(result.response.status, 401); + assert.equal(m.calls.sentinel, 0, "should not reach sentinel"); + } finally { + m.restore(); + } +}); + +test("Error: 403 from sentinel returns 403 SENTINEL_BLOCKED", async () => { + reset(); + const m = installMockFetch({ sentinel: { status: 403, body: { detail: "blocked" } } }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(result.response.status, 403); + const json = await result.response.json(); + assert.equal(json.error.code, "SENTINEL_BLOCKED"); + assert.equal(m.calls.conv, 0); + } finally { + m.restore(); + } +}); + +test("Error: 429 from conversation returns 429 with rate-limit message", async () => { + reset(); + const m = installMockFetch({ conv: { status: 429, error: "rate" } }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(result.response.status, 429); + const json = await result.response.json(); + assert.match(json.error.message, /rate limited/); + } finally { + m.restore(); + } +}); + +test("Error: empty messages returns 400 without any fetch", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(result.response.status, 400); + assert.equal(m.calls.session, 0); + } finally { + m.restore(); + } +}); + +test("Error: missing apiKey returns 401 without any fetch", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: {}, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(result.response.status, 401); + assert.equal(m.calls.session, 0); + } finally { + m.restore(); + } +}); + +// ─── Cookie prefix stripping ──────────────────────────────────────────────── + +test("Cookie: bare value gets prepended with cookie name", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "rawValue" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(m.calls.headers[0].Cookie, "__Secure-next-auth.session-token=rawValue"); + } finally { + m.restore(); + } +}); + +test("Cookie: unchunked cookie line is passed through verbatim", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "__Secure-next-auth.session-token=actualvalue" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(m.calls.headers[0].Cookie, "__Secure-next-auth.session-token=actualvalue"); + } finally { + m.restore(); + } +}); + +test("Cookie: chunked .0/.1 cookies are passed through verbatim (NextAuth reassembles)", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + apiKey: + "__Secure-next-auth.session-token.0=partA; __Secure-next-auth.session-token.1=partB", + }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal( + m.calls.headers[0].Cookie, + "__Secure-next-auth.session-token.0=partA; __Secure-next-auth.session-token.1=partB" + ); + } finally { + m.restore(); + } +}); + +test("Cookie: 'Cookie: ' DevTools prefix is stripped", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + apiKey: + "Cookie: __Secure-next-auth.session-token.0=A; __Secure-next-auth.session-token.1=B", + }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal( + m.calls.headers[0].Cookie, + "__Secure-next-auth.session-token.0=A; __Secure-next-auth.session-token.1=B" + ); + } finally { + m.restore(); + } +}); + +// ─── Session continuity ───────────────────────────────────────────────────── + +test("Session continuity: each call starts a fresh conversation (Temporary Chat mode)", async () => { + // Conversation continuity is intentionally disabled because the executor + // uses history_and_training_disabled: true (Temporary Chat), whose + // conversation_ids expire quickly upstream and 404 on re-use. Each call + // sends the full history with conversation_id: null. + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "First question" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + await executor.execute({ + model: "gpt-5.3-instant", + body: { + messages: [ + { role: "user", content: "First question" }, + { role: "assistant", content: "Hello, world!" }, + { role: "user", content: "Follow-up" }, + ], + }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + assert.equal(m.calls.conv, 2); + const secondBody = JSON.parse(m.calls.bodies[4]); + 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"); + assert.equal(userMessages.length, 2, "should include First question + Follow-up"); + } finally { + m.restore(); + } +}); + +// ─── Request inspection ───────────────────────────────────────────────────── + +test("Request: conversation POST has correct browser-like headers", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + assert.equal(m.calls.urls[2], "https://chatgpt.com/backend-api/f/conversation"); + const convHeaders = m.calls.headers[2]; + assert.match(convHeaders["User-Agent"], /Mozilla/); + assert.equal(convHeaders["Origin"], "https://chatgpt.com"); + assert.equal(convHeaders["Sec-Fetch-Site"], "same-origin"); + assert.equal(convHeaders["Accept"], "text/event-stream"); + } finally { + m.restore(); + } +}); + +test("Request: payload has correct ChatGPT shape", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { + messages: [ + { role: "system", content: "Be concise" }, + { role: "user", content: "What is 2+2?" }, + ], + }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + const body = JSON.parse(m.calls.bodies[2]); + assert.equal(body.action, "next"); + assert.equal(body.model, "gpt-5-3"); + assert.equal(body.history_and_training_disabled, true); + // System + user in messages + assert.equal(body.messages[0].author.role, "system"); + assert.equal(body.messages[0].content.parts[0], "Be concise"); + assert.equal(body.messages[body.messages.length - 1].author.role, "user"); + assert.equal(body.messages[body.messages.length - 1].content.parts[0], "What is 2+2?"); + } finally { + m.restore(); + } +}); + +// ─── Provider registry ────────────────────────────────────────────────────── + +test("Provider registry: chatgpt-web is registered with gpt-5.3-instant model", async () => { + const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); + const entry = getRegistryEntry("chatgpt-web"); + assert.ok(entry, "chatgpt-web should be in the registry"); + assert.equal(entry.executor, "chatgpt-web"); + assert.equal(entry.format, "openai"); + assert.equal(entry.authHeader, "cookie"); + assert.ok(entry.models.find((m) => m.id === "gpt-5.3-instant")); +});