feat: Add Grok Build (xAI) provider with OAuth import-token flow (#5020)

Integrated into release/v3.8.38 (rebased on tip; Hard Rule #11 fix — Grok public client_id now via resolvePublicCred(grok_id), 3 literals removed; grok-oauth 7/7 + check:public-creds green)
This commit is contained in:
fulorgnas
2026-06-27 04:31:40 +03:00
committed by GitHub
parent 9e2c2817da
commit 2eefe2f76d
19 changed files with 756 additions and 51 deletions

View File

@@ -10,6 +10,7 @@ _In development — bullets added per PR; finalized at release._
### ✨ New Features
- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow**`grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas)
- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic)
- **feat(providers): add ZenMux Free (session-cookie free-tier) provider**`zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil)
- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij)

View File

@@ -167,6 +167,7 @@ import { kiroProvider } from "./registry/kiro/index.ts";
import { openadapterProvider } from "./registry/openadapter/index.ts";
import { ditProvider } from "./registry/dit/index.ts";
import { tokenrouterProvider } from "./registry/tokenrouter/index.ts";
import { grok_cliProvider } from "./registry/grok-cli/index.ts";
import { codebuddy_cnProvider } from "./registry/codebuddy-cn/index.ts";
import { pioneerProvider } from "./registry/pioneer/index.ts";
import { zenmux_freeProvider } from "./registry/zenmux-free/index.ts";
@@ -339,6 +340,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
openadapter: openadapterProvider,
dit: ditProvider,
tokenrouter: tokenrouterProvider,
"grok-cli": grok_cliProvider,
"codebuddy-cn": codebuddy_cnProvider,
pioneer: pioneerProvider,
"zenmux-free": zenmux_freeProvider,

View File

@@ -0,0 +1,22 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred } from "../../shared.ts";
export const grok_cliProvider: RegistryEntry = {
id: "grok-cli",
alias: "gc",
format: "openai",
executor: "grok-cli",
baseUrl: "https://cli-chat-proxy.grok.com/v1/chat/completions",
authType: "oauth",
authHeader: "bearer",
passthroughModels: true,
models: [
{ id: "grok-build", name: "Grok Build", contextLength: 128000 },
{ id: "grok-composer-2.5-fast", name: "Grok Composer 2.5 Fast", contextLength: 128000 },
],
oauth: {
clientIdEnv: "GROK_OAUTH_CLIENT_ID",
clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
tokenUrl: "https://auth.x.ai/oauth2/token",
},
};

View File

