diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 3f52b406cd..b311bbb0f6 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -80,6 +80,7 @@ import { validatePoeProvider, } from "./validation/audioMiscProviders"; import { validateChatGptWebCodexProvider } from "./validation/chatgptWebCodex"; +import { validateChatGptSessionProvider } from "./validation/chatgptSession"; import { validateZaiWebProvider } from "./validation/zaiWeb"; import { validateSearchProvider, SEARCH_VALIDATOR_CONFIGS } from "./validation/searchProviders"; import { @@ -309,6 +310,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi "grok-web": validateGrokWebProvider, "kimi-web": validateKimiWebProvider, "chatgpt-web-codex": validateChatGptWebCodexProvider, + "chatgpt-session": validateChatGptSessionProvider, "perplexity-web": validatePerplexityWebProvider, "blackbox-web": validateBlackboxWebProvider, "muse-spark-web": validateMuseSparkWebProvider, diff --git a/src/lib/providers/validation/chatgptSession.ts b/src/lib/providers/validation/chatgptSession.ts new file mode 100644 index 0000000000..202407d277 --- /dev/null +++ b/src/lib/providers/validation/chatgptSession.ts @@ -0,0 +1,93 @@ +import { randomBytes } from "node:crypto"; +import { rmSync } from "node:fs"; + +import { CHATGPT_WEB_CODEX_CONNECTOR_NAME } from "@/shared/constants/chatgptWebCodex"; +import { inspectBrowserLoginCapabilities } from "@omniroute/open-sse/vendor/codex-chatgpt-web/browser-login.ts"; +import { decodeChatGptWebCodexSecrets } from "@omniroute/open-sse/executors/chatgpt-web-codex/credentials.ts"; +import { detectChromeExecutable } from "@omniroute/open-sse/executors/chatgpt-web-codex.ts"; +import { + connectionRuntimePaths, + ensureConnectionStorageState, + ensureConnectionStorageStateFromCredential, +} from "@omniroute/open-sse/executors/chatgpt-web-codex/storageState.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +export async function validateChatGptSessionProvider({ + apiKey, + providerSpecificData = {}, +}: { + apiKey?: string; + providerSpecificData?: Record; +}) { + try { + const secrets = decodeChatGptWebCodexSecrets(String(apiKey || "")); + if (!secrets.cookie && !secrets.storageState) { + return { + valid: false, + error: "A ChatGPT cookie header or a stored browser session is required.", + }; + } + + const cdpEndpoint = process.env.CHATGPT_WEB_CODEX_CDP_URL?.trim(); + const chromeExecutablePath = detectChromeExecutable( + typeof providerSpecificData.chromeExecutablePath === "string" + ? providerSpecificData.chromeExecutablePath + : undefined + ); + if (!chromeExecutablePath && !cdpEndpoint) { + return { + valid: false, + error: + "No supported Chrome or Chromium was found. Install Chromium or configure the browser path.", + }; + } + + const validationId = `validation-${randomBytes(12).toString("hex")}`; + const paths = connectionRuntimePaths(validationId); + const freshCookie = Boolean(secrets.cookie); + if (secrets.cookie) ensureConnectionStorageState(validationId, secrets.cookie); + else ensureConnectionStorageStateFromCredential(validationId, secrets); + + let capabilities; + try { + capabilities = await inspectBrowserLoginCapabilities({ + appName: CHATGPT_WEB_CODEX_CONNECTOR_NAME, + ...(chromeExecutablePath ? { chromeExecutablePath } : {}), + ...(cdpEndpoint ? { cdpEndpoint } : {}), + storageStatePath: paths.storageStatePath, + headed: false, + proAvailable: false, + autoApproveToolCalls: false, + }); + } catch (error) { + rmSync(paths.root, { recursive: true, force: true }); + throw error; + } + if (!freshCookie) rmSync(paths.root, { recursive: true, force: true }); + + return { + valid: true, + error: null, + method: "headless-browser", + capabilities: { + browser: "ready", + storageState: "verified", + login: "authenticated", + solAvailable: capabilities.solAvailable, + proAvailable: capabilities.proAvailable, + }, + providerSpecificData: { + solAvailable: capabilities.solAvailable, + proAvailable: capabilities.proAvailable, + browserVerified: true, + ...(chromeExecutablePath ? { chromeExecutablePath } : {}), + ...(freshCookie ? { validationId } : {}), + }, + }; + } catch (error) { + return { + valid: false, + error: sanitizeErrorMessage(error instanceof Error ? error.message : error), + }; + } +} diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index c45a07cb1a..1076bf77a2 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -329,6 +329,7 @@ const LOBE_PROVIDER_ALIASES = { "black-forest-labs": "Bfl", cerebras: "Cerebras", "chatgpt-web-codex": "OpenAI", + "chatgpt-session": "OpenAI", claude: "ClaudeCode", "claude-web": "Claude", cline: "Cline", diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index e079cec5e1..a1a4831a10 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -18,6 +18,21 @@ export const WEB_COOKIE_PROVIDERS = { riskNoticeVariant: "webCookie", toolCalling: "native", }, + "chatgpt-session": { + id: "chatgpt-session", + serviceKinds: ["llm"], + alias: "cgpt-session", + name: "ChatGPT Web (Session)", + icon: "chat", + color: "#10A37F", + textIcon: "CS", + website: "https://chatgpt.com", + authHint: + "Paste the full ChatGPT Cookie header. OmniRoute verifies it in an isolated browser profile and then stores only the verified session state.", + subscriptionRisk: true, + riskNoticeVariant: "webCookie", + toolCalling: "emulated", + }, "grok-web": { id: "grok-web", serviceKinds: ["llm"], diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 08eef2d6f5..8f306222ba 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -35,6 +35,13 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: true, storageKeys: ["cookie", "sessionToken", "session-token", "__Secure-next-auth.session-token"], }, + "chatgpt-session": { + kind: "cookie", + credentialName: "ChatGPT Cookie header (full)", + placeholder: "__Secure-next-auth.session-token=...; cf_clearance=...", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "sessionToken", "session-token", "__Secure-next-auth.session-token"], + }, "zenmux-free": { kind: "cookie", credentialName: "Cookie header (full)", diff --git a/tests/unit/chatgpt-session-validation.test.ts b/tests/unit/chatgpt-session-validation.test.ts new file mode 100644 index 0000000000..b65328ed48 --- /dev/null +++ b/tests/unit/chatgpt-session-validation.test.ts @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { WEB_COOKIE_PROVIDERS } from "../../src/shared/constants/providers/web-cookie.ts"; +import { WEB_SESSION_CREDENTIAL_REQUIREMENTS } from "../../src/shared/providers/webSessionCredentials.ts"; + +test("the dashboard card describes the session provider", () => { + const card = (WEB_COOKIE_PROVIDERS as Record>)[ + "chatgpt-session" + ]; + assert.ok(card, "chatgpt-session must have a web-cookie card"); + assert.equal(card.alias, "cgpt-session"); + assert.equal(card.website, "https://chatgpt.com"); + assert.equal(card.subscriptionRisk, true); + assert.equal(card.riskNoticeVariant, "webCookie"); + assert.equal(card.toolCalling, "emulated"); +}); + +test("the credential requirement accepts a full cookie header", () => { + const requirement = ( + WEB_SESSION_CREDENTIAL_REQUIREMENTS as Record> + )["chatgpt-session"]; + assert.ok(requirement); + assert.equal(requirement.kind, "cookie"); + assert.equal(requirement.acceptsFullCookieHeader, true); +}); + +test("validation is registered for the provider id", async () => { + const module = await import("../../src/lib/providers/validation.ts"); + const source = await import("node:fs").then((fs) => + fs.readFileSync( + new URL("../../src/lib/providers/validation.ts", import.meta.url), + "utf8" + ) + ); + assert.match(source, /"chatgpt-session":\s*validateChatGptSessionProvider/); + assert.ok(module); +}); + +test("validation rejects an empty credential without launching a browser", async () => { + const { validateChatGptSessionProvider } = await import( + "../../src/lib/providers/validation/chatgptSession.ts" + ); + const result = await validateChatGptSessionProvider({ apiKey: "" }); + assert.equal(result.valid, false); + assert.match(String(result.error), /cookie|credential/i); +});