mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +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>
|
||||
|
||||
@@ -60,6 +60,7 @@ export const OAUTH_PROVIDERS = {
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", deprecated: true, deprecationNotice: RISK_NOTICE, thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." }, website: "https://chatgpt.com/codex", notice: { signupUrl: "https://chatgpt.com/codex" } },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", deprecated: true, deprecationNotice: RISK_NOTICE, serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] }, website: "https://github.com/features/copilot", notice: { signupUrl: "https://github.com/features/copilot" } },
|
||||
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA", website: "https://cursor.com", notice: { signupUrl: "https://cursor.com" } },
|
||||
xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", notice: { apiKeyUrl: "https://console.x.ai", signupUrl: "https://x.ai" }, serviceKinds: ["llm", "imageToText", "webSearch", "image"], searchViaChat: { defaultModel: "grok-4.20-reasoning", pricingUrl: "https://x.ai/api#pricing" }, authModes: ["oauth", "apikey"], hasOAuth: true },
|
||||
// "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
|
||||
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC", website: "https://kilocode.ai", notice: { signupUrl: "https://kilocode.ai" } },
|
||||
cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL", website: "https://cline.bot", notice: { signupUrl: "https://cline.bot" } },
|
||||
@@ -86,7 +87,7 @@ export const APIKEY_PROVIDERS = {
|
||||
deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com", notice: { apiKeyUrl: "https://platform.deepseek.com/api_keys" } },
|
||||
commandcode: { id: "commandcode", alias: "cmc", name: "Command Code", icon: "smart_toy", color: "#000000", textIcon: "CC", website: "https://commandcode.ai", notice: { text: "Use your CommandCode CLI API key (starts with user_...) from ~/.commandcode/auth.json or commandcode.ai/studio.", apiKeyUrl: "https://commandcode.ai/studio" } },
|
||||
groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com", notice: { apiKeyUrl: "https://console.groq.com/keys" }, serviceKinds: ["llm", "imageToText", "stt"], sttConfig: { baseUrl: "https://api.groq.com/openai/v1/audio/transcriptions", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "whisper-large-v3", name: "Whisper Large v3" }, { id: "whisper-large-v3-turbo", name: "Whisper Large v3 Turbo" }, { id: "distil-whisper-large-v3-en", name: "Distil Whisper Large v3 EN" }] } },
|
||||
xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", notice: { apiKeyUrl: "https://console.x.ai" }, serviceKinds: ["llm", "imageToText", "webSearch"], searchViaChat: { defaultModel: "grok-4.20-reasoning", pricingUrl: "https://x.ai/api#pricing" } },
|
||||
xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", notice: { apiKeyUrl: "https://console.x.ai" }, serviceKinds: ["llm", "imageToText", "webSearch", "image"], searchViaChat: { defaultModel: "grok-4.20-reasoning", pricingUrl: "https://x.ai/api#pricing" }, authModes: ["oauth", "apikey"], hasOAuth: true },
|
||||
mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai", notice: { apiKeyUrl: "https://console.mistral.ai/api-keys" }, serviceKinds: ["llm", "imageToText", "embedding"], embeddingConfig: { baseUrl: "https://api.mistral.ai/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "mistral-embed", name: "Mistral Embed", dimensions: 1024 }] } },
|
||||
perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai", notice: { apiKeyUrl: "https://www.perplexity.ai/settings/api" }, serviceKinds: ["llm", "webSearch"], searchConfig: { baseUrl: "https://api.perplexity.ai/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 } },
|
||||
together: { id: "together", alias: "together", name: "Together AI", icon: "group_work", color: "#0F6FFF", textIcon: "TG", website: "https://www.together.ai", notice: { apiKeyUrl: "https://api.together.xyz/settings/api-keys" }, serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://api.together.xyz/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "BAAI/bge-large-en-v1.5", name: "BGE Large EN v1.5", dimensions: 1024 }, { id: "togethercomputer/m2-bert-80M-8k-retrieval", name: "M2 BERT 80M 8K", dimensions: 768 }] } },
|
||||
|
||||
Reference in New Issue
Block a user