@@ -0,0 +1,244 @@
/**
* GrokCliExecutor — Grok Build Provider
*
* Routes requests through Grok's chat proxy endpoint using OAuth authentication.
* Uses Node.js https module directly with IPv4 forced to bypass Cloudflare blocking.
* Supports automatic token refresh via refresh_token.
*/
import {
BaseExecutor,
type ExecuteInput,
type ExecutorLog,
type ProviderCredentials,
} from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
import { resolvePublicCred } from "../utils/publicCreds.ts";
import https from "node:https";
const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token";
const REQUEST_TIMEOUT_MS = 60_000;
export class GrokCliExecutor extends BaseExecutor {
constructor() {
super("grok-cli", PROVIDERS["grok-cli"]);
}
async execute(input: ExecuteInput) {
const { model, body, stream, credentials, signal } = input;
const url = this.buildUrl(model, stream, 0, credentials);
const headers = this.buildHeaders(credentials, stream);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const bodyStr = JSON.stringify(transformedBody);
const response = await this.nativePost(url, headers, bodyStr, signal);
return { response, url, headers, transformedBody };
}
async refreshCredentials(
credentials: ProviderCredentials,
log?: ExecutorLog | null
): Promise<Partial<ProviderCredentials> | null> {
if (!credentials?.refreshToken) {
log?.warn?.("TOKEN_REFRESH", "Grok Build: no refresh token available");
return null;
}
const clientId = resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID");
try {
const body = new URLSearchParams({
grant_type: "refresh_token",
client_id: clientId,
refresh_token: credentials.refreshToken,
});
const result = await this.nativeHttpsPost(
GROK_TOKEN_URL,
{
"Content-Type": "application/x-www-form-urlencoded",
},
body.toString(),
10_000
);
if (result.status !== 200) {
log?.warn?.("TOKEN_REFRESH", `Grok Build: refresh failed with status ${result.status}`);
return null;
}
const data = JSON.parse(result.body);
if (!data.access_token) {
log?.warn?.("TOKEN_REFRESH", "Grok Build: no access_token in refresh response");
return null;
}
const expiresIn = data.expires_in || 21600;
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
log?.info?.("TOKEN_REFRESH", `Grok Build: token refreshed, expires ${expiresAt}`);
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || credentials.refreshToken,
expiresAt,
};
} catch (error) {
log?.warn?.(
"TOKEN_REFRESH",
`Grok Build: refresh error: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}
private nativeHttpsPost(
url: string,
headers: Record<string, string>,
bodyStr: string,
timeoutMs = 10_000
): Promise<{ status: number; body: string }> {
const urlObj = new URL(url);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => req.destroy(new Error("Timeout")), timeoutMs);
const req = https.request(
{
hostname: urlObj.hostname,
port: 443,
path: urlObj.pathname + urlObj.search,
method: "POST",
family: 4,
headers: {
...headers,
"Content-Length": Buffer.byteLength(bodyStr),
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
clearTimeout(timer);
resolve({
status: res.statusCode ?? 500,
body: Buffer.concat(chunks).toString("utf-8"),
});
});
}
);
req.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
req.write(bodyStr);
req.end();
});
}
private nativePost(
url: string,
headers: Record<string, string>,
bodyStr: string,
signal?: AbortSignal | null
): Promise<Response> {
const urlObj = new URL(url);
if (signal?.aborted) {
return Promise.reject(new Error("Aborted"));
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => req.destroy(new Error("Timeout")), REQUEST_TIMEOUT_MS);
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
const req = https.request(
{
hostname: urlObj.hostname,
port: 443,
path: urlObj.pathname + urlObj.search,
method: "POST",
family: 4,
headers: {
...headers,
"Content-Length": Buffer.byteLength(bodyStr),
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
settle(() => {
const responseBody = Buffer.concat(chunks).toString("utf-8");
const responseHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(res.headers)) {
if (typeof value === "string") responseHeaders[key] = value;
else if (Array.isArray(value)) responseHeaders[key] = value.join(", ");
}
resolve(
new Response(responseBody, {
status: res.statusCode ?? 500,
headers: responseHeaders,
})
);
});
});
}
);
if (signal) {
const onAbort = () => settle(() => reject(new Error("Aborted")));
signal.addEventListener("abort", onAbort, { once: true });
// Clean up listener when request finishes naturally
req.on("close", () => signal.removeEventListener("abort", onAbort));
}
req.on("error", (err) => settle(() => reject(err)));
req.write(bodyStr);
req.end();
});
}
buildHeaders(credentials: ProviderCredentials, stream = true) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
} else if (credentials.apiKey) {
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
}
headers["Accept"] = stream ? "text/event-stream" : "application/json";
headers["x-grok-client-version"] = "0.2.64";
headers["x-grok-client-identifier"] = "grok_cli_rs";
headers["User-Agent"] = "grok-cli/0.2.64 (Windows 10.0.26200; x64)";
return headers;
}
transformRequest(
model: string,
body: unknown,
stream: boolean,
_credentials: ProviderCredentials
) {
const transformed =
body && typeof body === "object" ? { ...(body as Record<string, unknown>) } : {};
if (!transformed.model) {
transformed.model = model || "grok-composer-2.5-fast";
}
transformed.stream = !!stream;
return transformed;
}
}

View File

@@ -47,14 +47,16 @@ import { V0VercelWebExecutor } from "./v0-vercel-web.ts";
import { KimiWebExecutor } from "./kimi-web.ts";
import { DoubaoWebExecutor } from "./doubao-web.ts";
import { QwenWebExecutor } from "./qwen-web.ts";
import { KimiExecutor } from "./kimi.ts"
import { KimiExecutor } from "./kimi.ts";
import { TheOldLlmExecutor } from "./theoldllm.ts";
import { ChipotleExecutor } from "./chipotle.ts";
import { LMArenaExecutor } from "./lmarena.ts";
import { MimocodeExecutor } from "./mimocode.ts";
import { GrokCliExecutor } from "./grok-cli.ts";
import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts";
import { ZenmuxFreeExecutor } from "./zenmux-free.ts";
const executors = {
antigravity: new AntigravityExecutor(),
agy: new AntigravityExecutor(),
@@ -150,6 +152,8 @@ const executors = {
lma: new LMArenaExecutor(), // Alias
mimocode: new MimocodeExecutor(),
mcode: new MimocodeExecutor(), // Alias
"grok-cli": new GrokCliExecutor(),
gc: new GrokCliExecutor(), // Alias
"codebuddy-cn": new CodeBuddyCnExecutor(),
cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn
"zenmux-free": new ZenmuxFreeExecutor(),
@@ -214,5 +218,6 @@ export { TheOldLlmExecutor } from "./theoldllm.ts";
export { ChipotleExecutor } from "./chipotle.ts";
export { LMArenaExecutor } from "./lmarena.ts";
export { MimocodeExecutor } from "./mimocode.ts";
export { GrokCliExecutor } from "./grok-cli.ts";
export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts";
export { ZenmuxFreeExecutor } from "./zenmux-free.ts";

View File

@@ -179,6 +179,11 @@ const EMBEDDED_DEFAULTS = {
github_copilot_id: [
38, 27, 95, 71, 16, 90, 69, 67, 4, 29, 72, 22, 90, 91, 12, 0, 75, 19, 8, 87,
],
// Grok Build CLI (xAI) — public oauth client id (import-token flow)
grok_id: [
13, 92, 15, 89, 66, 91, 76, 70, 72, 29, 71, 70, 3, 65, 93, 84, 72, 23, 28, 87, 92, 88, 15, 95,
91, 22, 71, 87, 20, 66, 67, 86, 13, 81, 81, 21,
],
} as const;
export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS;

View File

@@ -71,6 +71,7 @@ export default function ProviderDetailPageClient() {
const [codexCliGuideOpen, setCodexCliGuideOpen] = useState(false);
const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false);
const [importGeminiModalOpen, setImportGeminiModalOpen] = useState(false);
const [importGrokCliModalOpen, setImportGrokCliModalOpen] = useState(false);
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
const isCommandCode = providerId === "command-code";
@@ -520,6 +521,7 @@ export default function ProviderDetailPageClient() {
onOpenImportCodex={() => setImportCodexModalOpen(true)}
onOpenImportClaude={() => setImportClaudeModalOpen(true)}
onOpenImportGemini={() => setImportGeminiModalOpen(true)}
onOpenImportGrokCli={() => setImportGrokCliModalOpen(true)}
t={t}
/>
@@ -539,6 +541,7 @@ export default function ProviderDetailPageClient() {
onOpenImportCodex={() => setImportCodexModalOpen(true)}
onOpenImportClaude={() => setImportClaudeModalOpen(true)}
onOpenImportGemini={() => setImportGeminiModalOpen(true)}
onOpenImportGrokCli={() => setImportGrokCliModalOpen(true)}
t={t}
/>
) : (
@@ -759,6 +762,8 @@ export default function ProviderDetailPageClient() {
handleApplyGeminiAuthLocal={handleApplyGeminiAuthLocal}
importGeminiModalOpen={importGeminiModalOpen}
setImportGeminiModalOpen={setImportGeminiModalOpen}
importGrokCliModalOpen={importGrokCliModalOpen}
setImportGrokCliModalOpen={setImportGrokCliModalOpen}
batchTestResults={batchTestResults}
setBatchTestResults={setBatchTestResults}
emailsVisible={emailsVisible}

View File

@@ -45,6 +45,7 @@ type ConnectionsHeaderToolbarProps = {
onOpenImportCodex: () => void;
onOpenImportClaude: () => void;
onOpenImportGemini: () => void;
onOpenImportGrokCli: () => void;
t: ProviderMessageTranslator;
};
@@ -87,6 +88,7 @@ export default function ConnectionsHeaderToolbar({
onOpenImportCodex,
onOpenImportClaude,
onOpenImportGemini,
onOpenImportGrokCli,
t,
}: ConnectionsHeaderToolbarProps) {
return (
@@ -338,6 +340,16 @@ export default function ConnectionsHeaderToolbar({
: "Import auth"}
</Button>
)}
{providerId === "grok-cli" && (
<Button
size="sm"
variant="secondary"
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportGrokCli)}
>
Import auth
</Button>
)}
</>
)}
</>

View File

@@ -24,6 +24,7 @@ interface EmptyConnectionsPlaceholderProps {
onOpenImportCodex: () => void;
onOpenImportClaude: () => void;
onOpenImportGemini: () => void;
onOpenImportGrokCli: () => void;
t: ProviderMessageTranslator;
}
@@ -42,14 +43,13 @@ export default function EmptyConnectionsPlaceholder({
onOpenImportCodex,
onOpenImportClaude,
onOpenImportGemini,
onOpenImportGrokCli,
t,
}: EmptyConnectionsPlaceholderProps) {
return (
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<span className="material-symbols-outlined text-[32px]">
{isOAuth ? "lock" : "key"}
</span>
<span className="material-symbols-outlined text-[32px]">{isOAuth ? "lock" : "key"}</span>
</div>
<p className="text-text-main font-medium mb-1">{t("noConnectionsYet")}</p>
<p className="text-sm text-text-muted mb-4">{t("addFirstConnectionHint")}</p>
@@ -82,10 +82,7 @@ export default function EmptyConnectionsPlaceholder({
{providerSupportsPat ? "Add PAT" : t("addConnection")}
</Button>
{providerId === "qoder" && (
<Button
variant="secondary"
onClick={() => gateConnectionFlow(onOpenOAuthModal)}
>
<Button variant="secondary" onClick={() => gateConnectionFlow(onOpenOAuthModal)}>
Experimental OAuth
</Button>
)}
@@ -122,6 +119,15 @@ export default function EmptyConnectionsPlaceholder({
: "Import auth"}
</Button>
)}
{providerId === "grok-cli" && (
<Button
variant="secondary"
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportGrokCli)}
>
Import auth
</Button>
)}
</>
)}
</div>

View File

@@ -23,6 +23,7 @@ import { AdaptaTutorialModal } from "./AdaptaTutorialModal";
import { ImportCodexAuthModal, ApplyCodexAuthModal } from "./modals/ImportCodexAuthModal";
import { ImportClaudeAuthModal, ApplyClaudeAuthModal } from "./modals/ImportClaudeAuthModal";
import { ImportGeminiAuthModal, ApplyGeminiAuthModal } from "./modals/ImportGeminiAuthModal";
import ImportGrokCliAuthModal from "./modals/ImportGrokCliAuthModal";
import { type ConnectionRowConnection } from "./ConnectionRow";
import { type BatchTestResults } from "../hooks/useProviderConnections";
import { type ImportProgress } from "../hooks/useModelImportHandlers";
@@ -123,6 +124,9 @@ interface ProviderModalsPanelProps {
handleApplyGeminiAuthLocal: (id: string) => Promise<void>;
importGeminiModalOpen: boolean;
setImportGeminiModalOpen: (open: boolean) => void;
// Grok Build auth
importGrokCliModalOpen: boolean;
setImportGrokCliModalOpen: (open: boolean) => void;
// Batch test results
batchTestResults: BatchTestResults | null;
setBatchTestResults: (r: BatchTestResults | null) => void;
@@ -209,6 +213,8 @@ export default function ProviderModalsPanel({
handleApplyGeminiAuthLocal,
importGeminiModalOpen,
setImportGeminiModalOpen,
importGrokCliModalOpen,
setImportGrokCliModalOpen,
batchTestResults,
setBatchTestResults,
emailsVisible,
@@ -395,6 +401,16 @@ export default function ProviderModalsPanel({
}}
/>
)}
{providerId === "grok-cli" && importGrokCliModalOpen && (
<ImportGrokCliAuthModal
key="import-grok-cli-modal"
onClose={() => setImportGrokCliModalOpen(false)}
onSuccess={() => {
setImportGrokCliModalOpen(false);
void fetchConnections();
}}
/>
)}
<BatchTestResultsModal
batchTestResults={batchTestResults}
providerInfo={providerInfo}

View File

@@ -0,0 +1,220 @@
"use client";
import { useState } from "react";
import { useNotificationStore } from "@/store/notificationStore";
import { Button, Modal } from "@/shared/components";
interface ImportGrokCliAuthModalProps {
onClose: () => void;
onSuccess: () => void;
}
function parseGrokJson(json: unknown): {
valid: boolean;
email: string | null;
hasRefreshToken: boolean;
} {
try {
const doc = json && typeof json === "object" ? (json as Record<string, unknown>) : null;
if (!doc) return { valid: false, email: null, hasRefreshToken: false };
const entries = Object.values(doc);
for (const entry of entries) {
if (entry && typeof entry === "object" && "key" in entry) {
const obj = entry as Record<string, unknown>;
const key = typeof obj.key === "string" ? obj.key : null;
const email = typeof obj.email === "string" ? obj.email : null;
const hasRefreshToken =
typeof obj.refresh_token === "string" && obj.refresh_token.length > 0;
if (key && key.startsWith("eyJ")) {
return { valid: true, email, hasRefreshToken };
}
}
}
return { valid: false, email: null, hasRefreshToken: false };
} catch {
return { valid: false, email: null, hasRefreshToken: false };
}
}
export default function ImportGrokCliAuthModal({
onClose,
onSuccess,
}: ImportGrokCliAuthModalProps) {
const notify = useNotificationStore();
const [tab, setTab] = useState<"upload" | "paste">("upload");
const [parsedJson, setParsedJson] = useState<unknown>(null);
const [detectedEmail, setDetectedEmail] = useState<string | null>(null);
const [hasRefreshToken, setHasRefreshToken] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const [pasteText, setPasteText] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function handlePreview(json: unknown) {
setParseError(null);
setDetectedEmail(null);
setParsedJson(null);
setHasRefreshToken(false);
const result = parseGrokJson(json);
if (!result.valid) {
setParseError(
"Not a valid Grok Build auth.json. Expected an object with a key containing a JWT."
);
return;
}
setDetectedEmail(result.email);
setHasRefreshToken(result.hasRefreshToken);
setParsedJson(json);
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
handlePreview(JSON.parse(ev.target?.result as string));
} catch {
setParseError("Could not parse JSON");
}
};
reader.readAsText(file);
}
function handlePasteChange(text: string) {
setPasteText(text);
if (!text.trim()) {
setParsedJson(null);
setParseError(null);
setDetectedEmail(null);
setHasRefreshToken(false);
return;
}
try {
handlePreview(JSON.parse(text));
} catch {
setParseError("Could not parse JSON");
setParsedJson(null);
}
}
async function handleSubmit() {
if (!parsedJson) return;
setLoading(true);
setError(null);
try {
const res = await fetch("/api/oauth/grok-cli/import-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: parsedJson }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error || "Failed to import");
return;
}
notify.success("Grok Build connection imported successfully");
onSuccess();
} catch {
setError("Failed to import Grok Build auth");
} finally {
setLoading(false);
}
}
return (
<Modal isOpen onClose={onClose} title="Import Grok Build Auth" size="md">
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">
Import your Grok Build <code>~/.grok/auth.json</code> file. You can get it by running{" "}
<code>grok login</code> in your terminal.
</p>
{/* Tab toggle */}
<div className="flex gap-2 border-b border-border pb-3">
<button
className={`text-sm px-3 py-1 rounded-t ${tab === "upload" ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={() => setTab("upload")}
>
Upload file
</button>
<button
className={`text-sm px-3 py-1 rounded-t ${tab === "paste" ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={() => setTab("paste")}
>
Paste JSON
</button>
</div>
{/* Upload tab */}
{tab === "upload" && (
<div className="flex flex-col gap-3">
<input
type="file"
accept=".json"
onChange={handleFileChange}
className="text-sm text-text-muted file:mr-3 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-primary/10 file:text-primary hover:file:bg-primary/20 cursor-pointer"
/>
</div>
)}
{/* Paste tab */}
{tab === "paste" && (
<div className="flex flex-col gap-3">
<textarea
className="w-full h-32 p-3 text-sm font-mono bg-input border border-border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-primary"
placeholder='{"https://auth.x.ai::clientId": {"key": "eyJ...", ...}}'
value={pasteText}
onChange={(e) => handlePasteChange(e.target.value)}
/>
</div>
)}
{/* Parse error */}
{parseError && <p className="text-sm text-red-500">{parseError}</p>}
{/* Detected info */}
{parsedJson && (
<div className="flex flex-col gap-3">
<div className="bg-green-500/10 border border-green-500/20 rounded-md p-3">
<p className="text-sm text-green-400">
Valid Grok Build token detected{detectedEmail ? ` (${detectedEmail})` : ""}
</p>
{hasRefreshToken && (
<p className="text-xs text-green-500 mt-1">
Refresh token included automatic token renewal enabled
</p>
)}
{!hasRefreshToken && (
<p className="text-xs text-amber-400 mt-1">
No refresh token found you will need to re-import when the token expires
</p>
)}
</div>
<input
type="text"
placeholder="Connection name (optional)"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full p-2 text-sm bg-input border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
)}
{/* Submit error */}
{error && <p className="text-sm text-red-500">{error}</p>}
{/* Buttons */}
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!parsedJson || loading}>
{loading ? "Saving…" : "Save Connection"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -83,11 +83,7 @@ export interface UseProviderConnectionsReturn {
handleUpdateConnectionStatus: (id: string, isActive: boolean) => Promise<void>;
handleToggleRateLimit: (connectionId: string, enabled: boolean) => Promise<void>;
handleToggleClaudeExtraUsage: (connectionId: string, enabled: boolean) => Promise<void>;
handleToggleCodexLimit: (
connectionId: string,
field: string,
enabled: boolean
) => Promise<void>;
handleToggleCodexLimit: (connectionId: string, field: string, enabled: boolean) => Promise<void>;
handleToggleCliproxyapiMode: (connectionId: string, enabled: boolean) => Promise<void>;
handleToggleProxyEnabled: (connectionId: string, proxyEnabled: boolean) => Promise<void>;
handleTogglePerKeyProxyEnabled: (
@@ -101,9 +97,7 @@ export interface UseProviderConnectionsReturn {
// Batch handlers
handleBatchSetActive: (isActive: boolean) => Promise<void>;
handleBatchDeleteOpenModal: () => void;
handleBatchDeleteConfirm: (
onAfter?: () => Promise<void>
) => Promise<void>;
handleBatchDeleteConfirm: (onAfter?: () => Promise<void>) => Promise<void>;
handleBatchRetest: () => Promise<void>;
handleBatchTestAll: () => Promise<void>;
@@ -198,8 +192,7 @@ export function useProviderConnections(
setConnections(filtered);
}
if (nodesRes.ok) {
let node =
(nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
let node = (nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
// Newly created compatible nodes can be briefly unavailable on one worker.
if (!node && isCompatible) {
@@ -208,8 +201,7 @@ export function useProviderConnections(
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
if (!retryRes.ok) continue;
const retryData = await retryRes.json();
node =
(retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
node = (retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
if (node) break;
}
}
@@ -230,10 +222,7 @@ export function useProviderConnections(
conns
.filter((c) => c.id)
.map((c) =>
fetch(
`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`,
{ cache: "no-store" }
)
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => [c.id!, data] as [string, any])
.catch(() => [c.id!, null] as [string, any])
@@ -347,9 +336,7 @@ export function useProviderConnections(
body: JSON.stringify({ isActive }),
});
if (res.ok) {
setConnections((prev: any[]) =>
prev.map((c) => (c.id === id ? { ...c, isActive } : c))
);
setConnections((prev: any[]) => prev.map((c) => (c.id === id ? { ...c, isActive } : c)));
}
} catch (error) {
console.log("Error updating connection status:", error);
@@ -365,9 +352,7 @@ export function useProviderConnections(
});
if (res.ok) {
setConnections((prev: any[]) =>
prev.map((c) =>
c.id === connectionId ? { ...c, rateLimitProtection: enabled } : c
)
prev.map((c) => (c.id === connectionId ? { ...c, rateLimitProtection: enabled } : c))
);
}
} catch (error) {
@@ -434,11 +419,7 @@ export function useProviderConnections(
}
};
const handleToggleCodexLimit = async (
connectionId: string,
field: string,
enabled: boolean
) => {
const handleToggleCodexLimit = async (connectionId: string, field: string, enabled: boolean) => {
try {
const target = (connections as any[]).find((connection) => connection.id === connectionId);
if (!target) return;
@@ -561,7 +542,7 @@ export function useProviderConnections(
const res = await fetch(`/api/providers/${connectionId}/test`, { method: "POST" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error || t("failedRetestConnection"));
notify.error(data.error || t("failedRetestConnection"));
return;
}
await fetchConnections();
@@ -641,10 +622,7 @@ export function useProviderConnections(
const handleToggleSelectAll = useCallback(() => {
setSelectedIds((prev) => {
if (
prev.size === (connections as any[]).length &&
(connections as any[]).length > 0
) {
if (prev.size === (connections as any[]).length && (connections as any[]).length > 0) {
return new Set();
}
return new Set((connections as any[]).map((c: { id: string }) => c.id));

View File

@@ -63,7 +63,7 @@ const BROWSER_DEVICE_FLOW_PROVIDERS = new Set(["codex"]);
const RETIRED_PKCE_PROVIDERS = new Set(["windsurf", "devin-cli"]);
/** Providers that allow direct import of a raw API token (no OAuth exchange). */
const IMPORT_TOKEN_PROVIDERS = new Set(["windsurf", "devin-cli"]);
const IMPORT_TOKEN_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]);
/**
* Constant-time string comparison to prevent timing-oracle attacks (CWE-208).

View File

@@ -0,0 +1,109 @@
/**
* Grok Build OAuth Provider — Import Token Flow with Refresh Support
*
* User pastes the entire auth.json from ~/.grok/auth.json
* or just the JWT access token string.
* Supports automatic token refresh using the refresh_token.
*/
import { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds";
const GROK_CLI_CONFIG = {
clientId: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
tokenUrl: "https://auth.x.ai/oauth2/token",
};
interface GrokCliAuthInfo {
user_id: string;
email: string;
team_id: string;
tier: number;
principal_type: string;
}
function parseJwtPayload(token: string): {
email: string | null;
authInfo: GrokCliAuthInfo | null;
} {
try {
const parts = token.split(".");
if (parts.length !== 3) return { email: null, authInfo: null };
let base64 = parts[1];
switch (base64.length % 4) {
case 2:
base64 += "==";
break;
case 3:
base64 += "=";
break;
}
base64 = base64.replace(/-/g, "+").replace(/_/g, "/");
const payload = JSON.parse(Buffer.from(base64, "base64").toString("utf-8"));
return {
email: payload.email || null,
authInfo: {
user_id: payload.sub || "",
email: payload.email || "",
team_id: payload.team_id || "",
tier: payload.tier || 1,
principal_type: payload.principal_type || "User",
},
};
} catch {
return { email: null, authInfo: null };
}
}
/**
* Extract the JWT access token and refresh_token from user input.
* Accepts either:
* - Raw JWT string (no refresh_token available)
* - The entire auth.json object: { "https://auth.x.ai::...": { "key": "eyJ...", "refresh_token": "..." } }
*/
function extractTokenAndRefresh(input: any): { accessToken: string; refreshToken: string | null } {
if (typeof input === "string") return { accessToken: input, refreshToken: null };
if (input && typeof input === "object") {
// auth.json format: first entry's "key" and "refresh_token" fields
const keys = Object.keys(input);
if (keys.length > 0 && input[keys[0]]?.key) {
return {
accessToken: input[keys[0]].key,
refreshToken: input[keys[0]].refresh_token || null,
};
}
// Already has accessToken
if (input.accessToken) {
return {
accessToken: input.accessToken,
refreshToken: input.refreshToken || null,
};
}
}
return { accessToken: "", refreshToken: null };
}
export const grokCli = {
config: GROK_CLI_CONFIG,
flowType: "import_token",
mapTokens: (token: any) => {
const { accessToken, refreshToken } = extractTokenAndRefresh(token);
const { email, authInfo } = parseJwtPayload(accessToken);
return {
accessToken,
refreshToken,
expiresIn: 21600,
email,
providerSpecificData: {
userId: authInfo?.user_id || null,
teamId: authInfo?.team_id || null,
tier: authInfo?.tier || 1,
principalType: authInfo?.principal_type || "User",
},
};
},
};

View File

@@ -26,6 +26,7 @@ import { trae } from "./trae";
import { kilocode } from "./kilocode";
import { cline } from "./cline";
import { windsurf } from "./windsurf";
import { grokCli } from "./grok-cli";
import { codebuddyCn } from "./codebuddy-cn";
export const PROVIDERS = {
@@ -48,6 +49,7 @@ export const PROVIDERS = {
windsurf,
// devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login)
"devin-cli": windsurf,
"grok-cli": grokCli,
"codebuddy-cn": codebuddyCn,
};

View File

@@ -84,6 +84,18 @@ const KNOWN_PLANS: Record<string, KnownPlanShape> = {
provider: "alibaba",
dimensions: [{ unit: "requests", window: "monthly", limit: 90_000 }],
},
// Grok Build (xAI) — rate limits from x-ratelimit-* headers:
// Daily: 864 requests, 18M tokens (from API headers)
// Weekly: derived from daily * 7
"grok-cli": {
provider: "grok-cli",
dimensions: [
{ unit: "requests", window: "daily", limit: 864 },
{ unit: "tokens", window: "daily", limit: 18_000_000 },
{ unit: "requests", window: "weekly", limit: 6048 },
{ unit: "tokens", window: "weekly", limit: 126_000_000 },
],
},
};
export function getKnownPlan(provider: string): KnownPlanShape | null {

View File

@@ -19,7 +19,7 @@ const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex"]);
* Phase 2 will reintroduce browser login via Firebase OAuth + RegisterUser.
* Spec: docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md.
*/
const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli"]);
const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]);
type OAuthModalProps = {
isOpen: boolean;
@@ -55,12 +55,13 @@ export default function OAuthModal({
const [polling, setPolling] = useState(false);
// API-key paste mode: for providers that accept a token directly (windsurf, devin-cli)
const [showPasteToken, setShowPasteToken] = useState(
provider === "windsurf" || provider === "devin-cli"
provider === "windsurf" || provider === "devin-cli" || provider === "grok-cli"
);
const [pasteToken, setPasteToken] = useState("");
const [savingToken, setSavingToken] = useState(false);
const supportsTokenPaste = provider === "windsurf" || provider === "devin-cli";
const supportsTokenPaste =
provider === "windsurf" || provider === "devin-cli" || provider === "grok-cli";
// Phase 1 hotfix (2026-05-29): windsurf/devin-cli are import-token-only.
// Hide the "Browser Login" tab — Phase 2 will restore it via Firebase OAuth.
const importTokenOnly = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider);
@@ -731,14 +732,16 @@ export default function OAuthModal({
<p className="text-sm text-text-muted">
{provider === "windsurf"
? 'In the Windsurf / VS Code IDE, run the "Windsurf: Provide Auth Token" command from the command palette (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it below. Opening windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the flow.'
: 'Provide your WINDSURF_API_KEY (obtained via `devin auth login`, or via the Windsurf IDE "Windsurf: Provide Auth Token" command).'}
: provider === "grok-cli"
? 'Paste your Grok Build JWT token from ~/.grok/auth.json (the "key" field value). You can get it by running `grok login` in your terminal.'
: 'Provide your WINDSURF_API_KEY (obtained via `devin auth login`, or via the Windsurf IDE "Windsurf: Provide Auth Token" command).'}
</p>
<Input
value={pasteToken}
onChange={(e) => setPasteToken(e.target.value)}
placeholder="ws-..."
placeholder={provider === "grok-cli" ? "eyJ..." : "ws-..."}
type="password"
label="API Key / Token"
label={provider === "grok-cli" ? "JWT Token" : "API Key / Token"}
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2">

View File

@@ -51,8 +51,6 @@ export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean {
return typeof providerId === "string" && FREE_APIKEY_PROVIDER_IDS.has(providerId);
}
// OAuth Providers
// Web / Cookie Providers
// API Key Providers
@@ -141,8 +139,6 @@ export function isAnthropicCompatibleProvider(providerId: unknown): providerId i
return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
}
export function isClaudeCodeCompatibleProvider(providerId: unknown): providerId is string {
return typeof providerId === "string" && providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
}

View File

@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
const { grokCli } = await import("../../src/lib/oauth/providers/grok-cli.ts");
const { resolvePublicCred } = await import("@omniroute/open-sse/utils/publicCreds");
test("Grok Build OAuth Provider - config", () => {
assert.ok(grokCli.config.clientId, "clientId should be defined");
// The public client_id must come from the embedded default (Hard Rule #11),
// not a string literal — assert it matches resolvePublicCred("grok_id").
assert.equal(
grokCli.config.clientId,
resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
"clientId must resolve from the embedded grok_id default"
);
assert.equal(grokCli.config.tokenUrl, "https://auth.x.ai/oauth2/token");
});
test("publicCreds: grok_id embedded default is present and decodes", () => {
const decoded = resolvePublicCred("grok_id");
assert.ok(decoded.length > 0, "grok_id must decode to a non-empty client id");
});
test("Grok Build OAuth Provider - flowType is import_token", () => {
assert.equal(grokCli.flowType, "import_token");
});
test("Grok Build OAuth Provider - mapTokens from raw JWT", () => {
// Create a valid JWT with base64url-encoded payload
const payload = { sub: "12345", email: "test@example.com", team_id: "team-67890", tier: 1 };
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
const mockJwt = `eyJhbGciOiJFUzI1NiJ9.${payloadBase64}.signature`;
const result = grokCli.mapTokens(mockJwt, null);
assert.equal(result.accessToken, mockJwt);
assert.equal(result.refreshToken, null);
assert.equal(result.email, "test@example.com");
assert.equal(result.expiresIn, 21600);
assert.equal(result.providerSpecificData?.userId, "12345");
assert.equal(result.providerSpecificData?.teamId, "team-67890");
assert.equal(result.providerSpecificData?.tier, 1);
});
test("Grok Build OAuth Provider - mapTokens from auth.json", () => {
const authJson = {
"https://auth.x.ai::clientId": {
key: "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
refresh_token: "test-refresh-token",
},
};
const result = grokCli.mapTokens(authJson, null);
assert.ok(result.accessToken.includes("eyJ"), "accessToken should be JWT");
assert.equal(result.refreshToken, "test-refresh-token");
assert.equal(result.email, "test@example.com");
});
test("Grok Build OAuth Provider - mapTokens from empty string", () => {
const result = grokCli.mapTokens("", null);
assert.equal(result.accessToken, "");
});
test("Grok Build OAuth Provider - mapTokens from object with accessToken", () => {
const input = { accessToken: "direct-token" };
const result = grokCli.mapTokens(input, null);
assert.equal(result.accessToken, "direct-token");
});