Files
OmniRoute/open-sse/utils/chatgptWebBrowserSession.ts
backryun 7ed8ada432 feat(providers): restore ChatGPT Web via clean-room browser transport (#12239)
Restores ChatGPT Web on a clean-room browser transport, merged on the operator's explicit decision.

Worth stating precisely, because this touches a provenance decision: the PR does not revert #11754. It narrows RETIRED_COMMON_CHATGPT_WEB_PROVIDER_IDS to the single GPL-derived alias cgpt-web and registers chatgpt-web as a separate clean-room id. The old implementation stays retired and blocked; the retirement machinery, its error code and its 410 contract are untouched. All four retirement suites agree with that distinction and pass unchanged.

The 44 protected agent-instruction surfaces this PR touches (AGENTS.md, llm.txt and its 42 mirrors, README) were verified rather than trusted: masking digits and comparing the removed and added line sets gives 264 lines on each side, identical — every change is a provider-count substitution, with no sentence added, removed or reworded.

Reconciled on merge: clean against the tip, with the two chat chokepoints this PR grows (src/sse/handlers/chat.ts +40, open-sse/handlers/chatCore.ts +30) recorded in the file-size baseline under an annotation. Verified that exactly those two caps move and nothing else, so the #12411 ratchet holds. The rebaseline is carried on this branch rather than left in a validation worktree — the propagation mistake that put the 2026-09-02 merge waves base-red in #12434.

Verified: 504/504 across the PR's 44 test files plus all four chatgpt-web retirement suites, check:provider-consistency OK (272 REGISTRY entries, 355 canonical providers), check-file-size OK, and every changed TypeScript file parses.

Thanks @backryun — separating the clean-room id from the retired alias, instead of reopening the old one, is what made this reviewable.
2026-09-02 10:31:41 -03:00

480 lines
16 KiB
TypeScript

import { Buffer } from "node:buffer";
import type { ChatGptWebResolvedAttachment } from "./chatgptWebAttachments.ts";
import {
executeChatGptWebFirstPartyTurn,
type ChatGptWebFirstPartyRequest,
type ChatGptWebUiSelection,
} from "./chatgptWebFirstParty.ts";
import { ChatGptWebDeltaV1Decoder, parseChatGptWebEncodedItem } from "./chatgptWebDeltaV1.ts";
import {
ChatGptWebTopicStream,
parseChatGptWebConversationHandoff,
} from "./chatgptWebTransport.ts";
type JsonRecord = Record<string, unknown>;
type Page = import("playwright").Page;
const CHATGPT_WEB_ORIGIN = "https://chatgpt.com";
const DEFAULT_TURN_TIMEOUT_MS = 180_000;
const MAX_BUFFERED_FRAMES = 2_048;
const MAX_BUFFERED_FRAME_BYTES = 16 * 1024 * 1024;
export interface ChatGptWebBrowserSessionHandlers {
onBootstrap(sseText: string): void;
onWebSocketFrame(frameText: string): void;
onError(error: Error): void;
}
/**
* Boundary owned by a logged-in first-party browser page.
*
* The implementation must let ChatGPT's own page execute Sentinel, Turnstile, proof-of-work,
* cookies, and conduit preparation. Callers receive only the sanitized stream result.
*/
export interface ChatGptWebBrowserSession {
url(): string;
start(handlers: ChatGptWebBrowserSessionHandlers): Promise<() => Promise<void>>;
submitPrompt(request: ChatGptWebBrowserSubmission): Promise<string | void>;
readRenderedAssistantText?(timeoutMs?: number): Promise<string | null>;
}
export interface ChatGptWebBrowserSubmission {
prompt: string;
attachments: ChatGptWebResolvedAttachment[];
signal?: AbortSignal | null;
}
export interface ChatGptWebBrowserTurnRequest {
prompt: string;
attachments?: ChatGptWebResolvedAttachment[];
timeoutMs?: number;
signal?: AbortSignal | null;
}
export interface ChatGptWebBrowserTurnResult {
conversationId: string;
turnExchangeId: string;
text: string;
status: string;
endTurn: true;
}
export type { ChatGptWebUiSelection } from "./chatgptWebFirstParty.ts";
export interface PlaywrightChatGptWebBrowserSessionOptions {
pageUrl?: string;
selection?: ChatGptWebUiSelection;
closePageOnCleanup?: boolean;
executePageRequest?: (
page: Page,
input: ChatGptWebFirstPartyRequest,
options?: { signal?: AbortSignal | null }
) => Promise<string>;
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requirePrompt(value: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error("ChatGPT Web browser turn requires a non-empty prompt");
}
return value;
}
function requireFirstPartyUrl(value: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("ChatGPT Web browser session requires a valid URL");
}
if (url.origin !== CHATGPT_WEB_ORIGIN) {
throw new Error("ChatGPT Web browser session requires the first-party chatgpt.com origin");
}
}
function maybeTerminalResult(
snapshot: unknown,
conversationId: string,
turnExchangeId: string
): ChatGptWebBrowserTurnResult | null {
if (!isRecord(snapshot) || !isRecord(snapshot.message)) return null;
const message = snapshot.message;
const author = isRecord(message.author) ? message.author : null;
const content = isRecord(message.content) ? message.content : null;
const parts = Array.isArray(content?.parts) ? content.parts : [];
if (
author?.role !== "assistant" ||
content?.content_type !== "text" ||
!parts.every((part) => typeof part === "string") ||
message.status !== "finished_successfully" ||
message.end_turn !== true
) {
return null;
}
return {
conversationId,
turnExchangeId,
text: parts.join(""),
status: message.status,
endTurn: true,
};
}
function snapshotMessageRole(snapshot: unknown): string | null {
if (!isRecord(snapshot) || !isRecord(snapshot.message)) return null;
const author = isRecord(snapshot.message.author) ? snapshot.message.author : null;
return typeof author?.role === "string" ? author.role : null;
}
function terminalResult(
snapshot: unknown,
conversationId: string,
turnExchangeId: string
): ChatGptWebBrowserTurnResult {
const result = maybeTerminalResult(snapshot, conversationId, turnExchangeId);
if (result) return result;
if (!isRecord(snapshot) || !isRecord(snapshot.message)) {
const rootKeys = isRecord(snapshot) ? Object.keys(snapshot).sort().join(",") : "non-object";
throw new Error(`ChatGPT Web assistant document is incomplete (root=${rootKeys})`);
}
const message = snapshot.message;
const author = isRecord(message.author) ? message.author : null;
const content = isRecord(message.content) ? message.content : null;
const parts = Array.isArray(content?.parts) ? content.parts : [];
const summary = JSON.stringify({
messageKeys: Object.keys(message).sort(),
role: author?.role ?? null,
contentType: content?.content_type ?? null,
partCount: parts.length,
partTypes: parts.map((part) => typeof part),
status: message.status ?? null,
endTurn: message.end_turn ?? null,
});
throw new Error(`ChatGPT Web assistant document is incomplete (${summary})`);
}
function encodeParsedEvent(event: ReturnType<typeof parseChatGptWebEncodedItem>[number]): string {
const eventLine = event.event === "message" ? "" : `event: ${event.event}\n`;
return `${eventLine}data: ${event.data}\n\n`;
}
/** Decode the direct first-party `/f/conversation` SSE body. */
export function parseChatGptWebDirectConversation(sseText: string): ChatGptWebBrowserTurnResult {
if (typeof sseText !== "string" || !sseText.trim()) {
throw new Error("ChatGPT Web direct conversation returned an empty stream");
}
let decoder = new ChatGptWebDeltaV1Decoder();
let conversationId = "";
let turnExchangeId = "";
let latestTerminal: ChatGptWebBrowserTurnResult | null = null;
for (const event of parseChatGptWebEncodedItem(sseText)) {
if (isRecord(event.json)) {
if (typeof event.json.conversation_id === "string") {
conversationId = event.json.conversation_id;
}
if (typeof event.json.turn_exchange_id === "string") {
turnExchangeId = event.json.turn_exchange_id;
}
}
if (event.event === "delta_encoding") {
latestTerminal =
maybeTerminalResult(decoder.snapshot(), conversationId, turnExchangeId) ?? latestTerminal;
decoder = new ChatGptWebDeltaV1Decoder();
}
decoder.ingest(encodeParsedEvent(event));
latestTerminal =
maybeTerminalResult(decoder.snapshot(), conversationId, turnExchangeId) ?? latestTerminal;
}
const result =
maybeTerminalResult(decoder.snapshot(), conversationId, turnExchangeId) ?? latestTerminal;
if (!result) return terminalResult(decoder.snapshot(), conversationId, turnExchangeId);
return { ...result, conversationId, turnExchangeId };
}
function turnError(error: unknown, fallback: string): Error {
return error instanceof Error ? error : new Error(fallback);
}
class ChatGptWebBrowserTurnRunner {
private decoder = new ChatGptWebDeltaV1Decoder();
private readonly bufferedFrames: string[] = [];
private bufferedFrameBytes = 0;
private topicStream: ChatGptWebTopicStream | null = null;
private conversationId = "";
private turnExchangeId = "";
private latestTerminalAssistant: ChatGptWebBrowserTurnResult | null = null;
private renderedReadPending = false;
private settled = false;
private readonly turnController = new AbortController();
private readonly resultPromise: Promise<ChatGptWebBrowserTurnResult>;
private resolveResult: (result: ChatGptWebBrowserTurnResult) => void = () => {};
private rejectResult: (error: Error) => void = () => {};
constructor(
private readonly session: ChatGptWebBrowserSession,
private readonly prompt: string,
private readonly attachments: ChatGptWebResolvedAttachment[]
) {
this.resultPromise = new Promise((resolve, reject) => {
this.resolveResult = resolve;
this.rejectResult = reject;
});
// Browser events can finish while Playwright is still resolving submission.
void this.resultPromise.catch(() => {});
}
private fail(error: Error): void {
if (this.settled) return;
this.settled = true;
this.turnController.abort();
this.rejectResult(error);
}
private complete(): void {
if (this.settled) return;
try {
const result =
this.latestTerminalAssistant ??
terminalResult(this.decoder.snapshot(), this.conversationId, this.turnExchangeId);
this.settled = true;
this.resolveResult(result);
} catch (error) {
this.fail(turnError(error, "ChatGPT Web browser turn failed"));
}
}
private completeFromRenderedAssistant(): void {
if (this.renderedReadPending || !this.session.readRenderedAssistantText) return;
this.renderedReadPending = true;
void this.session
.readRenderedAssistantText(10_000)
.then((text) => this.acceptRenderedAssistant(text))
.catch(() => {
this.renderedReadPending = false;
});
}
private acceptRenderedAssistant(text: string | null): void {
this.renderedReadPending = false;
if (this.settled || typeof text !== "string" || !text.trim()) return;
this.settled = true;
this.resolveResult({
conversationId: this.conversationId,
turnExchangeId: this.turnExchangeId,
text: text.trim(),
status: "finished_successfully",
endTurn: true,
});
}
private finishFrame(): void {
if (this.latestTerminalAssistant) {
this.complete();
return;
}
if (snapshotMessageRole(this.decoder.snapshot()) !== "tool") {
this.complete();
return;
}
this.topicStream = null;
this.decoder = new ChatGptWebDeltaV1Decoder();
this.completeFromRenderedAssistant();
}
private ingestFrame(frameText: string): void {
if (!this.topicStream || this.settled) return;
try {
const frame = this.topicStream.ingestFrame(frameText);
for (const encodedItem of frame.encodedItems) {
if (!this.decoder.ingest(encodedItem).changed) continue;
this.latestTerminalAssistant =
maybeTerminalResult(this.decoder.snapshot(), this.conversationId, this.turnExchangeId) ??
this.latestTerminalAssistant;
}
if (frame.done) this.finishFrame();
} catch (error) {
this.fail(turnError(error, "ChatGPT Web stream decoding failed"));
}
}
private handleBootstrap(sseText: string): void {
if (this.settled) return;
if (this.topicStream) {
this.fail(new Error("ChatGPT Web browser turn received more than one handoff"));
return;
}
try {
const handoff = parseChatGptWebConversationHandoff(sseText);
if (this.conversationId && handoff.conversationId !== this.conversationId) {
this.fail(new Error("ChatGPT Web browser turn changed conversation during handoff"));
return;
}
this.conversationId = handoff.conversationId;
this.turnExchangeId = handoff.turnExchangeId;
this.decoder = new ChatGptWebDeltaV1Decoder();
this.latestTerminalAssistant = null;
this.topicStream = new ChatGptWebTopicStream(handoff.topicId);
for (const frame of this.bufferedFrames.splice(0)) this.ingestFrame(frame);
this.bufferedFrameBytes = 0;
} catch (error) {
this.fail(turnError(error, "ChatGPT Web handoff parsing failed"));
}
}
private handleWebSocketFrame(frameText: string): void {
if (this.settled) return;
if (this.topicStream) {
this.ingestFrame(frameText);
return;
}
this.bufferedFrameBytes += Buffer.byteLength(frameText);
if (
this.bufferedFrames.length >= MAX_BUFFERED_FRAMES ||
this.bufferedFrameBytes > MAX_BUFFERED_FRAME_BYTES
) {
this.fail(new Error("ChatGPT Web browser turn exceeded the pre-handoff frame buffer"));
return;
}
this.bufferedFrames.push(frameText);
}
private handlers(): ChatGptWebBrowserSessionHandlers {
return {
onBootstrap: (sseText) => this.handleBootstrap(sseText),
onWebSocketFrame: (frameText) => this.handleWebSocketFrame(frameText),
onError: () => this.fail(new Error("ChatGPT Web first-party browser session failed")),
};
}
private submitPrompt(): void {
void this.session
.submitPrompt({
prompt: this.prompt,
attachments: this.attachments,
signal: this.turnController.signal,
})
.then((directResponse) => {
if (typeof directResponse !== "string" || this.settled) return;
this.settled = true;
this.resolveResult(parseChatGptWebDirectConversation(directResponse));
})
.catch((error: unknown) => {
this.fail(turnError(error, "ChatGPT Web prompt submission failed"));
});
}
async run(timeoutMs: number, signal?: AbortSignal | null): Promise<ChatGptWebBrowserTurnResult> {
let cleanup: (() => Promise<void>) | null = null;
const timeout = setTimeout(
() => this.fail(new Error("ChatGPT Web browser turn timed out")),
timeoutMs
);
timeout.unref?.();
const abort = (): void => this.fail(new Error("ChatGPT Web browser turn aborted"));
signal?.addEventListener("abort", abort, { once: true });
try {
cleanup = await this.session.start(this.handlers());
if (!this.settled) this.submitPrompt();
return await this.resultPromise;
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", abort);
await cleanup?.();
}
}
}
/** Run one turn while the first-party browser remains the sole challenge and auth owner. */
export async function runChatGptWebBrowserTurn(
session: ChatGptWebBrowserSession,
request: ChatGptWebBrowserTurnRequest
): Promise<ChatGptWebBrowserTurnResult> {
if (request.signal?.aborted) throw new Error("ChatGPT Web browser turn aborted");
const prompt = requirePrompt(request.prompt);
requireFirstPartyUrl(session.url());
const timeoutMs = request.timeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error("ChatGPT Web browser turn requires a positive timeout");
}
const runner = new ChatGptWebBrowserTurnRunner(session, prompt, request.attachments ?? []);
return runner.run(timeoutMs, request.signal);
}
/**
* Playwright binding for a logged-in ChatGPT page.
*
* ChatGPT's own loaded module performs auth and Sentinel inside the page. The hot path never
* touches the composer, model picker, attachment input, cookies, or bearer tokens.
*/
export class PlaywrightChatGptWebBrowserSession implements ChatGptWebBrowserSession {
private readonly pageUrl: string;
private readonly selection: ChatGptWebUiSelection | undefined;
private readonly closePageOnCleanup: boolean;
private readonly executePageRequest: NonNullable<
PlaywrightChatGptWebBrowserSessionOptions["executePageRequest"]
>;
constructor(
private readonly page: Page,
options: string | PlaywrightChatGptWebBrowserSessionOptions = {}
) {
if (typeof options === "string") {
this.pageUrl = options;
this.selection = undefined;
this.closePageOnCleanup = false;
this.executePageRequest = executeChatGptWebFirstPartyTurn;
} else {
this.pageUrl = options.pageUrl ?? "https://chatgpt.com/?temporary-chat=true";
this.selection = options.selection;
this.closePageOnCleanup = options.closePageOnCleanup === true;
this.executePageRequest = options.executePageRequest ?? executeChatGptWebFirstPartyTurn;
}
}
url(): string {
return this.pageUrl;
}
async start(handlers: ChatGptWebBrowserSessionHandlers): Promise<() => Promise<void>> {
void handlers;
requireFirstPartyUrl(this.pageUrl);
const cleanup = async (): Promise<void> => {
if (this.closePageOnCleanup) await this.page.close().catch(() => {});
};
try {
let currentIsFirstParty = false;
try {
currentIsFirstParty = new URL(this.page.url()).origin === CHATGPT_WEB_ORIGIN;
} catch {
currentIsFirstParty = false;
}
if (!currentIsFirstParty) {
await this.page.goto(this.pageUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
}
requireFirstPartyUrl(this.page.url());
return cleanup;
} catch (error) {
await cleanup();
throw error;
}
}
async submitPrompt(request: ChatGptWebBrowserSubmission): Promise<string> {
if (!this.selection) throw new Error("ChatGPT Web direct request requires a model selection");
requireFirstPartyUrl(this.page.url());
return this.executePageRequest(
this.page,
{
prompt: requirePrompt(request.prompt),
attachments: request.attachments,
selection: this.selection,
},
{ signal: request.signal }
);
}
}