Add native ChatGPT Web provider for Codex clients (#8949)

* Bypass proxy compaction for native Codex context

* Add native ChatGPT Web provider pipeline

* Add managed browser and tunnel deployment

* Add ChatGPT Web setup and doctor UI

* Document and test ChatGPT Web integration

* fix(security): register chatgpt-web-codex-doctor in LOCAL_ONLY_API_PATTERNS

The diagnostic route under /api/providers/{id}/chatgpt-web-codex-doctor
was not registered in the spawn-capable route guard. Adding it for
parity with the existing /login pattern.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(providers): route chatgpt-web-codex admin routes through a service boundary

The provider CRUD/doctor routes imported chatgpt-web-codex helpers
(finalizeValidatedChatGptWebCodexSecrets, encode/decodeChatGptWebCodexSecrets,
getChatGptWebCodexDoctorStatus) directly from open-sse/executors/**, which
no-restricted-imports (EXECUTOR_IMPORT_RESTRICTION) forbids for src/app/**
files — executor implementations must stay behind an open-sse handler or
service boundary.

Add open-sse/services/chatgptWebCodexAdmin.ts as a thin re-export boundary
(mirroring the existing tokenRefresh.ts re-export pattern) and import from
there instead. No behavior change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Jan Leon
2026-08-11 14:53:39 +02:00
committed by GitHub
parent 1d33025c70
commit a99c795a67
91 changed files with 10601 additions and 26 deletions

View File

@@ -93,6 +93,7 @@ export default function AddApiKeyModal({
const localProviderMetadata = getLocalProviderMetadata(provider);
const isLocalSelfHostedProvider = !!localProviderMetadata;
const isGooglePse = provider === "google-pse-search";
const isChatGptWebCodex = provider === "chatgpt-web-codex";
const webSessionCredential = getWebSessionCredentialRequirement(provider);
const isNoAuthWebSessionCredential = webSessionCredential?.kind === "none";
const isWebSessionCredential = !!webSessionCredential && webSessionCredential.kind !== "none";
@@ -132,9 +133,16 @@ export default function AddApiKeyModal({
ccCompatibleSummarizeThinking: false,
passthroughModels: false,
importFreeModelsOnly: false,
tunnelId: "",
runtimeKey: "",
connectorName: "OmniRoute Codex",
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [validationCapabilities, setValidationCapabilities] = useState<Record<
string,
unknown
> | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -235,12 +243,18 @@ export default function AddApiKeyModal({
baseUrl: formData.baseUrl.trim() || undefined,
region: showsRegion ? formData.region.trim() || defaultRegion : undefined,
cx: formData.cx.trim() || undefined,
runtimeKey: isChatGptWebCodex ? formData.runtimeKey.trim() || undefined : undefined,
tunnelId: isChatGptWebCodex ? formData.tunnelId.trim() || undefined : undefined,
connectorName: isChatGptWebCodex ? formData.connectorName.trim() || undefined : undefined,
}),
});
const data = await res.json();
const ok = !!data.valid;
const unsupported = !!data.unsupported;
setValidationResult(ok ? "success" : unsupported ? "unsupported" : "failed");
setValidationCapabilities(
ok && data.capabilities && typeof data.capabilities === "object" ? data.capabilities : null
);
// #5088: surface backend reason (e.g. TLS/EACCES) instead of bare "invalid".
if (!ok && !unsupported && typeof data.error === "string" && data.error) {
setSaveError(data.error);
@@ -287,6 +301,7 @@ export default function AddApiKeyModal({
let isValid = Boolean(isNoAuthWebSessionCredential && !credentialInput);
let validationError: string | null = null;
let isUnsupported = false; // #5565/#5567: no live validator → save anyway
let validatedProviderSpecificData: Record<string, unknown> | undefined;
if (!isValid) {
try {
setValidating(true);
@@ -302,6 +317,11 @@ export default function AddApiKeyModal({
baseUrl: formData.baseUrl.trim() || undefined,
region: showsRegion ? formData.region.trim() || defaultRegion : undefined,
cx: formData.cx.trim() || undefined,
runtimeKey: isChatGptWebCodex ? formData.runtimeKey.trim() || undefined : undefined,
tunnelId: isChatGptWebCodex ? formData.tunnelId.trim() || undefined : undefined,
connectorName: isChatGptWebCodex
? formData.connectorName.trim() || undefined
: undefined,
}),
});
const data = await res.json();
@@ -310,6 +330,13 @@ export default function AddApiKeyModal({
if (!isValid && data.error) {
validationError = data.error;
}
if (
isValid &&
data.providerSpecificData &&
typeof data.providerSpecificData === "object"
) {
validatedProviderSpecificData = data.providerSpecificData;
}
setValidationResult(isValid ? "success" : isUnsupported ? "unsupported" : "failed");
} catch {
setValidationResult("failed");
@@ -341,14 +368,28 @@ export default function AddApiKeyModal({
isCloudflare,
isCcCompatible,
});
const mergedProviderSpecificData = {
...(providerSpecificData || {}),
...(validatedProviderSpecificData || {}),
};
const encodedCredential = isChatGptWebCodex
? JSON.stringify({
version: 1,
cookie: credentialInput.trim().replace(/^cookie\s*:\s*/i, ""),
runtimeKey: formData.runtimeKey.trim(),
})
: credentialInput.trim();
const payload = {
name: formData.name,
apiKey: credentialInput.trim() || undefined,
apiKey: encodedCredential || undefined,
priority: formData.priority,
testStatus: "active",
defaultModel: isCompatible ? formData.defaultModel.trim() || undefined : undefined,
providerSpecificData,
providerSpecificData:
Object.keys(mergedProviderSpecificData).length > 0
? mergedProviderSpecificData
: undefined,
};
const error = await onSave(payload);
@@ -738,6 +779,59 @@ export default function AddApiKeyModal({
</div>
</div>
)}
{isChatGptWebCodex && (
<div className="space-y-3 rounded-lg border border-border bg-surface/40 p-3">
<div>
<p className="text-sm font-medium text-text-main">Codex-Toolverbindung</p>
<p className="mt-1 text-xs text-text-muted">
Der Tunnel bleibt ausschließlich ausgehend. Lokale Tools werden weiterhin nur
von Codex gemäß dessen Sandbox- und Freigaberichtlinie ausgeführt.
</p>
</div>
<Input
label="Tunnel-ID"
value={formData.tunnelId}
onChange={(e) => setFormData({ ...formData, tunnelId: e.target.value })}
placeholder="tunnel_0123456789abcdef0123456789abcdef"
autoComplete="off"
spellCheck={false}
/>
<Input
label="Tunnel Runtime-Key"
type="password"
value={formData.runtimeKey}
onChange={(e) => setFormData({ ...formData, runtimeKey: e.target.value })}
placeholder="Runtime-Key"
hint="Wird zusammen mit dem Cookie verschlüsselt gespeichert und nie in Logs ausgegeben."
autoComplete="off"
spellCheck={false}
/>
<Input
label="ChatGPT-Custom-Connector"
value={formData.connectorName}
onChange={(e) => setFormData({ ...formData, connectorName: e.target.value })}
placeholder="OmniRoute Codex"
/>
{validationCapabilities && (
<div className="grid grid-cols-2 gap-2 text-xs text-text-muted">
<div>Browser: bereit</div>
<div>Storage-State: geprüft</div>
<div>ChatGPT-Anmeldung: bestätigt</div>
<div>Temporary Chat: bereit</div>
<div>
Pro:{" "}
{validationCapabilities.proAvailable === true ? "verfügbar" : "nicht erkannt"}
</div>
<div>
Toolmodus:{" "}
{formData.tunnelId.trim() && formData.runtimeKey.trim()
? "konfiguriert"
: "global oder read-only"}
</div>
</div>
)}
</div>
)}
{isModal && (
<Input
label={providerText(t, "modalTokenSecretLabel", "Token Secret")}

View File

@@ -143,12 +143,20 @@ export default function EditConnectionModal({
passthroughModels: connectionProviderSpecificData?.passthroughModels === true,
disableCooling: connectionProviderSpecificData?.disableCooling === true,
importFreeModelsOnly: connectionProviderSpecificData?.importFreeModelsOnly === true,
tunnelId: stringField(connectionProviderSpecificData?.tunnelId),
runtimeKey: "",
connectorName: stringField(connectionProviderSpecificData?.connectorName) || "OmniRoute Codex",
m365Tier: normalizeM365TierValue(connectionProviderSpecificData?.tier) as M365TierValue,
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [validatedProviderSpecificData, setValidatedProviderSpecificData] = useState<
Record<string, unknown> | undefined
>();
const [doctorStatus, setDoctorStatus] = useState<Record<string, any> | null>(null);
const [doctorLoading, setDoctorLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
@@ -202,6 +210,7 @@ export default function EditConnectionModal({
const localProviderMetadata = getLocalProviderMetadata(provider);
const isLocalSelfHostedProvider = !!localProviderMetadata;
const isGooglePse = provider === "google-pse-search";
const isChatGptWebCodex = provider === "chatgpt-web-codex";
const isM365TierCapable = isM365TierCapableProvider(provider);
const webSessionCredential = getWebSessionCredentialRequirement(provider);
const isNoAuthWebSessionCredential = webSessionCredential?.kind === "none";
@@ -348,6 +357,10 @@ export default function EditConnectionModal({
passthroughModels: connection?.providerSpecificData?.passthroughModels === true,
disableCooling: connection?.providerSpecificData?.disableCooling === true,
importFreeModelsOnly: connection?.providerSpecificData?.importFreeModelsOnly === true,
tunnelId: stringField(connection.providerSpecificData?.tunnelId),
runtimeKey: "",
connectorName:
stringField(connection.providerSpecificData?.connectorName) || "OmniRoute Codex",
m365Tier: normalizeM365TierValue(connection.providerSpecificData?.tier) as M365TierValue,
});
const existing = connection.providerSpecificData?.extraApiKeys;
@@ -373,6 +386,7 @@ export default function EditConnectionModal({
);
setTestResult(null);
setValidationResult(null);
setValidatedProviderSpecificData(undefined);
setSaveError(null);
}
}, [
@@ -434,10 +448,20 @@ export default function EditConnectionModal({
baseUrl: formData.baseUrl.trim() || undefined,
region: showsRegion ? formData.region.trim() || defaultRegion : undefined,
cx: formData.cx.trim() || undefined,
runtimeKey: isChatGptWebCodex ? formData.runtimeKey.trim() || undefined : undefined,
tunnelId: isChatGptWebCodex ? formData.tunnelId.trim() || undefined : undefined,
connectorName: isChatGptWebCodex ? formData.connectorName.trim() || undefined : undefined,
}),
});
const data = await res.json();
setValidationResult(data.valid ? "success" : "failed");
if (
data.valid &&
data.providerSpecificData &&
typeof data.providerSpecificData === "object"
) {
setValidatedProviderSpecificData(data.providerSpecificData);
}
} catch {
setValidationResult("failed");
} finally {
@@ -506,7 +530,7 @@ export default function EditConnectionModal({
}
}
if (!isOAuth && formData.apiKey) {
updates.apiKey = formData.apiKey;
let validationPsd = validatedProviderSpecificData;
let isValid = validationResult === "success";
if (!isValid) {
try {
@@ -523,11 +547,24 @@ export default function EditConnectionModal({
baseUrl: formData.baseUrl.trim() || undefined,
region: showsRegion ? formData.region.trim() || defaultRegion : undefined,
cx: formData.cx.trim() || undefined,
runtimeKey: isChatGptWebCodex ? formData.runtimeKey.trim() || undefined : undefined,
tunnelId: isChatGptWebCodex ? formData.tunnelId.trim() || undefined : undefined,
connectorName: isChatGptWebCodex
? formData.connectorName.trim() || undefined
: undefined,
}),
});
const data = await res.json();
isValid = !!data.valid;
setValidationResult(isValid ? "success" : "failed");
if (
isValid &&
data.providerSpecificData &&
typeof data.providerSpecificData === "object"
) {
setValidatedProviderSpecificData(data.providerSpecificData);
validationPsd = data.providerSpecificData;
}
} catch {
setValidationResult("failed");
} finally {
@@ -535,6 +572,13 @@ export default function EditConnectionModal({
}
}
if (isValid) {
updates.apiKey = isChatGptWebCodex
? JSON.stringify({
version: 1,
cookie: formData.apiKey.trim().replace(/^cookie\s*:\s*/i, ""),
...(formData.runtimeKey.trim() ? { runtimeKey: formData.runtimeKey.trim() } : {}),
})
: formData.apiKey;
updates.testStatus = "active";
updates.lastError = null;
updates.lastErrorAt = null;
@@ -550,6 +594,7 @@ export default function EditConnectionModal({
}
updates.providerSpecificData = {
...(connection.providerSpecificData || {}),
...(validationPsd || {}),
};
assignEditApiKeyProviderSpecificData({
provider,
@@ -893,6 +938,83 @@ export default function EditConnectionModal({
</div>
</div>
)}
{isChatGptWebCodex && (
<div className="space-y-3 rounded-lg border border-border bg-surface/40 p-3">
<p className="text-sm font-medium text-text-main">Codex-Toolverbindung</p>
<Input
label="Tunnel-ID"
value={formData.tunnelId}
onChange={(event) => setFormData({ ...formData, tunnelId: event.target.value })}
placeholder="tunnel_0123456789abcdef0123456789abcdef"
/>
<Input
label="Neuer Tunnel Runtime-Key"
type="password"
value={formData.runtimeKey}
onChange={(event) => setFormData({ ...formData, runtimeKey: event.target.value })}
hint="Nur zusammen mit einem frischen Cookie eingeben. Der Wert wird verschlüsselt gespeichert."
autoComplete="off"
/>
<Input
label="ChatGPT-Custom-Connector"
value={formData.connectorName}
onChange={(event) =>
setFormData({ ...formData, connectorName: event.target.value })
}
/>
<Button
variant="secondary"
disabled={doctorLoading || !connection.id}
onClick={async () => {
if (!connection.id) return;
setDoctorLoading(true);
try {
const response = await fetch(
`/api/providers/${connection.id}/chatgpt-web-codex-doctor`
);
const payload = await response.json();
setDoctorStatus(response.ok ? payload.status : { error: payload.error });
} finally {
setDoctorLoading(false);
}
}}
>
{doctorLoading ? "Status wird geprüft …" : "Doctor-Status prüfen"}
</Button>
{doctorStatus && (
<div className="grid grid-cols-2 gap-2 text-xs text-text-muted">
{[
["Browser", doctorStatus.browser?.ready],
["Storage-State", doctorStatus.storageState?.ready],
["ChatGPT-Anmeldung", doctorStatus.login?.ready],
["Temporary Chat", doctorStatus.temporaryChats?.ready],
["Tunnel-Binary", doctorStatus.tunnelBinary?.ready],
["Tunnel", doctorStatus.tunnel?.ready],
["Connector", doctorStatus.connector?.ready],
["Tool-Roundtrip", doctorStatus.toolRoundtrip?.ready],
["Aktive Turns", doctorStatus.runtime?.activeTurns],
["Wartende Turns", doctorStatus.runtime?.waitingTurns],
].map(([label, ready]) => (
<div key={String(label)}>
{label}:{" "}
{typeof ready === "number" ? ready : ready ? "bereit" : "nicht bereit"}
</div>
))}
{doctorStatus.recovery?.interactiveLoginRequired && (
<div className="col-span-2 text-warning">
Interaktive Anmeldung erforderlich. Nutze den geschützten
Browser-/VNC-Recovery-Pfad.
</div>
)}
{doctorStatus.lastError && (
<div className="col-span-2 break-words text-danger">
Letzter Fehler: {String(doctorStatus.lastError)}
</div>
)}
</div>
)}
</div>
)}
{isGooglePse && (
<Input
label={t("searchEngineIdLabel")}

View File

@@ -34,6 +34,9 @@ type FormData = QuotaScrapingFieldValues &
routingTags: string;
tag?: string;
validationModelId?: string;
tunnelId: string;
connectorName: string;
runtimeKey?: string;
};
type ProviderSpecificData = Record<string, unknown>;
@@ -104,6 +107,10 @@ export function buildAddProviderSpecificData(options: {
data.quotaPerUnit = parsedQuotaPerUnit;
}
}
if (provider === "chatgpt-web-codex") {
if (formData.tunnelId.trim()) data.tunnelId = formData.tunnelId.trim();
if (formData.connectorName.trim()) data.connectorName = formData.connectorName.trim();
}
return Object.keys(data).length > 0 ? data : undefined;
}
@@ -174,4 +181,8 @@ export function assignEditApiKeyProviderSpecificData(options: {
o.target.newApiAggregatorBalance = undefined;
o.target.quotaPerUnit = undefined;
}
if (o.provider === "chatgpt-web-codex") {
o.target.tunnelId = o.formData.tunnelId.trim() || undefined;
o.target.connectorName = o.formData.connectorName.trim() || undefined;
}
}

View File

@@ -34,6 +34,8 @@ import { resolveRequestRoutingTags } from "@/domain/tagRouter";
import { validateApiKeyRoutingTarget } from "@/shared/utils/apiKeyPolicy";
import { persistResponsesWsCallHistory } from "./history";
import { applyResponsesWsCompression } from "./compression";
import { getComboByName } from "@/lib/db/combos";
import { getComboModelString } from "@/lib/combos/steps";
const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
const executor = new CodexExecutor();
@@ -506,6 +508,17 @@ async function resolveCodexProxy(provider: string): Promise<string | undefined>
async function prepare(body: JsonRecord) {
const context = await resolveCodexRequestContext(body);
if ("error" in context) return context.error;
const combo = await getComboByName(context.requestedModel).catch(() => null);
if (combo) {
const models = Array.isArray(combo.models) ? combo.models : [];
if (models.some((model) => getComboModelString(model)?.startsWith("chatgpt-web-codex/"))) {
return jsonError(
426,
"responses_websocket_http_fallback",
"This Combo contains ChatGPT Web (Codex) and must use the HTTP/SSE Responses transport"
);
}
}
const upstream = await resolveCodexUpstreamContext(context);
if ("error" in upstream) return upstream.error;
const { responseBody, metadata, provider, model, credentials: refreshedCredentials } = upstream;

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getProviderConnectionById } from "@/lib/db/providers";
import { getChatGptWebCodexDoctorStatus } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { id } = await params;
const connection = await getProviderConnectionById(id);
if (!connection || connection.provider !== "chatgpt-web-codex") {
return NextResponse.json(
{ error: "ChatGPT Web (Codex) connection not found" },
{ status: 404 }
);
}
return NextResponse.json({ status: await getChatGptWebCodexDoctorStatus(connection) });
}

View File

@@ -29,6 +29,11 @@ import {
refreshConnectionRateLimits,
enableRateLimitProtection,
} from "@/../open-sse/services/rateLimitManager";
import {
finalizeValidatedChatGptWebCodexSecrets,
decodeChatGptWebCodexSecrets,
encodeChatGptWebCodexSecrets,
} from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts";
function normalizeCodexLimitPolicy(
incoming: unknown,
@@ -156,7 +161,38 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
if (globalPriority !== undefined) updateData.globalPriority = globalPriority;
if (defaultModel !== undefined) updateData.defaultModel = defaultModel;
if (isActive !== undefined) updateData.isActive = isActive;
if (apiKey && existing.authType === "apikey") updateData.apiKey = apiKey;
if (apiKey && existing.authType === "apikey") {
if (existing.provider === "chatgpt-web-codex") {
const validationId =
incomingPsd && typeof incomingPsd.validationId === "string"
? incomingPsd.validationId
: "";
try {
const incomingSecrets = decodeChatGptWebCodexSecrets(apiKey);
const existingSecrets = decodeChatGptWebCodexSecrets(existing.apiKey || "");
const encoded = encodeChatGptWebCodexSecrets({
cookie: incomingSecrets.cookie,
runtimeKey: incomingSecrets.runtimeKey || existingSecrets.runtimeKey,
});
updateData.apiKey = finalizeValidatedChatGptWebCodexSecrets(
encoded,
validationId
).encodedCredential;
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Die ChatGPT-Browserprüfung konnte nicht abgeschlossen werden.",
},
{ status: 400 }
);
}
} else {
updateData.apiKey = apiKey;
}
}
if (testStatus !== undefined) updateData.testStatus = testStatus;
if (lastError !== undefined) updateData.lastError = lastError;
if (lastErrorAt !== undefined) updateData.lastErrorAt = lastErrorAt;
@@ -205,6 +241,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
? existing.providerSpecificData
: {};
const mergedPsd = { ...existingPsd, ...incomingPsd };
delete mergedPsd.validationId;
delete mergedPsd.runtimeKey;
// Deep-merge and normalize Codex limit policy defaults.
if (existing.provider === "codex") {

View File

@@ -39,6 +39,7 @@ import {
fetchModelSyncInternal,
getModelSyncInternalBaseUrl,
} from "@/shared/services/modelSyncScheduler";
import { finalizeValidatedChatGptWebCodexSecrets } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts";
// GET /api/providers - List all connections
export async function GET(request: Request) {
@@ -118,6 +119,7 @@ export async function POST(request: Request) {
}
let providerSpecificData = incomingPsd || null;
let persistedApiKey = apiKey;
const allowMultipleCompatibleConnections =
process.env.ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE === "true";
@@ -125,6 +127,29 @@ export async function POST(request: Request) {
providerSpecificData = normalizeQoderPatProviderData(providerSpecificData || {});
}
if (provider === "chatgpt-web-codex") {
const validationId =
providerSpecificData && typeof providerSpecificData.validationId === "string"
? providerSpecificData.validationId
: "";
try {
const finalized = finalizeValidatedChatGptWebCodexSecrets(apiKey || "", validationId);
persistedApiKey = finalized.encodedCredential;
providerSpecificData = { ...(providerSpecificData || {}) };
delete providerSpecificData.validationId;
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Die ChatGPT-Browserprüfung konnte nicht abgeschlossen werden.",
},
{ status: 400 }
);
}
}
if (isOpenAICompatibleProvider(provider)) {
const node: any = await resolveProviderNodeForConnection(provider);
if (!node) {
@@ -177,7 +202,7 @@ export async function POST(request: Request) {
provider,
authType: "apikey",
name,
apiKey,
apiKey: persistedApiKey,
priority: priority || 1,
globalPriority: globalPriority || null,
defaultModel: defaultModel || null,

View File

@@ -57,6 +57,9 @@ export async function POST(request) {
baseUrl: bodyBaseUrl,
region,
cx,
runtimeKey,
tunnelId,
connectorName,
} = validation.data;
let providerSpecificData: any = { validationModelId };
@@ -72,6 +75,9 @@ export async function POST(request) {
if (cx) {
providerSpecificData.cx = cx;
}
if (runtimeKey) providerSpecificData.runtimeKey = runtimeKey;
if (tunnelId) providerSpecificData.tunnelId = tunnelId;
if (connectorName) providerSpecificData.connectorName = connectorName;
if (isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider)) {
const node: any = await getProviderNodeById(provider);
@@ -151,6 +157,8 @@ export async function POST(request) {
error: result.valid ? null : result.error || "Invalid API key",
warning: result.warning || null,
method: result.method || null,
capabilities: result.capabilities || null,
providerSpecificData: result.providerSpecificData || null,
});
} catch (error) {
console.log("Error validating API key:", error);

View File

@@ -90,6 +90,9 @@ export async function deleteProviderConnection(id: string) {
await _cleanupDeletedComboConnectionRefs(id);
await _cleanupDeletedLKGPConnectionRefs(id);
void import("@omniroute/open-sse/services/combo/nativeCodexTurnPin.ts")
.then((module) => module.revokeNativeCodexTurnPinsForConnection(id))
.catch(() => {});
removeConnectionHealth(id);
removeConnectionIndex(id);
@@ -132,6 +135,9 @@ export async function deleteProviderConnections(ids: string[]): Promise<number>
for (const id of ids) {
removeConnectionHealth(id);
removeConnectionIndex(id);
void import("@omniroute/open-sse/services/combo/nativeCodexTurnPin.ts")
.then((module) => module.revokeNativeCodexTurnPinsForConnection(id))
.catch(() => {});
}
backupDbFile("pre-write");
@@ -169,6 +175,9 @@ export async function deleteProviderConnectionsByProvider(providerId: string) {
for (const connectionId of connectionIds) {
removeConnectionHealth(connectionId);
removeConnectionIndex(connectionId);
void import("@omniroute/open-sse/services/combo/nativeCodexTurnPin.ts")
.then((module) => module.revokeNativeCodexTurnPinsForConnection(connectionId))
.catch(() => {});
}
backupDbFile("pre-write");
invalidateDbCache("connections");

View File

@@ -131,6 +131,15 @@ async function cleanup(): Promise<void> {
} catch {
/* feature unused / docker missing */
}
try {
const { stopChatGptWebCodexRuntime } =
await import("@omniroute/open-sse/executors/chatgpt-web-codex/runtime.ts");
await stopChatGptWebCodexRuntime();
console.log("[Shutdown] ChatGPT Web (Codex) runtime stopped.");
} catch {
/* feature unused */
}
} catch (err) {
console.error("[Shutdown] Error during cleanup:", (err as Error).message);
}

View File

@@ -309,6 +309,9 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec
delete sanitized.ollamaCloudUsageCookie;
delete sanitized.ollamaCloudCookie;
delete sanitized.usageCookie;
delete sanitized.runtimeKey;
delete sanitized.validationId;
if (sanitized.browserCdpEndpoint) sanitized.browserCdpEndpoint = "configured";
return sanitized;
}

View File

@@ -80,6 +80,7 @@ import {
validateNousResearchProvider,
validatePoeProvider,
} from "./validation/audioMiscProviders";
import { validateChatGptWebCodexProvider } from "./validation/chatgptWebCodex";
import { validateSearchProvider, SEARCH_VALIDATOR_CONFIGS } from "./validation/searchProviders";
import {
validateClarifaiProvider,
@@ -252,6 +253,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
"qwen-web": validateQwenWebProvider,
"kimi-web": validateKimiWebProvider,
"chatgpt-web": validateChatGptWebProvider,
"chatgpt-web-codex": validateChatGptWebCodexProvider,
"perplexity-web": validatePerplexityWebProvider,
"blackbox-web": validateBlackboxWebProvider,
"muse-spark-web": validateMuseSparkWebProvider,

View File

@@ -0,0 +1,111 @@
import { randomBytes } from "node:crypto";
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,
} from "@omniroute/open-sse/executors/chatgpt-web-codex/storageState.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
export async function validateChatGptWebCodexProvider({
apiKey,
providerSpecificData = {},
}: {
apiKey?: string;
providerSpecificData?: Record<string, unknown>;
}) {
try {
const secrets = decodeChatGptWebCodexSecrets(String(apiKey || ""));
if (!secrets.cookie) {
return {
valid: false,
error: "Für die Browserprüfung ist ein frischer vollständiger ChatGPT-Cookie erforderlich.",
};
}
const runtimeKey =
typeof providerSpecificData.runtimeKey === "string"
? providerSpecificData.runtimeKey.trim()
: secrets.runtimeKey || process.env.CHATGPT_WEB_CODEX_RUNTIME_KEY?.trim();
const tunnelId =
typeof providerSpecificData.tunnelId === "string"
? providerSpecificData.tunnelId.trim()
: process.env.CHATGPT_WEB_CODEX_TUNNEL_ID?.trim() || "";
const connectorName =
typeof providerSpecificData.connectorName === "string"
? providerSpecificData.connectorName.trim()
: process.env.CHATGPT_WEB_CODEX_CONNECTOR_NAME?.trim() || "";
if (!connectorName) {
return {
valid: false,
error: "Der ChatGPT-Custom-Connector ist erforderlich.",
};
}
const tunnelConfigured = Boolean(runtimeKey || tunnelId);
if (tunnelConfigured && (!runtimeKey || !/^tunnel_[a-f0-9]{32}$/.test(tunnelId))) {
return {
valid: false,
error: "Tunnel-ID und Runtime-Key müssen gemeinsam gültig konfiguriert werden.",
};
}
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:
"Kein unterstütztes Chrome oder Chromium gefunden. Installiere Chromium oder konfiguriere den Browserpfad.",
};
}
const validationId = `validation-${randomBytes(12).toString("hex")}`;
const paths = connectionRuntimePaths(validationId);
ensureConnectionStorageState(validationId, secrets.cookie);
const capabilities = await inspectBrowserLoginCapabilities({
mode: "browser-only",
appName: "OmniRoute Codex",
...(chromeExecutablePath ? { chromeExecutablePath } : {}),
...(cdpEndpoint ? { cdpEndpoint } : {}),
storageStatePath: paths.storageStatePath,
brokerSocketPath: paths.brokerSocketPath,
headed: false,
proAvailable: false,
autoApproveToolCalls: false,
});
return {
valid: true,
error: null,
method: "headless-browser",
capabilities: {
browser: "ready",
storageState: "verified",
login: "authenticated",
temporaryChats: "ready",
proAvailable: capabilities.proAvailable,
},
providerSpecificData: {
proAvailable: capabilities.proAvailable,
browserVerified: true,
...(chromeExecutablePath ? { chromeExecutablePath } : {}),
...(typeof providerSpecificData.tunnelId === "string" &&
providerSpecificData.tunnelId.trim()
? { tunnelId: providerSpecificData.tunnelId.trim() }
: {}),
...(typeof providerSpecificData.connectorName === "string" &&
providerSpecificData.connectorName.trim()
? { connectorName: providerSpecificData.connectorName.trim() }
: {}),
validationId,
},
};
} catch (error) {
return {
valid: false,
error: sanitizeErrorMessage(error instanceof Error ? error.message : error),
};
}
}

View File

@@ -89,8 +89,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
* gated, matching the `/login` precedent's narrow-scoping rationale.
*/
export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray<RegExp> = [
/^\/api\/providers\/[^/]+\/login\/?$/,
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/,
/^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/,
];
// `SPAWN_CAPABLE_PREFIXES` / `SPAWN_CAPABLE_PATTERNS` (the spawn-capable

View File

@@ -329,6 +329,7 @@ const LOBE_PROVIDER_ALIASES = {
"black-forest-labs": "Bfl",
cerebras: "Cerebras",
"chatgpt-web": "OpenAI",
"chatgpt-web-codex": "OpenAI",
claude: "ClaudeCode",
"claude-web": "Claude",
cline: "Cline",

View File

@@ -3,6 +3,20 @@
* Pure data literal; re-exported by the providers.ts barrel. No behavior change.
*/
export const WEB_COOKIE_PROVIDERS = {
"chatgpt-web-codex": {
id: "chatgpt-web-codex",
alias: "cgpt-codex",
name: "ChatGPT Web (Codex)",
icon: "terminal",
color: "#10A37F",
textIcon: "CC",
website: "https://chatgpt.com",
authHint:
"Paste the full ChatGPT Cookie header. OmniRoute verifies it in an isolated headless browser profile.",
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
toolCalling: "native",
},
"chatgpt-web": {
id: "chatgpt-web",
alias: "cgpt-web",

View File

@@ -25,6 +25,13 @@ export type WebSessionCredentialRequirement =
};
export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
"chatgpt-web-codex": {
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)",

View File

@@ -611,6 +611,9 @@ export const validateProviderApiKeySchema = z
baseUrl: z.string().trim().url().optional(),
region: z.string().trim().max(64).optional(),
cx: z.string().trim().max(500).optional(),
runtimeKey: z.string().trim().max(65_536).optional(),
tunnelId: z.string().trim().max(128).optional(),
connectorName: z.string().trim().max(200).optional(),
})
.superRefine((data, ctx) => {
if (data.provider === "google-pse-search" && !data.cx) {

View File

@@ -32,6 +32,7 @@ import { handleComboChat, shouldSkipConnDisable } from "@omniroute/open-sse/serv
import type { SingleModelTarget } from "@omniroute/open-sse/services/combo/types.ts";
import { mergeAbortSignals } from "@omniroute/open-sse/executors/base.ts";
import { resolveRequestAutoControls } from "@omniroute/open-sse/services/autoCombo/requestControls.ts";
import { isVerifiedNativeCodexRequest } from "@omniroute/open-sse/config/codexIdentity.ts";
import { resolveComboConfig } from "@omniroute/open-sse/services/comboConfig.ts";
import { injectHandoffIntoBody } from "@omniroute/open-sse/services/contextHandoff.ts";
import {
@@ -822,7 +823,8 @@ async function handleChatImplementation(
combo,
clientManagedResponsesContext:
sourceFormat === "openai-responses" &&
new URL(request.url).pathname.split("/").includes("responses"),
new URL(request.url).pathname.split("/").includes("responses") &&
isVerifiedNativeCodexRequest(body, request.headers),
handleSingleModel: (
b: any,
m: string,
@@ -1102,7 +1104,8 @@ async function handleSingleModelChat(
detectFormatFromEndpoint(body, clientRawRequest?.endpoint || "") === "openai-responses" &&
String(clientRawRequest?.endpoint || "")
.split("/")
.includes("responses"),
.includes("responses") &&
isVerifiedNativeCodexRequest(body, clientRawRequest?.headers),
handleSingleModel: (b: Record<string, unknown>, m: string, target?: SingleModelTarget) => {
const resolvedTarget = target && "kind" in target ? target : null;
return handleSingleModelChat(