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) {