diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 3bbe456efc..8faf21b202 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -35,6 +35,7 @@ import { encodeChatGptWebCodexSecrets, } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts"; import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; +import { usesChatGptBrowserSessionCredentials } from "@/shared/constants/chatgptWebCodex"; function normalizeCodexLimitPolicy( incoming: unknown, @@ -172,7 +173,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (defaultModel !== undefined) updateData.defaultModel = defaultModel; if (isActive !== undefined) updateData.isActive = isActive; if (apiKey && canUpdateProviderApiKey(existing.authType, existing.provider)) { - if (existing.provider === "chatgpt-web-codex") { + if (usesChatGptBrowserSessionCredentials(existing.provider)) { const validationId = incomingPsd && typeof incomingPsd.validationId === "string" ? incomingPsd.validationId @@ -215,7 +216,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: // the override (connection follows the global default); 0-1440 = explicit // per-connection minutes (0 opts this connection out of the sweep). if (healthCheckInterval === null) updateData.healthCheckInterval = null; - else if (healthCheckInterval !== undefined) updateData.healthCheckInterval = healthCheckInterval; + else if (healthCheckInterval !== undefined) + updateData.healthCheckInterval = healthCheckInterval; if (group !== undefined) updateData.group = group; if (maxConcurrent !== undefined) updateData.maxConcurrent = maxConcurrent; if (incomingWindowThresholds !== undefined) { diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index bb5d1ded72..1f95f0b78c 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -48,6 +48,7 @@ import { getModelSyncInternalBaseUrl, } from "@/shared/services/modelSyncScheduler"; import { finalizeValidatedChatGptWebCodexSecrets } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts"; +import { usesChatGptBrowserSessionCredentials } from "@/shared/constants/chatgptWebCodex"; import { isAutoFetchModelsEnabled } from "@/lib/providerModels/modelDiscovery"; import { testSingleConnection } from "./[id]/test/route"; import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; @@ -198,7 +199,7 @@ export async function POST(request: Request) { providerSpecificData = normalizeQoderPatProviderData(providerSpecificData || {}); } - if (provider === "chatgpt-web-codex") { + if (usesChatGptBrowserSessionCredentials(provider)) { const validationId = providerSpecificData && typeof providerSpecificData.validationId === "string" ? providerSpecificData.validationId @@ -324,11 +325,16 @@ export async function POST(request: Request) { }) .then((syncRes) => { if (!syncRes.ok) { - console.log(`[providers] Auto-sync failed for ${newConnection.id}: ${syncRes.status}`); + console.log( + `[providers] Auto-sync failed for ${newConnection.id}: ${syncRes.status}` + ); } }) .catch((err) => { - console.log(`[providers] Auto-sync error for ${newConnection.id}:`, err?.message || err); + console.log( + `[providers] Auto-sync error for ${newConnection.id}:`, + err?.message || err + ); }); } catch (syncSetupError) { // Defensive: if URL parsing or header construction itself throws, do diff --git a/src/shared/constants/chatgptWebCodex.ts b/src/shared/constants/chatgptWebCodex.ts index 6a405fc7db..5442770f97 100644 --- a/src/shared/constants/chatgptWebCodex.ts +++ b/src/shared/constants/chatgptWebCodex.ts @@ -10,3 +10,21 @@ export function isChatGptWebCodexModel(model: unknown): boolean { // persisted account session is valid. Keep runtime turns aligned with the headed browser // used to verify that same storage state. export const CHATGPT_WEB_CODEX_RUNTIME_HEADED = true; + +// Both "chatgpt-web-codex" and "chatgpt-session" store a verified Playwright storage +// state produced from a pasted ChatGPT cookie header — the browser-session credential +// lifecycle (decode -> ensure storage state -> inspect -> finalize) is shared between +// them. The finalize step is what discards the raw pasted cookie in favor of the +// verified storage state, so any dashboard route that gates on that lifecycle must +// recognize both provider ids, not just the codex one. +export const CHATGPT_BROWSER_SESSION_PROVIDER_IDS = [ + CHATGPT_WEB_CODEX_PROVIDER_ID, + "chatgpt-session", +] as const; + +export function usesChatGptBrowserSessionCredentials(provider: unknown): boolean { + return ( + typeof provider === "string" && + (CHATGPT_BROWSER_SESSION_PROVIDER_IDS as readonly string[]).includes(provider) + ); +} diff --git a/tests/unit/chatgpt-session-validation.test.ts b/tests/unit/chatgpt-session-validation.test.ts index b65328ed48..4d6203603f 100644 --- a/tests/unit/chatgpt-session-validation.test.ts +++ b/tests/unit/chatgpt-session-validation.test.ts @@ -3,11 +3,10 @@ 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"; +import { usesChatGptBrowserSessionCredentials } from "../../src/shared/constants/chatgptWebCodex.ts"; test("the dashboard card describes the session provider", () => { - const card = (WEB_COOKIE_PROVIDERS as Record>)[ - "chatgpt-session" - ]; + 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"); @@ -25,23 +24,34 @@ test("the credential requirement accepts a full cookie header", () => { 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("validateProviderApiKey dispatch resolves chatgpt-session to its own validator", async () => { + const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); + // "cookie:" decodes to an empty cookie (decodeChatGptWebCodexSecrets strips the + // "cookie:" prefix), so validateChatGptSessionProvider returns its own + // cookie-required rejection immediately — before any Chrome/CDP detection or + // browser launch. A non-empty credential is required here: an empty apiKey is + // intercepted by validateProviderApiKey's own "Provider and API key required" + // gate before dispatch ever reaches the SPECIALTY_VALIDATORS map, which would + // prove nothing about chatgpt-session's own registration. + const result = await validateProviderApiKey({ + provider: "chatgpt-session", + apiKey: "cookie:", + }); + assert.equal(result.valid, false); + assert.equal(result.error, "A ChatGPT cookie header or a stored browser session is required."); }); test("validation rejects an empty credential without launching a browser", async () => { - const { validateChatGptSessionProvider } = await import( - "../../src/lib/providers/validation/chatgptSession.ts" - ); + 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); }); + +test("usesChatGptBrowserSessionCredentials recognizes both browser-session providers", () => { + assert.equal(usesChatGptBrowserSessionCredentials("chatgpt-web-codex"), true); + assert.equal(usesChatGptBrowserSessionCredentials("chatgpt-session"), true); + assert.equal(usesChatGptBrowserSessionCredentials("openai"), false); + assert.equal(usesChatGptBrowserSessionCredentials(undefined), false); +});