mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat(xai): add xAI Grok provider with OAuth + API key auth + image
Adapted from PR #1286 (mugnimaestra/feat/xai-grok-provider) to match existing app architecture. Includes: - OAuth 2.0 with PKCE on loopback port 56121 (Grok Build) - API key auth path (console.x.ai) - Token refresh wiring (open-sse + sse tokenRefresh) - Dashboard OAuth modal with fixed-port flow + manual code fallback - Provider registry entries (OAuth + API key) - xAI image generation via OpenAI-compatible adapter (grok-2-image-1212 model, no size/quality/style params) Excludes (intentionally, to match app patterns): - Custom xAI Responses executor (DefaultExecutor handles /chat/completions) - xAI-specific translators (app uses OpenAI as intermediate format) - Image edits (not supported by current imageGenerationCore) - Video endpoints (app has no video subsystem yet) - CLI xai-login command Refs decolua#1286 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
decolua
co-authored by
Cursor
parent
0654d7bb35
commit
d976f4cc87
@@ -66,6 +66,25 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
}
|
||||
}, [authData, provider, onSuccess]);
|
||||
|
||||
const completeXaiManualCode = useCallback(async (code) => {
|
||||
if (!authData?.state) return;
|
||||
try {
|
||||
const res = await fetch("/api/oauth/xai/manual-code", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, state: authData.state }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
|
||||
setStep("success");
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setStep("error");
|
||||
}
|
||||
}, [authData, onSuccess]);
|
||||
|
||||
// Poll for device code token
|
||||
const startPolling = useCallback(async (deviceCode, codeVerifier, interval, extraData) => {
|
||||
pollingAbortRef.current = false;
|
||||
@@ -175,6 +194,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
let redirectUri;
|
||||
if (provider === "codex") {
|
||||
redirectUri = "http://localhost:1455/auth/callback";
|
||||
} else if (provider === "xai") {
|
||||
redirectUri = "http://127.0.0.1:56121/callback";
|
||||
} else {
|
||||
redirectUri = `http://localhost:${appPort}/callback`;
|
||||
}
|
||||
@@ -208,7 +229,30 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
}
|
||||
}
|
||||
|
||||
setAuthData({ ...data, redirectUri, codexServerSide });
|
||||
// xAI: same fixed-port server-side proxy pattern as codex (port 56121)
|
||||
let xaiProxyActive = false;
|
||||
let xaiServerSide = false;
|
||||
if (provider === "xai") {
|
||||
try {
|
||||
const proxyUrl = new URL(`/api/oauth/xai/start-proxy`, window.location.origin);
|
||||
proxyUrl.searchParams.set("app_port", appPort);
|
||||
proxyUrl.searchParams.set("state", data.state);
|
||||
proxyUrl.searchParams.set("code_verifier", data.codeVerifier);
|
||||
proxyUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
const proxyRes = await fetch(proxyUrl.toString());
|
||||
const proxyData = await proxyRes.json();
|
||||
xaiProxyActive = proxyData.success;
|
||||
xaiServerSide = !!proxyData.serverSide;
|
||||
if (!xaiProxyActive && proxyData.reason === "port_busy") {
|
||||
throw new Error("Port 56121 in use; close the conflicting process and retry");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.message) throw e;
|
||||
xaiProxyActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
setAuthData({ ...data, redirectUri, codexServerSide, xaiServerSide });
|
||||
|
||||
if (provider === "codex" && codexProxyActive) {
|
||||
// Proxy active: callback will be handled server-side (auto-exchange) or via channels (fallback)
|
||||
@@ -217,12 +261,18 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
if (!popupRef.current) {
|
||||
setStep("input");
|
||||
}
|
||||
} else if (!isLocalhost || provider === "codex") {
|
||||
} else if (provider === "xai" && xaiProxyActive) {
|
||||
setStep("waiting");
|
||||
popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700");
|
||||
if (!popupRef.current) {
|
||||
setStep("input");
|
||||
}
|
||||
} else if (!isLocalhost || provider === "codex" || provider === "xai") {
|
||||
// Non-localhost or proxy failed: manual input mode
|
||||
setStep("input");
|
||||
window.open(data.authUrl, "_blank");
|
||||
} else {
|
||||
// Localhost (non-Codex): Open popup and wait for message
|
||||
// Localhost (non-Codex/xAI): Open popup and wait for message
|
||||
setStep("waiting");
|
||||
popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700");
|
||||
if (!popupRef.current) {
|
||||
@@ -251,13 +301,16 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
pollingAbortRef.current = true;
|
||||
if (provider === "codex") {
|
||||
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
|
||||
} else if (provider === "xai") {
|
||||
fetch("/api/oauth/xai/stop-proxy").catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [isOpen, provider, startOAuthFlow]);
|
||||
|
||||
// Codex server-side mode: poll status (proxy auto-exchanges + saves DB)
|
||||
// Fixed-port server-side mode: poll status (proxy auto-exchanges + saves DB)
|
||||
useEffect(() => {
|
||||
if (!authData?.codexServerSide || !authData?.state) return;
|
||||
const pollProvider = authData?.codexServerSide ? "codex" : authData?.xaiServerSide ? "xai" : null;
|
||||
if (!pollProvider || !authData?.state) return;
|
||||
if (callbackProcessedRef.current) return;
|
||||
let cancelled = false;
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
@@ -268,7 +321,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
if (cancelled || callbackProcessedRef.current) return;
|
||||
attempts += 1;
|
||||
try {
|
||||
const res = await fetch(`/api/oauth/codex/poll-status?state=${encodeURIComponent(authData.state)}`);
|
||||
const res = await fetch(`/api/oauth/${pollProvider}/poll-status?state=${encodeURIComponent(authData.state)}`);
|
||||
const data = await res.json();
|
||||
if (cancelled || callbackProcessedRef.current) return;
|
||||
if (data.status === "done") {
|
||||
@@ -392,6 +445,11 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === "xai" && input && !input.includes("://") && !input.includes("?") && !input.includes("code=")) {
|
||||
await completeXaiManualCode(input);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(input);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
@@ -402,7 +460,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error("No authorization code found in URL");
|
||||
throw new Error(provider === "xai" ? "Paste the callback URL or copied xAI code" : "No authorization code found in URL");
|
||||
}
|
||||
|
||||
await exchangeTokens(code, state);
|
||||
@@ -416,15 +474,22 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
const handleClose = useCallback(() => {
|
||||
if (provider === "codex") {
|
||||
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
|
||||
} else if (provider === "xai") {
|
||||
fetch("/api/oauth/xai/stop-proxy").catch(() => {});
|
||||
}
|
||||
onClose();
|
||||
}, [onClose, provider]);
|
||||
|
||||
if (!provider || !providerInfo) return null;
|
||||
const isXaiProvider = provider === "xai";
|
||||
const deviceLoginUrl = deviceData?.verification_uri_complete || deviceData?.verification_uri || "";
|
||||
const modalTitle = isXaiProvider ? "Connect Grok Build OAuth" : `Connect ${providerInfo.name}`;
|
||||
const manualPlaceholder = isXaiProvider
|
||||
? "http://127.0.0.1:56121/callback?code=... or copied code"
|
||||
: placeholderUrl;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={`Connect ${providerInfo.name}`} onClose={handleClose} size="lg">
|
||||
<Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Waiting + Manual Input combined (non-device-code) */}
|
||||
{(step === "waiting" || step === "input") && !isDeviceCode && (
|
||||
@@ -434,7 +499,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
<span className="material-symbols-outlined text-base text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
<span className="text-sm">Waiting for popup authorization…</span>
|
||||
<span className="text-sm">
|
||||
{isXaiProvider ? "Waiting for Grok Build OAuth…" : "Waiting for popup authorization…"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
@@ -447,7 +514,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
{/* Option B: Manual paste */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Step 1: Open this URL in your browser</p>
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Step 1: Open this {isXaiProvider ? "Grok Build OAuth URL" : "URL"} in your browser
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input value={authData?.authUrl || ""} readOnly className="flex-1 font-mono text-xs" />
|
||||
<Button variant="secondary" icon={copied === "auth_url" ? "check" : "content_copy"} onClick={() => copy(authData?.authUrl, "auth_url")} disabled={!authData?.authUrl}>
|
||||
@@ -457,14 +526,18 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Step 2: Paste the callback URL here</p>
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Step 2: Paste the {provider === "xai" ? "callback URL or copied code" : "callback URL"} here
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
After authorization, copy the full URL from your browser.
|
||||
{provider === "xai"
|
||||
? "If xAI shows a code instead of redirecting, paste that code here."
|
||||
: "After authorization, copy the full URL from your browser."}
|
||||
</p>
|
||||
<Input
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder={placeholderUrl}
|
||||
placeholder={manualPlaceholder}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user