mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 19:02:17 +03:00
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.
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { acquireBrowserContext, openPage } from "../services/browserPool.ts";
|
|
import type { ExecuteInput, ProviderCredentials } from "../executors/base.ts";
|
|
import {
|
|
extractChatGptWebAttachmentSources,
|
|
isChatGptWebAttachmentContentPart,
|
|
resolveChatGptWebAttachments,
|
|
type ChatGptWebAttachmentSource,
|
|
} from "./chatgptWebAttachments.ts";
|
|
import {
|
|
PlaywrightChatGptWebBrowserSession,
|
|
runChatGptWebBrowserTurn,
|
|
type ChatGptWebBrowserSession,
|
|
type ChatGptWebBrowserTurnRequest,
|
|
type ChatGptWebBrowserTurnResult,
|
|
type ChatGptWebUiSelection,
|
|
} from "./chatgptWebBrowserSession.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
const CHATGPT_WEB_PAGE_URL = "https://chatgpt.com/?temporary-chat=true";
|
|
const MAX_PROMPT_BYTES = 4 * 1024 * 1024;
|
|
const FIRST_PARTY_COOKIE_HOSTS = ["chatgpt.com", "openai.com"] as const;
|
|
|
|
export interface ChatGptWebStorageCookie extends JsonRecord {
|
|
name: string;
|
|
value: string;
|
|
domain: string;
|
|
path: string;
|
|
expires: number;
|
|
httpOnly: boolean;
|
|
secure: boolean;
|
|
sameSite: "Strict" | "Lax" | "None";
|
|
}
|
|
|
|
export interface ChatGptWebStorageOrigin extends JsonRecord {
|
|
origin: string;
|
|
localStorage: Array<{ name: string; value: string }>;
|
|
}
|
|
|
|
export interface ChatGptWebStorageState {
|
|
cookies: ChatGptWebStorageCookie[];
|
|
origins: ChatGptWebStorageOrigin[];
|
|
}
|
|
|
|
export interface PreparedChatGptWebBrowserRequest {
|
|
prompt: string;
|
|
selection: ChatGptWebUiSelection;
|
|
attachments: ChatGptWebAttachmentSource[];
|
|
}
|
|
|
|
export interface ChatGptWebSessionFactoryInput {
|
|
connectionId: string;
|
|
storageState: ChatGptWebStorageState;
|
|
selection: ChatGptWebUiSelection;
|
|
userAgent?: string;
|
|
locale?: string;
|
|
timezone?: string;
|
|
chromeExecutablePath?: string;
|
|
}
|
|
|
|
export interface ChatGptWebExecutorAdapterDeps {
|
|
createSession?: (input: ChatGptWebSessionFactoryInput) => Promise<ChatGptWebBrowserSession>;
|
|
runTurn?: (
|
|
session: ChatGptWebBrowserSession,
|
|
request: ChatGptWebBrowserTurnRequest
|
|
) => Promise<ChatGptWebBrowserTurnResult>;
|
|
id?: () => string;
|
|
now?: () => number;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is JsonRecord {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isFirstPartyHost(value: string): boolean {
|
|
const host = value.toLowerCase().replace(/^\./, "");
|
|
return FIRST_PARTY_COOKIE_HOSTS.some(
|
|
(allowed) => host === allowed || host.endsWith(`.${allowed}`)
|
|
);
|
|
}
|
|
|
|
function validateCookie(value: unknown): asserts value is ChatGptWebStorageCookie {
|
|
if (
|
|
!isRecord(value) ||
|
|
typeof value.name !== "string" ||
|
|
!value.name ||
|
|
typeof value.value !== "string" ||
|
|
typeof value.domain !== "string" ||
|
|
typeof value.path !== "string" ||
|
|
!value.path.startsWith("/") ||
|
|
typeof value.expires !== "number" ||
|
|
!Number.isFinite(value.expires) ||
|
|
typeof value.httpOnly !== "boolean" ||
|
|
typeof value.secure !== "boolean" ||
|
|
!["Strict", "Lax", "None"].includes(String(value.sameSite))
|
|
) {
|
|
throw new Error("ChatGPT Web browser storage state contains an invalid cookie");
|
|
}
|
|
if (!isFirstPartyHost(value.domain)) {
|
|
throw new Error("ChatGPT Web browser storage state contains a foreign cookie domain");
|
|
}
|
|
}
|
|
|
|
function validateOrigin(value: unknown): asserts value is ChatGptWebStorageOrigin {
|
|
if (!isRecord(value) || typeof value.origin !== "string" || !Array.isArray(value.localStorage)) {
|
|
throw new Error("ChatGPT Web browser storage state contains an invalid origin");
|
|
}
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value.origin);
|
|
} catch {
|
|
throw new Error("ChatGPT Web browser storage state contains an invalid origin");
|
|
}
|
|
if (url.protocol !== "https:" || !isFirstPartyHost(url.hostname)) {
|
|
throw new Error("ChatGPT Web browser storage state contains a foreign origin");
|
|
}
|
|
for (const entry of value.localStorage) {
|
|
if (!isRecord(entry) || typeof entry.name !== "string" || typeof entry.value !== "string") {
|
|
throw new Error("ChatGPT Web browser storage state contains invalid local storage");
|
|
}
|
|
}
|
|
}
|
|
|
|
export function normalizeChatGptWebStorageState(value: unknown): ChatGptWebStorageState {
|
|
if (!isRecord(value) || !Array.isArray(value.cookies) || !Array.isArray(value.origins)) {
|
|
throw new Error("ChatGPT Web browser storage state is invalid");
|
|
}
|
|
for (const cookie of value.cookies) validateCookie(cookie);
|
|
for (const origin of value.origins) validateOrigin(origin);
|
|
return structuredClone(value) as unknown as ChatGptWebStorageState;
|
|
}
|
|
|
|
function contentText(value: unknown): string {
|
|
if (typeof value === "string") return value;
|
|
if (!Array.isArray(value)) {
|
|
throw new Error("ChatGPT Web clean-room adapter supports text content only");
|
|
}
|
|
const parts: string[] = [];
|
|
for (const part of value) {
|
|
if (
|
|
isRecord(part) &&
|
|
(part.type === "text" || part.type === "input_text") &&
|
|
typeof part.text === "string"
|
|
) {
|
|
parts.push(part.text);
|
|
continue;
|
|
}
|
|
if (isChatGptWebAttachmentContentPart(part)) continue;
|
|
throw new Error("ChatGPT Web clean-room adapter received unsupported content");
|
|
}
|
|
return parts.join("");
|
|
}
|
|
|
|
function buildPrompt(body: JsonRecord): string {
|
|
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
throw new Error("ChatGPT Web clean-room adapter does not support tools yet");
|
|
}
|
|
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
|
throw new Error("ChatGPT Web clean-room adapter requires messages");
|
|
}
|
|
const messages = body.messages.map((value) => {
|
|
if (!isRecord(value) || typeof value.role !== "string") {
|
|
throw new Error("ChatGPT Web clean-room adapter received an invalid message");
|
|
}
|
|
if (!["system", "developer", "user", "assistant"].includes(value.role)) {
|
|
throw new Error("ChatGPT Web clean-room adapter does not support tool messages yet");
|
|
}
|
|
if (Array.isArray(value.tool_calls) && value.tool_calls.length > 0) {
|
|
throw new Error("ChatGPT Web clean-room adapter does not support tools yet");
|
|
}
|
|
return { role: value.role, text: contentText(value.content) };
|
|
});
|
|
|
|
const prompt =
|
|
messages.length === 1 && messages[0].role === "user"
|
|
? messages[0].text
|
|
: messages
|
|
.map(({ role, text }) => `${role[0].toUpperCase()}${role.slice(1)}:\n${text}`)
|
|
.join("\n\n");
|
|
if (!prompt.trim()) throw new Error("ChatGPT Web clean-room adapter requires non-empty text");
|
|
if (new TextEncoder().encode(prompt).byteLength > MAX_PROMPT_BYTES) {
|
|
throw new Error("ChatGPT Web clean-room adapter prompt is too large");
|
|
}
|
|
return prompt;
|
|
}
|
|
|
|
function reasoningEffort(body: JsonRecord): string | null {
|
|
if (typeof body.reasoning_effort === "string") return body.reasoning_effort.toLowerCase();
|
|
if (isRecord(body.reasoning) && typeof body.reasoning.effort === "string") {
|
|
return body.reasoning.effort.toLowerCase();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function effortIndex(effort: string | null): 0 | 1 | 2 | 3 {
|
|
if (effort === null || effort === "medium") return 1;
|
|
if (["none", "off", "minimal", "low"].includes(effort)) return 0;
|
|
if (effort === "high") return 2;
|
|
if (effort === "xhigh" || effort === "max") return 3;
|
|
throw new Error(`ChatGPT Web clean-room adapter does not support reasoning effort ${effort}`);
|
|
}
|
|
|
|
function normalizedModel(value: string): string {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^chatgpt-web\//, "")
|
|
.replace(/^cgpt-web\//, "")
|
|
.replace(/\./g, "-");
|
|
}
|
|
|
|
function resolveSelection(model: string, body: JsonRecord): ChatGptWebUiSelection {
|
|
const normalized = normalizedModel(model);
|
|
if (normalized === "gpt-5-6-luna-free") {
|
|
return { kind: "free", thinkEnabled: false };
|
|
}
|
|
if (normalized === "gpt-5-6-luna-free-thinking") {
|
|
return { kind: "free", thinkEnabled: true };
|
|
}
|
|
if (normalized === "gpt-5-6-pro") {
|
|
return { kind: "picker", modelLabel: "GPT-5.6 Sol", effortIndex: 4 };
|
|
}
|
|
if (normalized === "gpt-5-6-instant" || normalized === "gpt-5-6") {
|
|
return { kind: "picker", modelLabel: "GPT-5.6 Sol", effortIndex: 0 };
|
|
}
|
|
if (["gpt-5-6-thinking", "gpt-5-6-sol"].includes(normalized)) {
|
|
return {
|
|
kind: "picker",
|
|
modelLabel: "GPT-5.6 Sol",
|
|
effortIndex: effortIndex(reasoningEffort(body)),
|
|
};
|
|
}
|
|
if (normalized === "gpt-5-5-pro") {
|
|
return { kind: "picker", modelLabel: "GPT-5.5", effortIndex: 4 };
|
|
}
|
|
if (normalized === "gpt-5-5-instant") {
|
|
return { kind: "picker", modelLabel: "GPT-5.5", effortIndex: 0 };
|
|
}
|
|
if (["gpt-5-5", "gpt-5-5-thinking"].includes(normalized)) {
|
|
return {
|
|
kind: "picker",
|
|
modelLabel: "GPT-5.5",
|
|
effortIndex: effortIndex(reasoningEffort(body)),
|
|
};
|
|
}
|
|
throw new Error(`ChatGPT Web clean-room adapter received an unsupported model: ${model}`);
|
|
}
|
|
|
|
export function prepareChatGptWebBrowserRequest(
|
|
model: string,
|
|
body: unknown
|
|
): PreparedChatGptWebBrowserRequest {
|
|
if (!isRecord(body)) throw new Error("ChatGPT Web clean-room adapter requires an object body");
|
|
const prompt = buildPrompt(body);
|
|
const attachments = extractChatGptWebAttachmentSources(
|
|
body.messages as Array<{ role?: string; content?: unknown }>
|
|
);
|
|
return { prompt, selection: resolveSelection(model, body), attachments };
|
|
}
|
|
|
|
function readStorageState(credentials: ProviderCredentials): ChatGptWebStorageState {
|
|
const providerData = credentials.providerSpecificData;
|
|
const raw = providerData?.storageState ?? credentials.apiKey;
|
|
if (typeof raw === "string") {
|
|
try {
|
|
return normalizeChatGptWebStorageState(JSON.parse(raw) as unknown);
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) {
|
|
throw new Error("ChatGPT Web browser storage state JSON is invalid");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
return normalizeChatGptWebStorageState(raw);
|
|
}
|
|
|
|
function optionalString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
export function resolveChatGptWebChromeExecutable(
|
|
explicit?: string,
|
|
deps: {
|
|
env?: NodeJS.ProcessEnv;
|
|
exists?: (path: string) => boolean;
|
|
} = {}
|
|
): string | undefined {
|
|
const env = deps.env ?? process.env;
|
|
const exists = deps.exists ?? existsSync;
|
|
const candidates = [
|
|
explicit,
|
|
env.CHATGPT_WEB_CHROME_PATH,
|
|
env.CHROME_PATH,
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/usr/bin/google-chrome",
|
|
"/usr/bin/google-chrome-stable",
|
|
"/usr/bin/chromium",
|
|
"/usr/bin/chromium-browser",
|
|
...(env.PROGRAMFILES
|
|
? [join(env.PROGRAMFILES, "Google", "Chrome", "Application", "chrome.exe")]
|
|
: []),
|
|
...(env["PROGRAMFILES(X86)"]
|
|
? [join(env["PROGRAMFILES(X86)"], "Google", "Chrome", "Application", "chrome.exe")]
|
|
: []),
|
|
...(env.LOCALAPPDATA
|
|
? [join(env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe")]
|
|
: []),
|
|
];
|
|
return candidates.find((candidate): candidate is string =>
|
|
Boolean(candidate?.trim() && exists(candidate.trim()))
|
|
);
|
|
}
|
|
|
|
async function createDefaultSession(
|
|
input: ChatGptWebSessionFactoryInput
|
|
): Promise<ChatGptWebBrowserSession> {
|
|
const digest = createHash("sha256")
|
|
.update(input.connectionId)
|
|
.update("\0")
|
|
.update(JSON.stringify(input.storageState))
|
|
.digest("hex");
|
|
const pooled = await acquireBrowserContext(`chatgpt-web-cleanroom:${digest}`, {
|
|
cookieDomain: "chatgpt.com",
|
|
storageState: input.storageState,
|
|
userAgent: input.userAgent,
|
|
locale: input.locale,
|
|
timezone: input.timezone,
|
|
proxyProviderKey: "chatgpt-web",
|
|
warmupUrl: CHATGPT_WEB_PAGE_URL,
|
|
headless: false,
|
|
executablePath: input.chromeExecutablePath,
|
|
});
|
|
const page =
|
|
pooled.warmupPage && !pooled.warmupPage.isClosed() ? pooled.warmupPage : await openPage(pooled);
|
|
if (pooled.warmupPage !== page) pooled.warmupPage = page;
|
|
return new PlaywrightChatGptWebBrowserSession(page, {
|
|
pageUrl: CHATGPT_WEB_PAGE_URL,
|
|
selection: input.selection,
|
|
closePageOnCleanup: false,
|
|
});
|
|
}
|
|
|
|
export function buildChatGptWebOpenAiResponse(
|
|
model: string,
|
|
result: ChatGptWebBrowserTurnResult,
|
|
stream: boolean,
|
|
metadata: { id?: string; created?: number } = {}
|
|
): Response {
|
|
const id = metadata.id ?? `chatcmpl-${randomUUID()}`;
|
|
const created = metadata.created ?? Math.floor(Date.now() / 1000);
|
|
if (!stream) {
|
|
return Response.json({
|
|
id,
|
|
object: "chat.completion",
|
|
created,
|
|
model,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: "assistant", content: result.text },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
const chunks = [
|
|
{
|
|
id,
|
|
object: "chat.completion.chunk",
|
|
created,
|
|
model,
|
|
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
|
},
|
|
{
|
|
id,
|
|
object: "chat.completion.chunk",
|
|
created,
|
|
model,
|
|
choices: [{ index: 0, delta: { content: result.text }, finish_reason: null }],
|
|
},
|
|
{
|
|
id,
|
|
object: "chat.completion.chunk",
|
|
created,
|
|
model,
|
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
},
|
|
];
|
|
return new Response(
|
|
chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n",
|
|
{ headers: { "Content-Type": "text/event-stream; charset=utf-8" } }
|
|
);
|
|
}
|
|
|
|
export async function executeChatGptWebCleanRoom(
|
|
input: Pick<ExecuteInput, "model" | "body" | "stream" | "credentials" | "signal">,
|
|
deps: ChatGptWebExecutorAdapterDeps = {}
|
|
): Promise<Response> {
|
|
const prepared = prepareChatGptWebBrowserRequest(input.model, input.body);
|
|
const attachments = await resolveChatGptWebAttachments(prepared.attachments);
|
|
const storageState = readStorageState(input.credentials);
|
|
const connectionId = optionalString(input.credentials.connectionId);
|
|
if (!connectionId) throw new Error("ChatGPT Web clean-room adapter requires a connection ID");
|
|
const providerData = input.credentials.providerSpecificData;
|
|
const session = await (deps.createSession ?? createDefaultSession)({
|
|
connectionId,
|
|
storageState,
|
|
selection: prepared.selection,
|
|
userAgent: optionalString(providerData?.customUserAgent),
|
|
locale: optionalString(providerData?.locale),
|
|
timezone: optionalString(providerData?.timezone),
|
|
chromeExecutablePath: resolveChatGptWebChromeExecutable(
|
|
optionalString(providerData?.chromeExecutablePath)
|
|
),
|
|
});
|
|
const result = await (deps.runTurn ?? runChatGptWebBrowserTurn)(session, {
|
|
prompt: prepared.prompt,
|
|
attachments,
|
|
signal: input.signal,
|
|
});
|
|
return buildChatGptWebOpenAiResponse(input.model, result, input.stream, {
|
|
id: deps.id?.(),
|
|
created: deps.now ? Math.floor(deps.now() / 1000) : undefined,
|
|
});
|
|
}
|