Add ChatGPT Web setup and doctor UI

This commit is contained in:
Jan Leon
2026-07-30 06:56:09 +02:00
parent a0f9ad852d
commit 5ead198eb4
4 changed files with 231 additions and 3 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";
@@ -130,9 +131,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);
@@ -233,12 +241,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);
@@ -285,6 +299,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);
@@ -300,6 +315,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();
@@ -308,6 +328,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");
@@ -339,14 +366,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);
@@ -736,6 +777,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[]>([]);
@@ -196,6 +204,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";
@@ -334,6 +343,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;
@@ -359,6 +372,7 @@ export default function EditConnectionModal({
);
setTestResult(null);
setValidationResult(null);
setValidatedProviderSpecificData(undefined);
setSaveError(null);
}
}, [
@@ -422,10 +436,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 {
@@ -502,7 +526,7 @@ export default function EditConnectionModal({
}
if (!isOAuth && formData.apiKey) {
updates.apiKey = formData.apiKey;
let validationPsd = validatedProviderSpecificData;
let isValid = validationResult === "success";
if (!isValid) {
try {
@@ -519,11 +543,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 {
@@ -531,6 +568,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;
@@ -543,6 +587,7 @@ export default function EditConnectionModal({
if (!isOAuth) {
updates.providerSpecificData = {
...(connection.providerSpecificData || {}),
...(validationPsd || {}),
};
assignEditApiKeyProviderSpecificData({
provider,
@@ -872,6 +917,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

@@ -32,6 +32,9 @@ type FormData = QuotaScrapingFieldValues &
routingTags: string;
tag?: string;
validationModelId?: string;
tunnelId: string;
connectorName: string;
runtimeKey?: string;
};
type ProviderSpecificData = Record<string, unknown>;
@@ -92,6 +95,10 @@ export function buildAddProviderSpecificData(options: {
assignGlmTeamQuotaProviderData(isGlm, formData, data);
} else if (isCloudflare && formData.accountId.trim()) data.accountId = formData.accountId.trim();
if (isCcCompatible) assignCcCompatibleRequestDefaults(data, formData);
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;
}
@@ -147,4 +154,8 @@ export function assignEditApiKeyProviderSpecificData(options: {
o.formData
);
}
if (o.provider === "chatgpt-web-codex") {
o.target.tunnelId = o.formData.tunnelId.trim() || undefined;
o.target.connectorName = o.formData.connectorName.trim() || undefined;
}
}

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",