mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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:
committed by
decolua
parent
c73c419d09
commit
a11937cdd6
@@ -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 0–100 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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user