feat(grok-cli): add Grok CLI / Grok Build provider with OAuth device-code flow (#2502)

New OAuth provider routing through cli-chat-proxy.grok.com (OpenAI Responses
API), distinct from xai (api.x.ai) and grok-web (cookie SSO):

- Registry + GrokCliExecutor: Chat Completions -> Responses transform, CLI
  fingerprint headers, virtual effort models grok-4.5-{low,medium,high}
- OAuth device-code flow (auth.x.ai) with no-PKCE, shared xAI token refresh
- store=false multi-turn continuity via reasoning encrypted_content
- Quota tracker: on-demand window + prepaid balance on dashboard
- Connection test: 402 spending-limit = soft success (auth OK, out of credits)
- Alias/oauth/provider baselines + unit tests
This commit is contained in:
Fadjrir Herlambang
2026-07-10 11:47:08 +07:00
committed by decolua
parent c73c419d09
commit a11937cdd6
28 changed files with 2695 additions and 376 deletions
@@ -148,7 +148,10 @@ export default function ProviderDetailPage() {
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
const hasDualAuthModes = !isCompatible && isOAuth && supportsApiKeyAuth;
const oauthConnectionLabel = providerId === "xai" ? "Grok Build OAuth" : "OAuth";
const oauthConnectionLabel =
providerId === "xai" ? "Grok Build OAuth"
: providerId === "grok-cli" ? "Grok CLI Device Login"
: "OAuth";
const apiKeyConnectionLabel = providerId === "xai" ? "xAI API Key" : "API Key";
// Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it.
const resolveThinkingSuffix = (modelId) => {
@@ -451,6 +451,23 @@ export function parseQuotaData(provider, data) {
}
break;
case "grok-cli":
// Grok Build credits (on-demand window + prepaid balance).
// Do NOT forward absolute `remaining` — getRemainingPercentage treats
// it as a 0100 percentage (same as Qoder). Use remainingPercentage.
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
}
break;
default:
// Generic fallback for unknown providers
if (data.quotas) {
+10 -2
View File
@@ -150,8 +150,16 @@ export async function GET(request, { params }) {
}
: undefined;
// Providers that don't use PKCE for device code
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy-cn", "qoder"];
// Providers that don't use PKCE for device code (Grok CLI HAR: plain device_code, no challenge)
const noPkceDeviceProviders = [
"github",
"kiro",
"kimi-coding",
"kilocode",
"codebuddy-cn",
"qoder",
"grok-cli",
];
let deviceData;
if (noPkceDeviceProviders.includes(provider)) {
deviceData = await requestDeviceCode(provider, undefined, deviceOptions);
+88 -10
View File
@@ -103,8 +103,61 @@ const OAUTH_TEST_CONFIG = {
},
refreshable: false,
},
// Grok CLI / Grok Build — probe /v1/user (no inference quota). Headers mirror official CLI.
"grok-cli": {
url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: {
Accept: "application/json",
...(PROVIDERS["grok-cli"]?.headers || {
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-identifier": "grok-pager",
"x-grok-client-version": "0.2.93",
}),
},
refreshable: true,
// Subscription spending-limit is not an auth failure — token is fine, credits aren't.
// Accept 402 so the connection stays "active" with a warning (same idea as Codex 400).
acceptStatuses: [402],
softFailMessage: {
402: "Connected, but Grok Build credits are exhausted (spending limit). Add credits or upgrade SuperGrok.",
},
},
};
/**
* Classify an OAuth probe response as success / soft-success / hard-fail.
* Soft success (e.g. 402 spending-limit on Grok CLI) means auth works but the
* account cannot spend — keep connection active and surface a warning.
* Exported for unit tests.
*/
export function classifyOAuthProbeResult(res, config, bodyText = "") {
if (!res) return { valid: false, error: "No response", soft: false };
const status = res.status;
const accepted = res.ok || (config?.acceptStatuses && config.acceptStatuses.includes(status));
if (!accepted) {
if (status === 401) return { valid: false, error: "Token invalid or revoked", soft: false };
if (status === 403) return { valid: false, error: "Access denied", soft: false };
return { valid: false, error: `API returned ${status}`, soft: false };
}
// Soft success only when the provider configured an explicit message for this
// status (e.g. Grok CLI 402 spending-limit). Codex-style acceptStatuses:[400]
// stays silent success — 400 there only proves auth, not a user-facing warning.
if (!res.ok && config?.acceptStatuses?.includes(status)) {
const softMap = config.softFailMessage || {};
if (softMap[status]) {
return { valid: true, error: softMap[status], soft: true };
}
return { valid: true, error: null, soft: false };
}
return { valid: true, error: null, soft: false };
}
async function probeClineAccessToken(accessToken) {
const res = await fetch("https://api.cline.bot/api/v1/users/me", {
method: "GET",
@@ -186,7 +239,7 @@ async function refreshOAuthToken(connection) {
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
}
if (provider === "codex") {
if (provider === "codex" || provider === "grok-cli" || provider === "xai") {
return await refreshProviderCredentials(provider, connection, console);
}
@@ -362,9 +415,19 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
const fetchOpts = { method: config.method, headers };
if (config.body) fetchOpts.body = config.body;
const res = await fetchWithConnectionProxy(testUrl, fetchOpts, effectiveProxy);
const bodyText = !res.ok ? await res.text().catch(() => "") : "";
const accepted = res.ok || (config.acceptStatuses && config.acceptStatuses.includes(res.status));
if (accepted) return { valid: true, error: null, refreshed, newTokens };
const classified = classifyOAuthProbeResult(res, config, bodyText);
if (classified.valid) {
return {
valid: true,
// soft success surfaces warning text without marking connection error
error: classified.soft ? classified.error : null,
warning: classified.soft ? classified.error : null,
refreshed,
newTokens,
};
}
if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) {
const tokens = await refreshOAuthToken(connection);
@@ -376,15 +439,22 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
const retryOpts = { method: config.method, headers: retryHeaders };
if (config.body) retryOpts.body = config.body;
const retryRes = await fetchWithConnectionProxy(retryUrl, retryOpts, effectiveProxy);
const retryAccepted = retryRes.ok || (config.acceptStatuses && config.acceptStatuses.includes(retryRes.status));
if (retryAccepted) return { valid: true, error: null, refreshed: true, newTokens: tokens };
const retryBody = !retryRes.ok ? await retryRes.text().catch(() => "") : "";
const retryClassified = classifyOAuthProbeResult(retryRes, config, retryBody);
if (retryClassified.valid) {
return {
valid: true,
error: retryClassified.soft ? retryClassified.error : null,
warning: retryClassified.soft ? retryClassified.error : null,
refreshed: true,
newTokens: tokens,
};
}
}
return { valid: false, error: "Token invalid or revoked", refreshed: false };
}
if (res.status === 401) return { valid: false, error: "Token invalid or revoked", refreshed };
if (res.status === 403) return { valid: false, error: "Access denied", refreshed };
return { valid: false, error: `API returned ${res.status}`, refreshed };
return { valid: false, error: classified.error, refreshed };
} catch (err) {
return { valid: false, error: err.message, refreshed };
}
@@ -752,10 +822,18 @@ export async function testSingleConnection(id) {
const latencyMs = Date.now() - start;
// Soft success (e.g. Grok CLI 402 spending-limit): credentials are good, account is
// out of credits. Keep testStatus active; surface the message as lastError so the
// dashboard can show a warning without marking the connection broken.
const softWarning = result.valid && (result.warning || result.error);
const updateData = {
testStatus: result.valid ? "active" : "error",
lastError: result.valid ? null : result.error,
lastErrorAt: result.valid ? null : new Date().toISOString(),
lastError: result.valid ? (softWarning || null) : result.error,
lastErrorAt: result.valid
? softWarning
? new Date().toISOString()
: null
: new Date().toISOString(),
};
if (result.refreshed && result.newTokens) {
+5
View File
@@ -114,6 +114,10 @@ export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] };
// Kimchi OAuth Configuration (Browser token callback flow)
export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] };
// Grok CLI / Grok Build OAuth Configuration (Device Code Flow)
// Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes
export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] };
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -137,4 +141,5 @@ export const PROVIDERS = {
GITLAB: "gitlab",
CODEBUDDY: "codebuddy-cn",
KIMCHI: "kimchi",
GROK_CLI: "grok-cli",
};
+117
View File
@@ -27,6 +27,7 @@ import {
GITLAB_CONFIG,
CODEBUDDY_CONFIG,
KIMCHI_CONFIG,
GROK_CLI_CONFIG,
getOAuthClientMetadata,
} from "./constants/oauth";
import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai";
@@ -255,6 +256,122 @@ const PROVIDERS = {
},
},
// Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com
"grok-cli": {
config: GROK_CLI_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const body = new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
});
// Official CLI sends referrer=grok-build
if (config.referrer) body.set("referrer", config.referrer);
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
},
body,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Grok CLI device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: config.clientId,
}),
});
let data;
try {
data = await response.json();
} catch {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
// Device flow: 400 + authorization_pending is expected while user authorizes
const pending =
data?.error === "authorization_pending" ||
data?.error === "slow_down";
return {
ok: response.ok || pending,
data,
};
},
postExchange: async (tokens) => {
// Best-effort user profile from cli-chat-proxy (non-fatal)
try {
const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-version": "0.2.93",
},
});
if (res.ok) return { user: await res.json() };
} catch {
/* ignore */
}
return { user: null };
},
mapTokens: (tokens, extra) => {
const email =
decodeXaiIdTokenEmail(tokens.id_token) ||
extractEmailFromAccessToken(tokens.access_token) ||
extra?.user?.email ||
null;
const userId =
extra?.user?.userId ||
extra?.user?.principalId ||
null;
const displayName = [extra?.user?.firstName, extra?.user?.lastName]
.filter(Boolean)
.join(" ")
.trim() || null;
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || null,
expiresIn: tokens.expires_in,
scope: tokens.scope,
// Top-level for dashboard connection cards
email: email || undefined,
displayName: displayName || undefined,
// Mirror identity into providerSpecificData so GrokCliExecutor can set
// x-email / x-userid without depending on top-level credential shape.
providerSpecificData: {
authMethod: "device_code",
idToken: tokens.id_token || null,
email: email || null,
userId,
hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null,
subscriptionTier: extra?.user?.subscriptionTier ?? null,
},
};
},
},
"gemini-cli": {
config: GEMINI_CONFIG,
flowType: "authorization_code",
+22 -2
View File
@@ -156,8 +156,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
try {
setError(null);
// Device code flow providers
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode", "codebuddy-cn", "qoder"];
// Device code flow providers (must match oauth providers with flowType: "device_code")
const deviceCodeProviders = [
"github",
"qwen",
"kiro",
"kimi-coding",
"kilocode",
"codebuddy-cn",
"qoder",
"grok-cli",
];
if (deviceCodeProviders.includes(provider)) {
setIsDeviceCode(true);
setStep("waiting");
@@ -277,6 +286,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setAuthData({ ...data, redirectUri, codexServerSide, xaiServerSide });
// Guard: device_code providers return authUrl:null from /authorize. Never window.open(null)
// (browsers coerce it to the relative path ".../null").
if (!data.authUrl) {
if (data.flowType === "device_code") {
throw new Error(
`Provider ${provider} uses device-code login but is not wired in the OAuth modal device-code list`
);
}
throw new Error("No authorization URL returned from OAuth provider");
}
if (provider === "codex" && codexProxyActive) {
// Proxy active: callback will be handled server-side (auto-exchange) or via channels (fallback)
setStep("waiting");