mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 19:22:32 +03:00
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:
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
19
src/app/api/providers/[id]/chatgpt-web-codex-doctor/route.ts
Normal file
19
src/app/api/providers/[id]/chatgpt-web-codex-doctor/route.ts
Normal 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) });
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
111
src/lib/providers/validation/chatgptWebCodex.ts
Normal file
111
src/lib/providers/validation/chatgptWebCodex.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user