feat(grok-cli): align with official Grok Build client (#7358)

Rebuilt clean on release/v3.8.49 (branch forked from old main, ~drift). Resolved
2 real conflicts against the current tip: providerModelsConfig.ts keeps BOTH the
tip's DashScope text-model helpers (#7882) and this PR's ProviderModelsHeaderContext
type; OAuthModal.tsx takes this PR's DEVICE_CODE_PROVIDERS set (superset of the
tip's hardcoded chain + grok-cli), dropping the now-dead qwen entry (#7866 removed
qwen OAuth).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
backryun
2026-07-21 07:28:21 +09:00
committed by GitHub
parent cec2f58c24
commit 0df0ff2ddb
18 changed files with 2044 additions and 792 deletions

View File

@@ -0,0 +1,142 @@
import { getRuntimeArch, getRuntimePlatform } from "./providerHeaderProfiles.ts";
export const GROK_BUILD_PROXY_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
export const GROK_BUILD_RESPONSES_URL = `${GROK_BUILD_PROXY_BASE_URL}/responses`;
export const GROK_BUILD_MODELS_URL = `${GROK_BUILD_PROXY_BASE_URL}/models`;
export const GROK_BUILD_OAUTH_ISSUER = "https://auth.x.ai";
export const GROK_BUILD_DEVICE_CODE_URL = `${GROK_BUILD_OAUTH_ISSUER}/oauth2/device/code`;
export const GROK_BUILD_TOKEN_URL = `${GROK_BUILD_OAUTH_ISSUER}/oauth2/token`;
export const GROK_BUILD_DEFAULT_CLIENT_VERSION = "0.2.106";
export const GROK_BUILD_DEFAULT_CONTEXT_WINDOW = 256_000;
export const GROK_BUILD_DEFAULT_REASONING_EFFORT = "high";
export const GROK_BUILD_CLIENT_IDENTIFIER = "grok-shell";
export const GROK_BUILD_TOKEN_AUTH = "xai-grok-cli";
export const GROK_BUILD_REASONING_INCLUDE = "reasoning.encrypted_content";
export const GROK_BUILD_OAUTH_REFERRER = "grok-build";
export const GROK_BUILD_OAUTH_SCOPES = Object.freeze([
"openid",
"profile",
"email",
"offline_access",
"grok-cli:access",
"api:access",
"conversations:read",
"conversations:write",
"workspaces:read",
"workspaces:write",
]);
export type GrokBuildClientMode = "headless" | "interactive";
export type GrokBuildClientSurface = "ui" | "cli" | "headless";
export type GrokBuildSessionHeaderOptions = {
token?: string | null;
model?: string | null;
stream?: boolean;
clientMode?: GrokBuildClientMode;
userId?: string | null;
email?: string | null;
principalType?: string | null;
};
function getWireEmail(email?: string | null, principalType?: string | null): string | null {
const normalizedPrincipalType = principalType?.trim().toLowerCase();
return normalizedPrincipalType === "team" || normalizedPrincipalType === "organization"
? null
: email || null;
}
function mapPlatform(platform: string): string {
if (platform === "darwin") return "macos";
if (platform === "win32") return "windows";
return platform;
}
function mapArch(arch: string): string {
if (arch === "arm64") return "aarch64";
if (arch === "x64") return "x86_64";
return arch;
}
export function getGrokBuildClientVersion(): string {
return GROK_BUILD_DEFAULT_CLIENT_VERSION;
}
export function getGrokBuildUserAgent(): string {
return `${GROK_BUILD_CLIENT_IDENTIFIER}/${getGrokBuildClientVersion()} (${mapPlatform(
getRuntimePlatform()
)}; ${mapArch(getRuntimeArch())})`;
}
export function getGrokBuildClientHeaders(
clientMode: GrokBuildClientMode = "headless"
): Record<string, string> {
return {
"x-grok-client-version": getGrokBuildClientVersion(),
"x-grok-client-identifier": GROK_BUILD_CLIENT_IDENTIFIER,
"x-grok-client-mode": clientMode,
"User-Agent": getGrokBuildUserAgent(),
};
}
export function getGrokBuildSessionHeaders({
token,
model,
stream = false,
clientMode = "headless",
userId,
email,
principalType,
}: GrokBuildSessionHeaderOptions = {}): Record<string, string> {
const wireEmail = getWireEmail(email, principalType);
return {
"Content-Type": "application/json",
Accept: stream ? "text/event-stream" : "application/json",
...getGrokBuildClientHeaders(clientMode),
"X-XAI-Token-Auth": GROK_BUILD_TOKEN_AUTH,
"x-authenticateresponse": "authenticate-response",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(model ? { "x-grok-model-override": model } : {}),
...(userId
? {
"x-userid": userId,
"x-grok-user-id": userId,
}
: {}),
...(wireEmail ? { "x-email": wireEmail } : {}),
};
}
export function getGrokBuildOAuthHeaders(
surface: GrokBuildClientSurface = "ui"
): Record<string, string> {
return {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"x-grok-client-version": getGrokBuildClientVersion(),
"x-grok-client-surface": surface,
};
}
export function getGrokBuildModelsHeaders({
token,
userId,
email,
principalType,
}: Pick<GrokBuildSessionHeaderOptions, "token" | "userId" | "email" | "principalType">): Record<
string,
string
> {
const wireEmail = getWireEmail(email, principalType);
return {
Accept: "application/json",
...getGrokBuildClientHeaders("headless"),
"X-XAI-Token-Auth": GROK_BUILD_TOKEN_AUTH,
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(userId ? { "x-userid": userId } : {}),
...(wireEmail ? { "x-email": wireEmail } : {}),
};
}

View File

@@ -1,50 +1,47 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred } from "../../shared.ts";
import {
getGrokBuildClientVersion,
GROK_BUILD_MODELS_URL,
GROK_BUILD_TOKEN_URL,
} from "../../../grokBuild.ts";
export const grok_cliProvider: RegistryEntry = {
id: "grok-cli",
alias: "gc",
format: "openai",
executor: "grok-cli",
// Keep the generic translate-path contract stable. GrokCliExecutor owns the
// official Grok Build upstream URL and always dispatches to /v1/responses.
baseUrl: "https://cli-chat-proxy.grok.com/v1/chat/completions",
modelsUrl: GROK_BUILD_MODELS_URL,
clientVersion: getGrokBuildClientVersion(),
authType: "oauth",
authHeader: "bearer",
passthroughModels: true,
models: [
{
id: "grok-build",
name: "Grok Build",
contextLength: 256000,
// cli-chat-proxy rejects reasoning_effort/reasoning outright (see grok-cli.ts
// executor's transformRequest, which strips them unconditionally for this model).
supportsReasoning: false,
unsupportedParams: [
"presencePenalty",
"frequencyPenalty",
"logprobs",
"topLogprobs",
"reasoningEffort",
],
id: "grok-4.5",
name: "Grok 4.5",
contextLength: 500000,
supportsReasoning: true,
toolCalling: true,
targetFormat: "openai-responses",
unsupportedParams: ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"],
},
{
id: "grok-composer-2.5-fast",
name: "Grok Composer 2.5 Fast",
name: "Composer 2.5",
contextLength: 200000,
// cli-chat-proxy rejects reasoning_effort/reasoning outright (see grok-cli.ts
// executor's transformRequest, which strips them unconditionally for this model).
supportsReasoning: false,
unsupportedParams: [
"presencePenalty",
"frequencyPenalty",
"logprobs",
"topLogprobs",
"reasoningEffort",
],
toolCalling: true,
targetFormat: "openai-responses",
unsupportedParams: ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"],
},
],
oauth: {
clientIdEnv: "GROK_OAUTH_CLIENT_ID",
clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
tokenUrl: "https://auth.x.ai/oauth2/token",
tokenUrl: GROK_BUILD_TOKEN_URL,
},
};

View File

@@ -142,6 +142,7 @@ export type ProviderCredentials = {
accessToken?: string;
refreshToken?: string;
apiKey?: string;
email?: string | null;
projectId?: string | null;
expiresAt?: string;
connectionId?: string; // T07: used for API key rotation index

View File

@@ -1,74 +1,139 @@
/**
* 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
* (only for the no-proxy direct path — see resolveGrokRequestDispatch below).
* Supports automatic token refresh via refresh_token.
* Routes Responses API requests through Grok's chat proxy using OAuth authentication.
* The standard BaseExecutor transport provides streaming, retries, abort propagation,
* proxy-aware fetch dispatch, upstream-header merging, and credential-refresh persistence.
*/
import {
BaseExecutor,
type ExecuteInput,
type ExecutorLog,
type ProviderCredentials,
} from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
import {
getGrokBuildSessionHeaders,
GROK_BUILD_DEFAULT_REASONING_EFFORT,
GROK_BUILD_REASONING_INCLUDE,
GROK_BUILD_RESPONSES_URL,
GROK_BUILD_TOKEN_URL,
} from "../config/grokBuild.ts";
import { resolvePublicCred } from "../utils/publicCreds.ts";
import { resolveProxyForRequest } from "../utils/proxyFetch.ts";
import { runWithOnPersist, isUnrecoverableRefreshError } from "../services/tokenRefresh.ts";
import https from "node:https";
import { HttpsProxyAgent } from "https-proxy-agent";
import { BaseExecutor, type ExecutorLog, type ProviderCredentials } from "./base.ts";
const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token";
const REQUEST_TIMEOUT_MS = 60_000;
// xAI cli-chat-proxy hard limit on tools per request.
const MAX_TOOLS = 200;
const GROK_BUILD_MAX_TOOLS = 200;
const GROK_BUILD_SUPPORTED_REASONING_EFFORTS = new Set(["low", "medium", "high"]);
const GROK_BUILD_REFRESH_MAX_ATTEMPTS = 3;
const GROK_BUILD_REFRESH_MIN_DELAY_MS = 200;
const GROK_BUILD_TERMINAL_REFRESH_ERRORS = new Set(["invalid_grant", "invalid_client"]);
const GROK_BUILD_UNSUPPORTED_PARAMS = [
"presencePenalty",
"frequencyPenalty",
"logprobs",
"topLogprobs",
"presence_penalty",
"frequency_penalty",
"top_logprobs",
"reasoning_effort",
];
type ProxyResolution = { source: string; proxyUrl: string | null };
type GrokRequestDispatch = { agent?: https.Agent; family?: 4 };
type GrokBuildRefreshResult = Partial<ProviderCredentials> | null | undefined;
/**
* Resolve how a Grok Build request to `targetUrl` should egress: through the
* operator's configured proxy (connection/provider/global — whatever the caller
* already pinned via `runWithProxyContext` upstream in chatHelpers.ts) when one
* is set, or direct with the existing forced-IPv4 workaround when none is.
*
* This executor talks to Grok via raw `https.request()` instead of the global
* patched `fetch()` (every other executor's path), so it never consulted the
* proxy context at all — a configured proxy was silently ignored and the
* request always egressed on the host's real IP. Only HTTP/HTTPS (CONNECT)
* proxies are supported here; an explicitly configured proxy of another kind
* (e.g. SOCKS5) fails closed rather than silently falling back to direct,
* matching the "fail closed for OAuth usage account proxies" convention (#3051).
*
* `resolveProxy` is injectable for tests; defaults to the shared
* `resolveProxyForRequest` used by the patched global fetch.
*/
export function resolveGrokRequestDispatch(
targetUrl: string,
resolveProxy: (url: string) => ProxyResolution = resolveProxyForRequest
): GrokRequestDispatch {
const { proxyUrl } = resolveProxy(targetUrl);
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
if (!proxyUrl) {
return { family: 4 };
}
let protocol: string;
try {
protocol = new URL(proxyUrl).protocol;
} catch {
throw new Error("Grok Build: configured proxy URL could not be parsed");
}
if (protocol === "http:" || protocol === "https:") {
return { agent: new HttpsProxyAgent(proxyUrl) as unknown as https.Agent };
}
throw new Error(
"Grok Build: configured proxy protocol is not supported for this provider (HTTP/HTTPS proxies only)"
function getRefreshRetryDelayMs(retryNumber: number): number {
const baseDelay = Math.min(
2_000,
GROK_BUILD_REFRESH_MIN_DELAY_MS * 2 ** Math.max(0, retryNumber - 1)
);
return Math.max(1, Math.round(baseDelay * (0.5 + Math.random())));
}
function asRequestRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
}
function ensureReasoningInclude(value: unknown): unknown[] {
const include = Array.isArray(value) ? [...value] : [];
if (!include.includes(GROK_BUILD_REASONING_INCLUDE)) {
include.push(GROK_BUILD_REASONING_INCLUDE);
}
return include;
}
function normalizeGrokBuildReasoning(
value: unknown,
model: string
): Record<string, unknown> | null {
const reasoning = asRequestRecord(value);
const hasExplicitEffort = Object.prototype.hasOwnProperty.call(reasoning, "effort");
if (!GROK_BUILD_SUPPORTED_REASONING_EFFORTS.has(String(reasoning.effort))) {
delete reasoning.effort;
}
if (model === "grok-composer-2.5-fast") {
delete reasoning.effort;
} else if (model === "grok-4.5" && !hasExplicitEffort) {
reasoning.effort = GROK_BUILD_DEFAULT_REASONING_EFFORT;
}
return Object.keys(reasoning).length > 0 ? reasoning : null;
}
function stripUnsupportedGrokBuildParams(request: Record<string, unknown>): void {
for (const param of GROK_BUILD_UNSUPPORTED_PARAMS) {
delete request[param];
}
}
async function refreshGrokBuildCredentialsOnce(
body: URLSearchParams,
credentials: ProviderCredentials,
attempt: number,
log?: ExecutorLog | null
): Promise<GrokBuildRefreshResult> {
try {
const response = await fetch(GROK_BUILD_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
signal: AbortSignal.timeout(15_000),
});
const data = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (!response.ok) {
const errorCode = nonEmptyString(data.error);
const isTerminal =
attempt === GROK_BUILD_REFRESH_MAX_ATTEMPTS ||
(errorCode !== null && GROK_BUILD_TERMINAL_REFRESH_ERRORS.has(errorCode));
log?.warn?.("TOKEN_REFRESH", `Grok Build: refresh failed with status ${response.status}`);
return isTerminal ? null : undefined;
}
const accessToken = nonEmptyString(data.access_token);
if (!accessToken) {
log?.warn?.("TOKEN_REFRESH", "Grok Build: no access_token in refresh response");
return attempt === GROK_BUILD_REFRESH_MAX_ATTEMPTS ? null : undefined;
}
const expiresIn =
typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0
? 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,
refreshToken: nonEmptyString(data.refresh_token) || credentials.refreshToken,
expiresAt,
};
} catch (error) {
log?.warn?.(
"TOKEN_REFRESH",
`Grok Build: refresh error: ${error instanceof Error ? error.message : String(error)}`
);
return attempt === GROK_BUILD_REFRESH_MAX_ATTEMPTS ? null : undefined;
}
}
export class GrokCliExecutor extends BaseExecutor {
@@ -76,77 +141,13 @@ export class GrokCliExecutor extends BaseExecutor {
super("grok-cli", PROVIDERS["grok-cli"]);
}
async execute(input: ExecuteInput) {
const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input;
// #7610: unlike BaseExecutor.execute() (which most executors inherit or
// delegate to via super.execute()), this executor talks upstream via raw
// https.request() (nativePost) instead of the shared fetch path, so it
// never picked up the base class's proactive refresh gate. Without it,
// xAI's rotating refresh_token idled until real expiry — the only refresh
// that fired was the reactive one on a 401/403 from upstream — matching
// the "unusable within minutes" report. Apply the same gate here.
const activeCredentials = await this.applyProactiveRefresh(
credentials,
log,
onCredentialsRefreshed
);
const url = this.buildUrl(model, stream, 0, activeCredentials);
const headers = this.buildHeaders(activeCredentials, stream);
const transformedBody = this.transformRequest(model, body, stream, activeCredentials);
const bodyStr = JSON.stringify(transformedBody);
const response = await this.nativePost(url, headers, bodyStr, signal);
return { response, url, headers, transformedBody };
}
/**
* Proactive-refresh gate mirroring BaseExecutor.execute()'s (base.ts:599-685),
* scoped to grok-cli's single-URL nativePost dispatch (no fallback-URL retry
* loop to thread through). xAI uses rotating refresh tokens (same family as
* Codex/Claude) — `runWithOnPersist` keeps the [refresh + persist] atomic
* under the same per-connection mutex `getAccessToken` uses, and
* `isUnrecoverableRefreshError` keeps a reused/invalid sentinel from being
* spread into the outgoing credentials — see base.ts:622-673 for the full
* regression history this mirrors.
*/
private async applyProactiveRefresh(
credentials: ProviderCredentials,
log?: ExecutorLog | null,
onCredentialsRefreshed?: ExecuteInput["onCredentialsRefreshed"]
): Promise<ProviderCredentials> {
if (!this.needsRefresh(credentials)) return credentials;
try {
let persistRan = false;
const onPersist = onCredentialsRefreshed
? async (refreshResult: Record<string, unknown>) => {
persistRan = true;
await onCredentialsRefreshed(refreshResult as Partial<ProviderCredentials>);
}
: null;
const refreshed = await runWithOnPersist(onPersist, () =>
this.refreshCredentials(credentials, log || null)
);
if (!refreshed || isUnrecoverableRefreshError(refreshed)) {
return credentials;
}
const merged = { ...credentials, ...refreshed };
if (onCredentialsRefreshed && !persistRan) {
await onCredentialsRefreshed(refreshed);
}
return merged;
} catch (error) {
log?.error?.(
"TOKEN",
`Credential refresh failed for ${this.provider}: ${error instanceof Error ? error.message : String(error)}`
);
return credentials;
}
buildUrl(
_model: string,
_stream: boolean,
_urlIndex = 0,
_credentials: ProviderCredentials | null = null
) {
return GROK_BUILD_RESPONSES_URL;
}
async refreshCredentials(
@@ -160,188 +161,58 @@ export class GrokCliExecutor extends BaseExecutor {
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);
const dispatch = resolveGrokRequestDispatch(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",
...(dispatch.family ? { family: dispatch.family } : {}),
...(dispatch.agent ? { agent: dispatch.agent } : {}),
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();
const body = new URLSearchParams({
grant_type: "refresh_token",
client_id: clientId,
refresh_token: credentials.refreshToken,
});
}
private nativePost(
url: string,
headers: Record<string, string>,
bodyStr: string,
signal?: AbortSignal | null
): Promise<Response> {
const urlObj = new URL(url);
const dispatch = resolveGrokRequestDispatch(url);
const providerData = credentials.providerSpecificData || {};
const principalType = nonEmptyString(providerData.principalType);
const principalId = nonEmptyString(providerData.principalId);
if (principalType) body.set("principal_type", principalType);
if (principalId) body.set("principal_id", principalId);
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",
...(dispatch.family ? { family: dispatch.family } : {}),
...(dispatch.agent ? { agent: dispatch.agent } : {}),
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));
for (let attempt = 1; attempt <= GROK_BUILD_REFRESH_MAX_ATTEMPTS; attempt++) {
if (attempt > 1) {
const delayMs = getRefreshRetryDelayMs(attempt - 1);
log?.debug?.(
"TOKEN_REFRESH",
`Grok Build: retrying token refresh (${attempt}/${GROK_BUILD_REFRESH_MAX_ATTEMPTS})`
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
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}`;
const refreshed = await refreshGrokBuildCredentialsOnce(body, credentials, attempt, log);
if (refreshed !== undefined) return refreshed;
}
headers["Accept"] = stream ? "text/event-stream" : "application/json";
headers["x-grok-client-version"] = "0.2.72";
headers["x-grok-client-identifier"] = "grok_cli_rs";
headers["User-Agent"] = "grok-cli/0.2.72 (Windows 10.0.26200; x64)";
return null;
}
return headers;
buildHeaders(
credentials: ProviderCredentials,
stream = true,
clientHeaders?: Record<string, string> | null,
model?: string
) {
const headers = super.buildHeaders(credentials, stream, clientHeaders, model);
const providerData = credentials.providerSpecificData || {};
const principalType = nonEmptyString(providerData.principalType);
const sessionHeaders = getGrokBuildSessionHeaders({
model,
stream,
userId: nonEmptyString(providerData.userId),
email: nonEmptyString(credentials.email) || nonEmptyString(providerData.email),
principalType,
});
// Preserve the standard GROK_CLI_USER_AGENT override produced by BaseExecutor.
if (headers["User-Agent"] || headers["user-agent"]) {
delete sessionHeaders["User-Agent"];
}
return { ...headers, ...sessionHeaders };
}
transformRequest(
@@ -350,36 +221,30 @@ export class GrokCliExecutor extends BaseExecutor {
stream: boolean,
_credentials: ProviderCredentials
) {
const transformed =
body && typeof body === "object" ? { ...(body as Record<string, unknown>) } : {};
const base = super.transformRequest(model, body, stream, _credentials);
const transformed = asRequestRecord(base);
if (!transformed.model) {
transformed.model = model || "grok-composer-2.5-fast";
}
transformed.stream = !!stream;
// Grok Build rejects unsupported parameters with 400. `reasoning_effort`/`reasoning`
// are sent by clients like Claude Code (routing the Opus slot) but are not accepted
// by Grok Build's upstream chat-proxy endpoint — see #6288.
const UNSUPPORTED = [
"presencePenalty",
"frequencyPenalty",
"logprobs",
"topLogprobs",
"reasoning_effort",
"reasoning",
];
for (const param of UNSUPPORTED) {
if (param in transformed) {
delete transformed[param];
}
// Grok Build applies these Responses defaults before every request.
if (transformed.store === undefined) transformed.store = false;
transformed.include = ensureReasoningInclude(transformed.include);
// OpenAI-compatible clients may carry fields the Grok Responses endpoint rejects.
stripUnsupportedGrokBuildParams(transformed);
const reasoning = normalizeGrokBuildReasoning(transformed.reasoning, model);
if (reasoning) {
transformed.reasoning = reasoning;
} else {
delete transformed.reasoning;
}
// xAI's cli-chat-proxy enforces a maximum of 200 tools per request and
// 400s above that ceiling. Clients that fan a large MCP toolset through
// Grok Build/Composer (e.g. Claude Code with many registered tools) can
// exceed it — cap defensively rather than let the request fail upstream.
if (Array.isArray(transformed.tools) && transformed.tools.length > MAX_TOOLS) {
transformed.tools = transformed.tools.slice(0, MAX_TOOLS);
// xAI's cli-chat-proxy rejects requests containing more than 200 tools.
if (Array.isArray(transformed.tools) && transformed.tools.length > GROK_BUILD_MAX_TOOLS) {
transformed.tools = transformed.tools.slice(0, GROK_BUILD_MAX_TOOLS);
}
return transformed;

View File

@@ -55,6 +55,16 @@ const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "xai-oauth"]);
*/
const BROWSER_DEVICE_FLOW_PROVIDERS = new Set(["codex"]);
/** Device Code providers whose token grant does not use a PKCE verifier. */
const NO_PKCE_DEVICE_CODE_PROVIDERS = new Set([
"github",
"kimi-coding",
"kilocode",
"codebuddy-cn",
"grok-cli",
"ghe-copilot",
]);
/**
* Providers whose PKCE flow has been retired but whose import-token path is
* still active. Returning 410 Gone on `authorize` / `start-callback-server` /
@@ -203,15 +213,11 @@ export async function GET(
// Request device code (through proxy if configured)
let deviceData;
if (
provider === "github" ||
NO_PKCE_DEVICE_CODE_PROVIDERS.has(provider) ||
provider === "kiro" ||
provider === "amazon-q" ||
provider === "kimi-coding" ||
provider === "kilocode" ||
provider === "codebuddy-cn" ||
provider === "ghe-copilot"
provider === "amazon-q"
) {
// GitHub, Kiro/Amazon Q, Kimi Coding, KiloCode, and GHE Copilot don't use PKCE for device code
// These providers don't use PKCE for device code.
if (provider === "ghe-copilot" && gheUrl) {
// GHE Copilot targets the enterprise host configured via gheUrl
const providerOverrideConfig = {
@@ -562,13 +568,8 @@ export async function POST(
// Poll for token (through proxy if configured)
let result;
if (
provider === "github" ||
provider === "kimi-coding" ||
provider === "kilocode" ||
provider === "codebuddy-cn"
) {
// For providers that don't use PKCE (GitHub, Kimi Coding, KiloCode), don't pass codeVerifier
if (NO_PKCE_DEVICE_CODE_PROVIDERS.has(provider)) {
// Non-PKCE device providers do not receive a code verifier.
result = await runWithProxyContextOrDirect(proxy, () =>
(pollForToken as any)(provider, deviceCode)
);

View File

@@ -1,4 +1,9 @@
import { getAntigravityModelsDiscoveryUrls } from "@omniroute/open-sse/config/antigravityUpstream.ts";
import {
GROK_BUILD_DEFAULT_CONTEXT_WINDOW,
getGrokBuildModelsHeaders,
GROK_BUILD_MODELS_URL,
} from "@omniroute/open-sse/config/grokBuild.ts";
import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts";
import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser";
import { filterClinepassModels } from "@omniroute/open-sse/services/clinepassModels.ts";
@@ -76,6 +81,11 @@ export function parseAlibabaModelStudioModels(data: any): any[] {
export function parseQwenCloudTextModels(data: any): any[] {
return parseCuratedDashscopeModels(data, QWEN_CLOUD_TEXT_MODELS, QWEN_CLOUD_TEXT_MODEL_IDS);
}
type ProviderModelsHeaderContext = {
authType?: string;
providerSpecificData?: unknown;
email?: string | null;
};
export type ProviderModelsConfigEntry = {
url: string;
@@ -85,7 +95,10 @@ export type ProviderModelsConfigEntry = {
authPrefix?: string;
authQuery?: string;
body?: unknown;
buildHeaders?: (token: string, connection?: any) => Record<string, string>;
buildHeaders?: (
token: string,
connection?: ProviderModelsHeaderContext
) => Record<string, string>;
parseResponse: (data: any) => any;
};
@@ -175,6 +188,118 @@ export function parseKimiCodingModels(data: any): any[] {
.map(normalizeKimiCodingModel);
}
type GrokBuildModelRecord = Record<string, unknown>;
function asGrokBuildRecord(value: unknown): GrokBuildModelRecord {
return value && typeof value === "object" && !Array.isArray(value)
? (value as GrokBuildModelRecord)
: {};
}
function grokBuildString(...values: unknown[]): string | undefined {
return values
.find((value): value is string => typeof value === "string" && value.trim().length > 0)
?.trim();
}
function grokBuildPositiveNumber(...values: unknown[]): number | undefined {
return values.find(
(value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0
);
}
function getGrokBuildModelItems(data: unknown): unknown[] {
const envelope = asGrokBuildRecord(data);
if (Array.isArray(data)) return data;
if (Array.isArray(envelope.data)) return envelope.data;
return Array.isArray(envelope.models) ? envelope.models : [];
}
function hasGrokBuildReasoning(model: GrokBuildModelRecord, metadata: GrokBuildModelRecord) {
const flags = [
model.supportsReasoningEffort,
model.supports_reasoning_effort,
metadata.supportsReasoningEffort,
metadata.supports_reasoning_effort,
];
const effortLists = [
model.reasoningEfforts,
model.reasoning_efforts,
metadata.reasoningEfforts,
metadata.reasoning_efforts,
];
return (
flags.some((value) => value === true) ||
grokBuildString(
model.reasoningEffort,
model.reasoning_effort,
metadata.reasoningEffort,
metadata.reasoning_effort
) !== undefined ||
effortLists.some((value) => Array.isArray(value) && value.length > 0)
);
}
function normalizeGrokBuildModel(value: unknown): GrokBuildModelRecord | null {
const model = asGrokBuildRecord(value);
const metadata = asGrokBuildRecord(model._meta);
const catalogId = grokBuildString(model.id);
const id = grokBuildString(
model.model,
model.modelId,
catalogId,
metadata.model,
metadata.modelId
);
const hidden = model.hidden === true || metadata.hidden === true;
// grok-cli always uses OAuth session auth. Official Grok Build visibility
// keeps supported_in_api=false models available to session users and only
// hides them from API-key users.
if (!id || hidden) return null;
const backend = grokBuildString(
model.apiBackend,
model.api_backend,
metadata.apiBackend,
metadata.api_backend
);
// This provider currently executes against /v1/responses. Grok Build can
// advertise chat_completions or messages backends too, but exposing those
// here would route their request shape to the wrong upstream endpoint.
if (backend !== "responses") return null;
const inputTokenLimit =
grokBuildPositiveNumber(
model.contextWindow,
model.context_window,
metadata.contextWindow,
metadata.totalContextTokens
) || GROK_BUILD_DEFAULT_CONTEXT_WINDOW;
const outputTokenLimit = grokBuildPositiveNumber(
model.maxCompletionTokens,
model.max_completion_tokens
);
const description = grokBuildString(model.description);
return {
id,
name: grokBuildString(model.name, id) || id,
owned_by: "grok-cli",
...(description ? { description } : {}),
inputTokenLimit,
...(outputTokenLimit ? { outputTokenLimit } : {}),
...(hasGrokBuildReasoning(model, metadata) ? { supportsThinking: true } : {}),
apiFormat: "responses",
supportedEndpoints: ["responses"],
};
}
function parseGrokBuildModels(data: unknown): GrokBuildModelRecord[] {
return getGrokBuildModelItems(data)
.map(normalizeGrokBuildModel)
.filter((model): model is GrokBuildModelRecord => model !== null);
}
const KIMI_CODING_MODELS_CONFIG: ProviderModelsConfigEntry = {
url: KIMI_CODING_MODELS_URL,
method: "GET",
@@ -316,6 +441,21 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
"grok-cli": {
url: GROK_BUILD_MODELS_URL,
method: "GET",
headers: {},
buildHeaders: (token, context) => {
const providerData = asGrokBuildRecord(context?.providerSpecificData);
return getGrokBuildModelsHeaders({
token,
userId: grokBuildString(providerData.userId),
email: grokBuildString(context?.email, providerData.email),
principalType: grokBuildString(providerData.principalType),
});
},
parseResponse: parseGrokBuildModels,
},
openrouter: {
url: "https://openrouter.ai/api/v1/models",
method: "GET",

View File

@@ -13,6 +13,12 @@ import {
GITHUB_COPILOT_CHAT_USER_AGENT,
GITHUB_COPILOT_EDITOR_VERSION,
} from "@omniroute/open-sse/config/providerHeaderProfiles.ts";
import {
GROK_BUILD_DEVICE_CODE_URL,
GROK_BUILD_OAUTH_ISSUER,
GROK_BUILD_OAUTH_SCOPES,
GROK_BUILD_TOKEN_URL,
} from "@omniroute/open-sse/config/grokBuild.ts";
import { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds.ts";
import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitlab";
@@ -110,11 +116,14 @@ export const CODEBUDDY_CN_CONFIG = {
pollInterval: 5000,
};
// Grok Build (xAI) OAuth Configuration (Import-Token Flow with refresh)
// Grok Build (xAI) OAuth Configuration (Device Code + import-token fallback)
// Public client_id resolved through resolvePublicCred so it is never a literal.
export const GROK_CLI_CONFIG = {
clientId: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
tokenUrl: "https://auth.x.ai/oauth2/token",
issuer: GROK_BUILD_OAUTH_ISSUER,
deviceCodeUrl: GROK_BUILD_DEVICE_CODE_URL,
tokenUrl: GROK_BUILD_TOKEN_URL,
scope: GROK_BUILD_OAUTH_SCOPES.join(" "),
};
// xAI API OAuth Configuration (Authorization Code Flow with PKCE)

View File

@@ -1,11 +1,16 @@
/**
* Grok Build OAuth Provider — Import Token Flow with Refresh Support
* Grok Build OAuth Provider — Device Code Flow with Import Token Fallback
*
* 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 {
getGrokBuildOAuthHeaders,
GROK_BUILD_OAUTH_ISSUER,
GROK_BUILD_OAUTH_REFERRER,
} from "@omniroute/open-sse/config/grokBuild.ts";
import { GROK_CLI_CONFIG } from "../constants/oauth";
interface GrokCliAuthInfo {
@@ -14,43 +19,179 @@ interface GrokCliAuthInfo {
team_id: string;
tier: number;
principal_type: string;
principal_id: string;
organization_id: string;
}
function parseJwtPayload(token: string): {
const EMPTY_STANDARD_TOKEN_FIELDS = {
idToken: null,
tokenType: null,
scope: null,
oauthExpiresIn: null,
} as const;
async function parseOAuthResponse(response: Response): Promise<Record<string, unknown>> {
try {
const value = await response.json();
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
} catch {
return {
error: "invalid_response",
error_description: "xAI returned a non-JSON OAuth response",
};
}
}
function validateVerificationUri(value: string): void {
if (
[...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 0x1f || codePoint === 0x7f;
})
) {
throw new Error("Grok returned an invalid verification URL");
}
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("Grok returned an invalid verification URL");
}
const isLocalHttp =
url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
if (url.protocol !== "https:" && !isLocalHttp) {
throw new Error("Grok returned an unsupported verification URL");
}
}
async function requestDeviceCode(config: typeof GROK_CLI_CONFIG) {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: getGrokBuildOAuthHeaders("ui"),
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
referrer: GROK_BUILD_OAUTH_REFERRER,
}),
});
const data = await parseOAuthResponse(response);
if (!response.ok) {
throw new Error(
typeof data.error_description === "string"
? data.error_description
: "Grok device authorization failed"
);
}
if (
typeof data.device_code !== "string" ||
typeof data.user_code !== "string" ||
typeof data.verification_uri !== "string"
) {
throw new Error("Grok device authorization response is incomplete");
}
if (!/^[A-Za-z0-9-]+$/.test(data.user_code)) {
throw new Error("Grok returned an invalid device code");
}
validateVerificationUri(data.verification_uri);
if (typeof data.verification_uri_complete === "string") {
validateVerificationUri(data.verification_uri_complete);
}
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri,
verification_uri_complete:
typeof data.verification_uri_complete === "string"
? data.verification_uri_complete
: data.verification_uri,
expires_in: typeof data.expires_in === "number" ? data.expires_in : 1800,
interval: typeof data.interval === "number" ? data.interval : 5,
};
}
async function pollToken(config: typeof GROK_CLI_CONFIG, deviceCode: string) {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: getGrokBuildOAuthHeaders("ui"),
body: new URLSearchParams({
client_id: config.clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
return { ok: response.ok, data: await parseOAuthResponse(response) };
}
type ParsedGrokJwt = {
email: string | null;
authInfo: GrokCliAuthInfo | null;
exp: number | null;
} {
try {
const parts = token.split(".");
if (parts.length !== 3) return { email: null, authInfo: null, exp: null };
};
let base64 = parts[1];
switch (base64.length % 4) {
case 2:
base64 += "==";
break;
case 3:
base64 += "=";
break;
}
base64 = base64.replace(/-/g, "+").replace(/_/g, "/");
function emptyGrokJwt(): ParsedGrokJwt {
return { email: null, authInfo: null, exp: null };
}
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",
},
exp: typeof payload.exp === "number" ? payload.exp : null,
};
} catch {
return { email: null, authInfo: null, exp: null };
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
let base64 = parts[1];
switch (base64.length % 4) {
case 2:
base64 += "==";
break;
case 3:
base64 += "=";
break;
}
base64 = base64.replace(/-/g, "+").replace(/_/g, "/");
try {
const payload = JSON.parse(Buffer.from(base64, "base64").toString("utf-8"));
return payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function jwtString(payload: Record<string, unknown>, key: string): string {
return typeof payload[key] === "string" ? payload[key] : "";
}
function parseJwtPayload(token: string): ParsedGrokJwt {
const payload = decodeJwtPayload(token);
if (!payload) return emptyGrokJwt();
const principalType = jwtString(payload, "principal_type");
const principalId = jwtString(payload, "principal_id");
const normalizedPrincipalType = principalType.toLowerCase();
const isTeamPrincipal = normalizedPrincipalType === "team" && principalId.length > 0;
const isOrganizationPrincipal =
normalizedPrincipalType === "organization" && principalId.length > 0;
const email = jwtString(payload, "email");
return {
email: email || null,
authInfo: {
user_id: isTeamPrincipal || isOrganizationPrincipal ? principalId : jwtString(payload, "sub"),
email,
team_id: jwtString(payload, "team_id") || (isTeamPrincipal ? principalId : ""),
tier: (payload.tier as number) || 1,
principal_type: principalType,
principal_id: principalId,
organization_id:
jwtString(payload, "organization_id") || (isOrganizationPrincipal ? principalId : ""),
},
exp: typeof payload.exp === "number" ? payload.exp : null,
};
}
/**
@@ -62,16 +203,42 @@ function parseJwtPayload(token: string): {
function extractTokenAndRefresh(input: unknown): {
accessToken: string;
refreshToken: string | null;
idToken: string | null;
tokenType: string | null;
scope: string | null;
oauthExpiresIn: number | null;
rawAuthJson: Record<string, unknown> | null;
expiresAt: string | null;
} {
// Direct JWT string
if (typeof input === "string")
return { accessToken: input, refreshToken: null, rawAuthJson: null, expiresAt: null };
return {
...EMPTY_STANDARD_TOKEN_FIELDS,
accessToken: input,
refreshToken: null,
rawAuthJson: null,
expiresAt: null,
};
if (input && typeof input === "object") {
const obj = input as Record<string, unknown>;
if (typeof obj.access_token === "string" && obj.access_token.length > 0) {
return {
accessToken: obj.access_token,
refreshToken: typeof obj.refresh_token === "string" ? obj.refresh_token : null,
idToken: typeof obj.id_token === "string" ? obj.id_token : null,
tokenType: typeof obj.token_type === "string" ? obj.token_type : null,
scope: typeof obj.scope === "string" ? obj.scope : null,
oauthExpiresIn:
typeof obj.expires_in === "number" && Number.isFinite(obj.expires_in)
? obj.expires_in
: null,
rawAuthJson: null,
expiresAt: null,
};
}
// The route handler wraps the token: { accessToken: <token> }.
// Unwrap once before checking the inner value.
const inner =
@@ -81,13 +248,18 @@ function extractTokenAndRefresh(input: unknown): {
// auth.json format: { "https://auth.x.ai::...": { key: "eyJ...", refresh_token: "..." } }
if (inner && typeof inner === "object") {
const preferredScope = `${GROK_BUILD_OAUTH_ISSUER}::${GROK_CLI_CONFIG.clientId}`;
const innerKeys = Object.keys(inner);
for (const k of innerKeys) {
const orderedKeys = innerKeys.includes(preferredScope)
? [preferredScope, ...innerKeys.filter((key) => key !== preferredScope)]
: innerKeys;
for (const k of orderedKeys) {
const entry = inner[k];
if (entry && typeof entry === "object" && "key" in entry) {
const e = entry as Record<string, unknown>;
if (typeof e.key === "string" && e.key.startsWith("eyJ")) {
return {
...EMPTY_STANDARD_TOKEN_FIELDS,
accessToken: e.key,
refreshToken: typeof e.refresh_token === "string" ? e.refresh_token : null,
rawAuthJson: inner as Record<string, unknown>,
@@ -101,6 +273,7 @@ function extractTokenAndRefresh(input: unknown): {
// Raw JWT passed as { accessToken: "eyJ..." }
if (typeof obj.accessToken === "string" && obj.accessToken.length > 0) {
return {
...EMPTY_STANDARD_TOKEN_FIELDS,
accessToken: obj.accessToken,
refreshToken: typeof obj.refreshToken === "string" ? obj.refreshToken : null,
rawAuthJson: null,
@@ -109,46 +282,96 @@ function extractTokenAndRefresh(input: unknown): {
}
}
return { accessToken: "", refreshToken: null, rawAuthJson: null, expiresAt: null };
return {
...EMPTY_STANDARD_TOKEN_FIELDS,
accessToken: "",
refreshToken: null,
rawAuthJson: null,
expiresAt: null,
};
}
type ExtractedGrokToken = ReturnType<typeof extractTokenAndRefresh>;
function firstString(...values: Array<string | null | undefined>): string | null {
return values.find((value) => Boolean(value)) || null;
}
function firstAuthInfoString(
primaryClaims: ParsedGrokJwt,
secondaryClaims: ParsedGrokJwt,
key: Exclude<keyof GrokCliAuthInfo, "tier">
): string | null {
return firstString(primaryClaims.authInfo?.[key], secondaryClaims.authInfo?.[key]);
}
function resolveGrokIdentity(accessClaims: ParsedGrokJwt, idClaims: ParsedGrokJwt) {
const principalType = firstAuthInfoString(accessClaims, idClaims, "principal_type");
const principalId = firstAuthInfoString(accessClaims, idClaims, "principal_id");
const normalizedPrincipalType = principalType?.toLowerCase();
const isTeamPrincipal = normalizedPrincipalType === "team" && Boolean(principalId);
const isOrganizationPrincipal =
normalizedPrincipalType === "organization" && Boolean(principalId);
return {
principalType,
principalId,
email: firstString(idClaims.email, accessClaims.email),
userId:
isTeamPrincipal || isOrganizationPrincipal
? principalId
: firstAuthInfoString(idClaims, accessClaims, "user_id"),
teamId: isTeamPrincipal ? principalId : firstAuthInfoString(accessClaims, idClaims, "team_id"),
organizationId: isOrganizationPrincipal
? principalId
: firstAuthInfoString(accessClaims, idClaims, "organization_id"),
};
}
function resolveGrokExpiresIn(extracted: ExtractedGrokToken, accessClaims: ParsedGrokJwt): number {
const currentSec = Math.floor(Date.now() / 1000);
let expiresIn = extracted.oauthExpiresIn ?? 21600;
if (extracted.oauthExpiresIn == null && extracted.expiresAt) {
const parsed = Date.parse(extracted.expiresAt);
if (!isNaN(parsed)) expiresIn = Math.floor(parsed / 1000) - currentSec;
} else if (extracted.oauthExpiresIn == null && accessClaims.exp) {
expiresIn = accessClaims.exp - currentSec;
}
// Keep an already-expired token eligible for the refresh path.
return Math.max(1, expiresIn);
}
export const grokCli = {
config: GROK_CLI_CONFIG,
flowType: "import_token",
mapTokens: (token: unknown, extra?: unknown) => {
const { accessToken, refreshToken, rawAuthJson, expiresAt } = extractTokenAndRefresh(token);
const { email, authInfo, exp } = parseJwtPayload(accessToken);
const currentSec = Math.floor(Date.now() / 1000);
let expiresIn = 21600;
if (expiresAt) {
const parsed = Date.parse(expiresAt);
if (!isNaN(parsed)) {
expiresIn = Math.floor(parsed / 1000) - currentSec;
}
} else if (typeof exp === "number" && exp > 0) {
expiresIn = exp - currentSec;
}
// #5775 follow-up: guard against an already-expired token yielding a negative
// expiresIn. A negative value is truthy downstream (import-token route) and maps
// to a PAST expiresAt, which AutoCombo reads as "already expired" and excludes the
// connection instead of refreshing it. Clamp to a tiny positive TTL so the token is
// treated as due-for-refresh.
expiresIn = Math.max(1, expiresIn);
flowType: "device_code",
requestDeviceCode,
pollToken,
mapTokens: (token: unknown, _extra?: unknown) => {
const extracted = extractTokenAndRefresh(token);
const accessClaims = parseJwtPayload(extracted.accessToken);
const idClaims = extracted.idToken ? parseJwtPayload(extracted.idToken) : emptyGrokJwt();
const identity = resolveGrokIdentity(accessClaims, idClaims);
const expiresIn = resolveGrokExpiresIn(extracted, accessClaims);
return {
accessToken,
refreshToken,
accessToken: extracted.accessToken,
refreshToken: extracted.refreshToken,
idToken: extracted.idToken,
expiresIn,
email,
tokenType: extracted.tokenType,
scope: extracted.scope,
email: identity.email,
providerSpecificData: {
userId: authInfo?.user_id || null,
teamId: authInfo?.team_id || null,
tier: authInfo?.tier || 1,
principalType: authInfo?.principal_type || "User",
rawAuthJson: rawAuthJson || undefined,
userId: identity.userId,
email: identity.email,
teamId: identity.teamId,
tier: accessClaims.authInfo?.tier || idClaims.authInfo?.tier || 1,
principalType: identity.principalType,
principalId: identity.principalId,
organizationId: identity.organizationId,
rawAuthJson: extracted.rawAuthJson || undefined,
},
};
},

View File

@@ -6,7 +6,7 @@ import Modal from "./Modal";
import Button from "./Button";
import Input from "./Input";
import LinkifiedText from "./LinkifiedText";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { OAuthDeviceCodePanel, OAuthManualInputPanel } from "./OAuthModalPanels";
import { parseResponseBody, getErrorMessage } from "@/shared/utils/api";
import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oauthBlobSubmit";
import {
@@ -15,18 +15,26 @@ import {
} from "@/lib/oauth/utils/codexSessionImport";
import GheConfigStep from "@/shared/components/oauthModal/GheConfigStep";
export { formatDeviceCodeRemaining } from "./OAuthModalPanels";
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy"]);
/** Providers that use a local callback server on a random port (PKCE browser flow). */
const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "xai-oauth"]);
/**
* Phase 1 hotfix (2026-05-29): windsurf & devin-cli only support import-token.
* Their PKCE flow targeting app.devin.ai/editor/signin returned 404 post-rebrand.
* Phase 2 will reintroduce browser login via Firebase OAuth + RegisterUser.
* Spec: _tasks/superpowers/specs/2026-05-29-windsurf-login-fix-design.md.
*/
const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]);
const DEVICE_CODE_PROVIDERS = new Set([
"github",
"kiro",
"amazon-q",
"kimi-coding",
"kilocode",
"codebuddy-cn",
"grok-cli",
"ghe-copilot",
]);
const TOKEN_PASTE_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]);
const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli"]);
// POST a bare Codex access token to the access-token-only import endpoint
// (#1290); shared by the bare-JWT and session-JSON paste branches (#6636).
@@ -59,6 +67,39 @@ type OAuthModalProps = {
reauthConnection?: null | { id?: string };
};
type DevicePollResult =
{ status: "pending" | "slow_down" | "success" } | { status: "error"; message: string };
function positiveNumberOr(value: unknown, fallback: number): number {
return Math.max(1, Number(value) || fallback);
}
async function pollDeviceCodeOnce(
provider: string | undefined,
payload: Record<string, unknown>
): Promise<DevicePollResult> {
try {
const res = await fetch(`/api/oauth/${provider}/poll`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = (await parseResponseBody(res)) as Record<string, unknown>;
if (data.success) return { status: "success" };
if (data.error === "slow_down") return { status: "slow_down" };
if (data.error && !data.pending) {
return { status: "error", message: String(data.errorDescription || data.error) };
}
return { status: "pending" };
} catch (error) {
return {
status: "error",
message: error instanceof Error ? error.message : "Authorization failed",
};
}
}
/**
* OAuth Modal Component
* - Localhost: Auto callback via popup message
@@ -82,20 +123,17 @@ export default function OAuthModal({
const [deviceData, setDeviceData] = useState(null);
const [gheUrl, setGheUrl] = useState("");
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 === "grok-cli"
);
const [deviceCodeExpiresAt, setDeviceCodeExpiresAt] = useState<number | null>(null);
const [deviceCodeSecondsRemaining, setDeviceCodeSecondsRemaining] = useState<number | null>(null);
// API-key paste mode for direct-token providers.
const [showPasteToken, setShowPasteToken] = useState(IMPORT_TOKEN_ONLY_PROVIDERS.has(provider));
const [pasteToken, setPasteToken] = useState("");
const [savingToken, setSavingToken] = useState(false);
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 supportsTokenPaste = TOKEN_PASTE_PROVIDERS.has(provider);
const importTokenOnly = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider);
const popupRef = useRef(null);
const { copied, copy } = useCopyToClipboard();
const deviceFlowRunRef = useRef(0);
const deviceVerificationUrl =
deviceData?.verification_uri_complete || deviceData?.verification_uri || "";
@@ -129,6 +167,13 @@ export default function OAuthModal({
const callbackProcessedRef = useRef(false);
const flowStartedRef = useRef(false);
const invalidateDeviceFlow = useCallback(() => {
deviceFlowRunRef.current += 1;
setPolling(false);
setDeviceCodeExpiresAt(null);
setDeviceCodeSecondsRemaining(null);
}, []);
// Define all useCallback hooks BEFORE the useEffects that reference them
// Exchange tokens
@@ -234,52 +279,56 @@ export default function OAuthModal({
// Poll for device code token
const startPolling = useCallback(
async (deviceCode, codeVerifier, interval, extraData) => {
async (deviceCode, codeVerifier, interval, expiresIn, extraData) => {
const runId = ++deviceFlowRunRef.current;
const safeInterval = positiveNumberOr(interval, 5);
const safeExpiresIn = positiveNumberOr(expiresIn, safeInterval * 60);
const deadline = Date.now() + safeExpiresIn * 1000;
let currentInterval = safeInterval;
setPolling(true);
const maxAttempts = 60;
setDeviceCodeExpiresAt(deadline);
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, interval * 1000));
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, currentInterval * 1000));
if (runId !== deviceFlowRunRef.current || Date.now() >= deadline) break;
try {
const res = await fetch(`/api/oauth/${provider}/poll`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deviceCode,
connectionId: reauthConnection?.id,
codeVerifier,
extraData,
}),
});
const result = await pollDeviceCodeOnce(provider, {
deviceCode,
connectionId: reauthConnection?.id,
codeVerifier,
extraData,
});
if (runId !== deviceFlowRunRef.current) return;
const data = (await parseResponseBody(res)) as Record<string, unknown>;
if (result.status === "success") {
setStep("success");
setPolling(false);
setDeviceCodeExpiresAt(null);
onSuccess?.();
return;
}
if (data.success) {
setStep("success");
setPolling(false);
onSuccess?.();
return;
}
if (result.status === "slow_down") {
currentInterval = Math.min(currentInterval + 5, 30);
continue;
}
if (data.error === "expired_token" || data.error === "access_denied") {
throw new Error(data.errorDescription || data.error);
}
if (data.error === "slow_down") {
interval = Math.min(interval + 5, 30);
}
} catch (err) {
setError(err.message);
if (result.status === "error") {
setError(result.message);
setStep("error");
setPolling(false);
setDeviceCodeExpiresAt(null);
return;
}
}
setError("Authorization timeout");
setStep("error");
setPolling(false);
if (runId === deviceFlowRunRef.current) {
setError("Authorization timeout");
setStep("error");
setPolling(false);
setDeviceCodeExpiresAt(null);
}
},
[provider, onSuccess, reauthConnection]
);
@@ -290,17 +339,11 @@ export default function OAuthModal({
try {
setError(null);
// Device code flow (GitHub, Kiro, Kimi Coding, KiloCode, GHE Copilot)
if (
provider === "github" ||
provider === "ghe-copilot" ||
provider === "kiro" ||
provider === "amazon-q" ||
provider === "kimi-coding" ||
provider === "kilocode" ||
provider === "codebuddy-cn"
) {
// Device code flow
if (DEVICE_CODE_PROVIDERS.has(provider)) {
invalidateDeviceFlow();
setIsDeviceCode(true);
setDeviceData(null);
setStep("waiting");
// GHE Copilot needs the enterprise URL collected first (see ghe-config step)
@@ -338,7 +381,7 @@ export default function OAuthModal({
// Open verification URL
const verifyUrl = data.verification_uri_complete || data.verification_uri;
if (verifyUrl) window.open(verifyUrl, "oauth_verify");
if (typeof verifyUrl === "string" && verifyUrl) window.open(verifyUrl, "oauth_verify");
// Start polling - pass extraData for Kiro (contains _clientId, _clientSecret)
const extraData =
@@ -351,7 +394,13 @@ export default function OAuthModal({
: provider === "ghe-copilot" && gheUrl.trim()
? { gheUrl: gheUrl.trim() }
: null;
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
startPolling(
data.device_code,
data.codeVerifier,
data.interval || 5,
data.expires_in,
extraData
);
return;
}
@@ -519,29 +568,57 @@ export default function OAuthModal({
reauthConnection,
idcConfig,
gheUrl,
invalidateDeviceFlow,
]);
// Reset guard when modal closes
useEffect(() => {
if (!deviceCodeExpiresAt) {
setDeviceCodeSecondsRemaining(null);
return;
}
const updateRemaining = () => {
setDeviceCodeSecondsRemaining(
Math.max(0, Math.ceil((deviceCodeExpiresAt - Date.now()) / 1000))
);
};
updateRemaining();
const timer = window.setInterval(updateRemaining, 1000);
return () => window.clearInterval(timer);
}, [deviceCodeExpiresAt]);
useEffect(() => {
invalidateDeviceFlow();
flowStartedRef.current = false;
}, [provider, invalidateDeviceFlow]);
useEffect(() => {
if (!isOpen) {
invalidateDeviceFlow();
flowStartedRef.current = false;
}
}, [isOpen]);
}, [isOpen, invalidateDeviceFlow]);
useEffect(
() => () => {
deviceFlowRunRef.current += 1;
},
[]
);
// Reset state and start OAuth when modal opens
useEffect(() => {
if (isOpen && provider) {
if (flowStartedRef.current) return; // Already started, prevent duplicate
flowStartedRef.current = true;
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
// Auto start OAuth
startOAuthFlow();
}
if (!isOpen || !provider || flowStartedRef.current) return;
flowStartedRef.current = true;
const startsInPasteMode = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider);
setShowPasteToken(startsInPasteMode);
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
if (!startsInPasteMode) startOAuthFlow();
}, [isOpen, provider, startOAuthFlow]);
// Listen for OAuth callback via multiple methods
@@ -774,32 +851,45 @@ export default function OAuthModal({
}
};
const handleClose = useCallback(() => {
invalidateDeviceFlow();
onClose();
}, [invalidateDeviceFlow, onClose]);
const handlePasteMode = useCallback(() => {
invalidateDeviceFlow();
setShowPasteToken(true);
}, [invalidateDeviceFlow]);
const handleBrowserMode = useCallback(() => {
setShowPasteToken(false);
startOAuthFlow();
}, [startOAuthFlow]);
if (!provider || !providerInfo) return null;
return (
<Modal
isOpen={isOpen}
title={t("title", { providerName: providerInfo.name })}
onClose={onClose}
onClose={handleClose}
size="lg"
>
<div className="flex flex-col gap-4">
{/* Paste-token tab toggle (Windsurf / Devin CLI only).
Phase 1 hotfix: when importTokenOnly is true, hide the entire toggle —
there is no "Browser Login" tab to switch to until Phase 2 ships. */}
{/* Browser login with an optional token-import fallback. */}
{supportsTokenPaste && !importTokenOnly && step !== "success" && (
<div className="flex gap-2 border-b border-border pb-3">
<button
className={`text-sm px-3 py-1 rounded-t ${!showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={() => setShowPasteToken(false)}
onClick={handleBrowserMode}
>
Browser Login
</button>
<button
className={`text-sm px-3 py-1 rounded-t ${showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={() => setShowPasteToken(true)}
onClick={handlePasteMode}
>
Paste API Key
{provider === "grok-cli" ? "JWT Token" : "Paste API Key"}
</button>
</div>
)}
@@ -830,7 +920,7 @@ export default function OAuthModal({
>
{savingToken ? "Saving…" : "Save Connection"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
<Button onClick={handleClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
@@ -870,142 +960,28 @@ export default function OAuthModal({
{/* Device Code Flow - Waiting */}
{step === "waiting" && isDeviceCode && deviceData && (
<>
<div className="text-center py-4">
<p className="text-sm text-text-muted mb-4">{t("deviceCodeVisitUrl")}</p>
<div className="bg-sidebar p-4 rounded-lg mb-4">
<p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm break-all">{deviceVerificationUrl}</code>
<Button
size="sm"
variant="ghost"
icon={copied === "verify_url" ? "check" : "content_copy"}
onClick={() => copy(deviceVerificationUrl, "verify_url")}
/>
</div>
</div>
<div className="bg-primary/10 p-4 rounded-lg">
<p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</p>
<div className="flex items-center justify-center gap-2">
<p className="text-2xl font-mono font-bold text-primary">
{deviceData.user_code}
</p>
<Button
size="sm"
variant="ghost"
icon={copied === "user_code" ? "check" : "content_copy"}
onClick={() => copy(deviceData.user_code, "user_code")}
/>
</div>
</div>
</div>
{polling && (
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined animate-spin">
progress_activity
</span>
{t("deviceCodeWaiting")}
</div>
)}
</>
<OAuthDeviceCodePanel
deviceData={deviceData}
verificationUrl={deviceVerificationUrl}
secondsRemaining={deviceCodeSecondsRemaining}
polling={polling}
/>
)}
{/* Manual Input Step */}
{step === "input" && !isDeviceCode && (
<>
<div className="space-y-4">
{/* Remote/LAN server info for Google OAuth providers */}
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">
warning
</span>
<strong>
{t.rich("googleOAuthWarning", {
code: (c) => <code className="font-mono">{c}</code>,
a: (c) => (
<a
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
target="_blank"
rel="noreferrer"
className="underline"
>
{c}
</a>
),
})}
</strong>
</div>
)}
{/* Actionable remote paste instruction — shown for ALL remote providers,
including Google OAuth (antigravity/agy). The Google
loopback creds redirect to 127.0.0.1:<port>/callback, which on a
remotely-accessed dashboard lands on the operator's own machine and
shows a "can't reach this page" error. That is expected: the URL bar
still carries ?code=…, and pasting it below completes the login. Before
this, Google providers only saw the discouraging loopback warning and
never the "copy the URL and paste it" step, so remote login appeared to
hang. */}
{!isTrueLocalhost && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">
info
</span>
{t("remoteAccessInfo")}
</div>
)}
<div>
<p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</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")}
>
{t("copy")}
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
<p className="text-xs text-text-muted mb-2">
{t.rich("step2Hint", {
code: (c) => <code className="font-mono">{c}</code>,
})}
</p>
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder={
provider === "claude" || provider === "cline"
? "code#state or /callback?code=..."
: placeholderUrl
}
className="font-mono text-xs"
/>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleManualSubmit}
fullWidth
disabled={!callbackUrl || (!authData && !isCredentialBlob(callbackUrl))}
>
{t("connect")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</>
<OAuthManualInputPanel
provider={provider}
isGoogleOAuth={GOOGLE_OAUTH_PROVIDERS.has(provider)}
isTrueLocalhost={isTrueLocalhost}
authUrl={typeof authData?.authUrl === "string" ? authData.authUrl : ""}
callbackUrl={callbackUrl}
placeholderUrl={placeholderUrl}
canSubmit={Boolean(callbackUrl && (authData || isCredentialBlob(callbackUrl)))}
onCallbackUrlChange={setCallbackUrl}
onSubmit={handleManualSubmit}
onClose={handleClose}
/>
)}
</>
)}
@@ -1022,7 +998,7 @@ export default function OAuthModal({
<p className="text-sm text-text-muted mb-4">
{t("successMessage", { providerName: providerInfo.name })}
</p>
<Button onClick={onClose} fullWidth>
<Button onClick={handleClose} fullWidth>
{t("done")}
</Button>
</div>
@@ -1042,7 +1018,7 @@ export default function OAuthModal({
<Button onClick={startOAuthFlow} variant="secondary" fullWidth>
{t("tryAgain")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
<Button onClick={handleClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>

View File

@@ -0,0 +1,201 @@
"use client";
import { useTranslations } from "next-intl";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import Button from "./Button";
import Input from "./Input";
export function formatDeviceCodeRemaining(seconds: number): string {
const safeSeconds = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(safeSeconds / 60);
return `${minutes}:${String(safeSeconds % 60).padStart(2, "0")}`;
}
type OAuthDeviceCodePanelProps = {
deviceData: { user_code: string };
verificationUrl: string;
secondsRemaining: number | null;
polling: boolean;
};
export function OAuthDeviceCodePanel({
deviceData,
verificationUrl,
secondsRemaining,
polling,
}: OAuthDeviceCodePanelProps) {
const t = useTranslations("oauthModal");
const { copied, copy } = useCopyToClipboard();
return (
<>
<div className="text-center py-4">
<p className="text-sm text-text-muted mb-4">{t("deviceCodeVisitUrl")}</p>
<div className="bg-sidebar p-4 rounded-lg mb-4">
<p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p>
<div className="flex items-center gap-2">
<a
href={verificationUrl}
target="_blank"
rel="noreferrer"
className="flex-1 text-sm break-all text-primary hover:underline"
>
{verificationUrl}
</a>
<Button
size="sm"
variant="ghost"
icon={copied === "verify_url" ? "check" : "content_copy"}
onClick={() => copy(verificationUrl, "verify_url")}
/>
</div>
</div>
<div className="bg-primary/10 p-4 rounded-lg">
<p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</p>
<div className="flex items-center justify-center gap-2">
<p className="text-2xl font-mono font-bold text-primary">{deviceData.user_code}</p>
<Button
size="sm"
variant="ghost"
icon={copied === "user_code" ? "check" : "content_copy"}
onClick={() => copy(deviceData.user_code, "user_code")}
/>
</div>
{secondsRemaining !== null && (
<div
className="mt-3 flex items-center justify-center gap-1 text-xs text-text-muted"
aria-label={t("deviceCodeWaiting")}
>
<span className="material-symbols-outlined text-sm">schedule</span>
<span>{formatDeviceCodeRemaining(secondsRemaining)}</span>
</div>
)}
</div>
</div>
{polling && (
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
{t("deviceCodeWaiting")}
</div>
)}
</>
);
}
function OAuthRemoteAccessNotices({
isGoogleOAuth,
isTrueLocalhost,
}: {
isGoogleOAuth: boolean;
isTrueLocalhost: boolean;
}) {
const t = useTranslations("oauthModal");
if (isTrueLocalhost) return null;
return (
<>
{isGoogleOAuth && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">warning</span>
<strong>
{t.rich("googleOAuthWarning", {
code: (chunks) => <code className="font-mono">{chunks}</code>,
a: (chunks) => (
<a
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
target="_blank"
rel="noreferrer"
className="underline"
>
{chunks}
</a>
),
})}
</strong>
</div>
)}
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
{t("remoteAccessInfo")}
</div>
</>
);
}
type OAuthManualInputPanelProps = {
provider: string;
isGoogleOAuth: boolean;
isTrueLocalhost: boolean;
authUrl: string;
callbackUrl: string;
placeholderUrl: string;
canSubmit: boolean;
onCallbackUrlChange: (value: string) => void;
onSubmit: () => void | Promise<void>;
onClose: () => void;
};
export function OAuthManualInputPanel({
provider,
isGoogleOAuth,
isTrueLocalhost,
authUrl,
callbackUrl,
placeholderUrl,
canSubmit,
onCallbackUrlChange,
onSubmit,
onClose,
}: OAuthManualInputPanelProps) {
const t = useTranslations("oauthModal");
const { copied, copy } = useCopyToClipboard();
return (
<>
<div className="space-y-4">
<OAuthRemoteAccessNotices isGoogleOAuth={isGoogleOAuth} isTrueLocalhost={isTrueLocalhost} />
<div>
<p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</p>
<div className="flex gap-2">
<Input value={authUrl} readOnly className="flex-1 font-mono text-xs" />
<Button
variant="secondary"
icon={copied === "auth_url" ? "check" : "content_copy"}
onClick={() => copy(authUrl, "auth_url")}
>
{t("copy")}
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
<p className="text-xs text-text-muted mb-2">
{t.rich("step2Hint", {
code: (chunks) => <code className="font-mono">{chunks}</code>,
})}
</p>
<Input
value={callbackUrl}
onChange={(event) => onCallbackUrlChange(event.target.value)}
placeholder={
provider === "claude" || provider === "cline"
? "code#state or /callback?code=..."
: placeholderUrl
}
className="font-mono text-xs"
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={onSubmit} fullWidth disabled={!canSubmit}>
{t("connect")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-grok-device-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const route = await import("../../src/app/api/oauth/[provider]/[action]/route.ts");
const originalFetch = globalThis.fetch;
test.before(async () => {
await settingsDb.updateSettings({ requireLogin: false });
});
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("grok-cli poll does not require a PKCE code verifier", async () => {
let upstreamBody = "";
globalThis.fetch = (async (_input, init) => {
upstreamBody = String(init?.body);
return new Response(
JSON.stringify({
error: "authorization_pending",
error_description: "User has not yet authorized",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
const request = new Request("http://localhost:20128/api/oauth/grok-cli/poll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceCode: "opaque-device-code" }),
});
const response = await route.POST(request, {
params: Promise.resolve({ provider: "grok-cli", action: "poll" }),
});
const body = await response.json();
const params = new URLSearchParams(upstreamBody);
assert.equal(response.status, 200);
assert.equal(body.success, false);
assert.equal(body.pending, true);
assert.equal(body.error, "authorization_pending");
assert.equal(params.get("device_code"), "opaque-device-code");
});

View File

@@ -2,8 +2,13 @@ import test from "node:test";
import assert from "node:assert/strict";
const { grokCli } = await import("../../src/lib/oauth/providers/grok-cli.ts");
const { GrokCliExecutor } = await import("@omniroute/open-sse/executors/grok-cli");
const { getGrokBuildClientVersion } = await import("@omniroute/open-sse/config/grokBuild.ts");
const { resolvePublicCred } = await import("@omniroute/open-sse/utils/publicCreds");
const GROK_CLI_SCOPE =
"openid profile email offline_access grok-cli:access api:access conversations:read conversations:write workspaces:read workspaces:write";
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),
@@ -14,6 +19,7 @@ test("Grok Build OAuth Provider - config", () => {
"clientId must resolve from the embedded grok_id default"
);
assert.equal(grokCli.config.tokenUrl, "https://auth.x.ai/oauth2/token");
assert.equal(getGrokBuildClientVersion(), "0.2.106");
});
test("publicCreds: grok_id embedded default is present and decodes", () => {
@@ -21,8 +27,181 @@ test("publicCreds: grok_id embedded default is present and decodes", () => {
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 - flowType is device_code", () => {
assert.equal(grokCli.flowType, "device_code");
assert.equal(grokCli.config.deviceCodeUrl, "https://auth.x.ai/oauth2/device/code");
assert.equal(grokCli.config.scope, GROK_CLI_SCOPE);
});
test("Grok Build OAuth Provider - requests and normalizes a device code", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
let requestUrl = "";
let requestInit: RequestInit | undefined;
globalThis.fetch = (async (input, init) => {
requestUrl = String(input);
requestInit = init;
return new Response(
JSON.stringify({
device_code: "opaque-device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://accounts.x.ai/oauth2/device",
verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
expires_in: 1800,
interval: 5,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
const result = await grokCli.requestDeviceCode(grokCli.config);
const body = new URLSearchParams(String(requestInit?.body));
assert.equal(requestUrl, grokCli.config.deviceCodeUrl);
assert.equal(requestInit?.method, "POST");
assert.equal(body.get("client_id"), grokCli.config.clientId);
assert.equal(body.get("scope"), GROK_CLI_SCOPE);
assert.equal(body.get("referrer"), "grok-build");
const headers = new Headers(requestInit?.headers);
assert.equal(headers.get("x-grok-client-version"), getGrokBuildClientVersion());
assert.equal(headers.get("x-grok-client-surface"), "ui");
assert.equal(result.device_code, "opaque-device-code");
assert.equal(result.user_code, "ABCD-EFGH");
assert.equal(result.expires_in, 1800);
assert.equal(result.interval, 5);
});
test("Grok Build OAuth Provider - rejects unsafe device authorization responses", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
device_code: "opaque-device-code",
user_code: "ABCD\nEFGH",
verification_uri: "javascript:alert(1)",
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)) as typeof fetch;
await assert.rejects(() => grokCli.requestDeviceCode(grokCli.config), /invalid device code/);
});
test("Grok Build OAuth Provider - rejects unsupported verification URL schemes", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
device_code: "opaque-device-code",
user_code: "ABCD-EFGH",
verification_uri: "javascript:alert(1)",
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)) as typeof fetch;
await assert.rejects(
() => grokCli.requestDeviceCode(grokCli.config),
/unsupported verification URL/
);
});
test("Grok Build OAuth Provider - polls with the standard device grant", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
let requestInit: RequestInit | undefined;
globalThis.fetch = (async (_input, init) => {
requestInit = init;
return new Response(
JSON.stringify({
error: "authorization_pending",
error_description: "User has not yet authorized",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
const result = await grokCli.pollToken(grokCli.config, "opaque-device-code");
const body = new URLSearchParams(String(requestInit?.body));
assert.equal(body.get("client_id"), grokCli.config.clientId);
assert.equal(body.get("device_code"), "opaque-device-code");
assert.equal(body.get("grant_type"), "urn:ietf:params:oauth:grant-type:device_code");
const headers = new Headers(requestInit?.headers);
assert.equal(headers.get("x-grok-client-version"), getGrokBuildClientVersion());
assert.equal(headers.get("x-grok-client-surface"), "ui");
assert.equal(result.ok, false);
assert.equal(result.data.error, "authorization_pending");
});
test("Grok Build OAuth Provider - maps a standard OAuth token response", () => {
const accessPayload = {
sub: "user-123",
email: "device@example.com",
team_id: "team-456",
tier: 2,
principal_type: "Team",
principal_id: "team-456",
exp: Math.floor(Date.now() / 1000) + 3600,
};
const accessToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(JSON.stringify(accessPayload)).toString("base64url")}.signature`;
const idToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(
JSON.stringify({ email: "device@example.com" })
).toString("base64url")}.signature`;
const result = grokCli.mapTokens({
access_token: accessToken,
refresh_token: "refresh-device-token",
id_token: idToken,
expires_in: 3600,
token_type: "Bearer",
scope: GROK_CLI_SCOPE,
});
assert.equal(result.accessToken, accessToken);
assert.equal(result.refreshToken, "refresh-device-token");
assert.equal(result.idToken, idToken);
assert.equal(result.expiresIn, 3600);
assert.equal(result.tokenType, "Bearer");
assert.equal(result.scope, GROK_CLI_SCOPE);
assert.equal(result.email, "device@example.com");
assert.equal(result.providerSpecificData?.teamId, "team-456");
assert.equal(result.providerSpecificData?.userId, "team-456");
assert.equal(result.providerSpecificData?.email, "device@example.com");
assert.equal(result.providerSpecificData?.principalType, "Team");
assert.equal(result.providerSpecificData?.principalId, "team-456");
});
test("Grok Build OAuth Provider - maps organization principals to their principal id", () => {
const accessPayload = {
sub: "user-123",
principal_type: "Organization",
principal_id: "org-456",
exp: Math.floor(Date.now() / 1000) + 3600,
};
const idPayload = { sub: "user-123", email: "org-user@example.com" };
const accessToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(JSON.stringify(accessPayload)).toString("base64url")}.signature`;
const idToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(JSON.stringify(idPayload)).toString("base64url")}.signature`;
const result = grokCli.mapTokens({ access_token: accessToken, id_token: idToken });
assert.equal(result.email, "org-user@example.com");
assert.equal(result.providerSpecificData?.userId, "org-456");
assert.equal(result.providerSpecificData?.organizationId, "org-456");
assert.equal(result.providerSpecificData?.principalType, "Organization");
assert.equal(result.providerSpecificData?.principalId, "org-456");
});
test("Grok Build OAuth Provider - mapTokens from raw JWT", () => {
@@ -107,6 +286,30 @@ test("Grok Build OAuth Provider - mapTokens from direct auth.json has rawAuthJso
assert.deepEqual(result.providerSpecificData?.rawAuthJson, authJson);
});
test("Grok Build OAuth Provider - prefers the active issuer/client auth.json scope", () => {
const otherPayload = Buffer.from(JSON.stringify({ email: "other@example.com" })).toString(
"base64url"
);
const preferredPayload = Buffer.from(JSON.stringify({ email: "preferred@example.com" })).toString(
"base64url"
);
const authJson = {
"https://auth.x.ai::other-client": {
key: `eyJhbGciOiJFUzI1NiJ9.${otherPayload}.signature`,
refresh_token: "other-refresh",
},
[`https://auth.x.ai::${grokCli.config.clientId}`]: {
key: `eyJhbGciOiJFUzI1NiJ9.${preferredPayload}.signature`,
refresh_token: "preferred-refresh",
},
};
const result = grokCli.mapTokens(authJson, null);
assert.equal(result.email, "preferred@example.com");
assert.equal(result.refreshToken, "preferred-refresh");
});
test("Grok Build OAuth Provider - mapTokens from raw JWT has no rawAuthJson", () => {
const payload = { sub: "12345", email: "test@example.com" };
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
@@ -181,3 +384,92 @@ test("Grok Build OAuth Provider - mapTokens clamps expired JSON expires_at to a
assert.ok(result.expiresIn >= 1, `expected expiresIn >= 1, got ${result.expiresIn}`);
});
test("Grok Build executor refresh forwards principal metadata and preserves token rotation", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
let requestUrl = "";
let requestInit: RequestInit | undefined;
globalThis.fetch = (async (input, init) => {
requestUrl = String(input);
requestInit = init;
return new Response(
JSON.stringify({
access_token: "new-access-token",
refresh_token: "rotated-refresh-token",
expires_in: 900,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
const result = await new GrokCliExecutor().refreshCredentials({
refreshToken: "old-refresh-token",
providerSpecificData: { principalType: "Team", principalId: "team-123" },
});
const body = new URLSearchParams(String(requestInit?.body));
assert.equal(requestUrl, "https://auth.x.ai/oauth2/token");
assert.equal(body.get("grant_type"), "refresh_token");
assert.equal(body.get("refresh_token"), "old-refresh-token");
assert.equal(body.get("principal_type"), "Team");
assert.equal(body.get("principal_id"), "team-123");
assert.equal(result?.accessToken, "new-access-token");
assert.equal(result?.refreshToken, "rotated-refresh-token");
});
test("Grok Build executor retries transient refresh failures", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
let calls = 0;
globalThis.fetch = (async () => {
calls++;
if (calls === 1) {
return new Response(JSON.stringify({ error: "temporarily_unavailable" }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ access_token: "recovered-access-token" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const result = await new GrokCliExecutor().refreshCredentials({
refreshToken: "refresh-token",
});
assert.equal(calls, 2);
assert.equal(result?.accessToken, "recovered-access-token");
assert.equal(result?.refreshToken, "refresh-token");
});
test("Grok Build executor does not retry terminal refresh failures", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
let calls = 0;
globalThis.fetch = (async () => {
calls++;
return new Response(JSON.stringify({ error: "invalid_grant" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const result = await new GrokCliExecutor().refreshCredentials({
refreshToken: "revoked-refresh-token",
});
assert.equal(calls, 1);
assert.equal(result, null);
});

View File

@@ -1,29 +1,18 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts";
import type { ExecuteInput, ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts";
type TestableGrokCliExecutor = {
execute: (input: ExecuteInput) => Promise<{ response: Response }>;
refreshCredentials: (
credentials: ProviderCredentials,
log?: ExecutorLog | null
) => Promise<Partial<ProviderCredentials> | null>;
nativePost: (
url: string,
headers: Record<string, string>,
bodyStr: string,
signal?: AbortSignal | null
) => Promise<Response>;
};
import type { ExecuteInput, ProviderCredentials } from "../../open-sse/executors/base.ts";
test("GrokCliExecutor.execute() proactively refreshes an expired access token (#7610)", async () => {
const executor = new GrokCliExecutor() as unknown as TestableGrokCliExecutor;
const executor = new GrokCliExecutor();
// Stub the real network call (nativeHttpsPost → auth.x.ai) so the test never
// touches the network — only the wiring (does execute() call
// refreshCredentials() at all, and does the refreshed token reach the
// outgoing Authorization header) is under test here.
// #7358 moved grok-cli off the raw https.request()/nativePost dispatch onto the
// shared fetch-based BaseExecutor.execute() path (buildUrl/buildHeaders only) —
// which already runs the same proactive-refresh gate generically (base.ts:600).
// Stub refreshCredentials() (the network call to auth.x.ai) and global fetch (the
// upstream Grok Build call) so only the wiring is under test: does execute() call
// refreshCredentials() before dispatch, and does the refreshed token reach the
// outgoing Authorization header.
let refreshCalled = false;
executor.refreshCredentials = async () => {
refreshCalled = true;
@@ -35,30 +24,37 @@ test("GrokCliExecutor.execute() proactively refreshes an expired access token (#
};
let capturedHeaders: Record<string, string> | null = null;
executor.nativePost = async (_url, headers) => {
capturedHeaders = headers;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_url: string, init: RequestInit = {}) => {
capturedHeaders = Object.fromEntries(
new Headers(init.headers as HeadersInit).entries()
) as Record<string, string>;
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};
}) as typeof fetch;
const expiredAt = new Date(Date.now() - 60_000).toISOString();
const credentials: ProviderCredentials = {
accessToken: "STALE_ACCESS_TOKEN",
refreshToken: "valid-refresh-token",
expiresAt: expiredAt,
};
try {
const expiredAt = new Date(Date.now() - 60_000).toISOString();
const credentials: ProviderCredentials = {
accessToken: "STALE_ACCESS_TOKEN",
refreshToken: "valid-refresh-token",
expiresAt: expiredAt,
};
await executor.execute({
model: "grok-composer-2.5-fast",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials,
} as ExecuteInput);
await executor.execute({
model: "grok-composer-2.5-fast",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials,
} as ExecuteInput);
assert.equal(
refreshCalled,
true,
"expected GrokCliExecutor.execute() to proactively call refreshCredentials()"
);
assert.notEqual(capturedHeaders?.["Authorization"], "Bearer STALE_ACCESS_TOKEN");
assert.equal(capturedHeaders?.["Authorization"], "Bearer FRESH_ACCESS_TOKEN");
assert.equal(
refreshCalled,
true,
"expected GrokCliExecutor.execute() to proactively call refreshCredentials()"
);
assert.notEqual(capturedHeaders?.["authorization"], "Bearer STALE_ACCESS_TOKEN");
assert.equal(capturedHeaders?.["authorization"], "Bearer FRESH_ACCESS_TOKEN");
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -1,56 +1,15 @@
// Regression test for: Grok Build (grok-cli) inference/refresh requests bypassed the
// operator's configured proxy entirely. The executor talks to Grok's upstream via raw
// Node `https.request()` (forced IPv4, to dodge Cloudflare blocking on the direct path)
// instead of the process-wide patched `fetch()` that every other executor uses — so a
// proxy pinned to the connection/provider/global scope was silently ignored, leaking the
// real egress IP and defeating account-isolation/anonymity setups. Mirrors the class of
// bug fixed upstream in decolua/9router#2343 ("fix(oauth): honor proxy selection during
// OAuth login"), adapted to OmniRoute's actual grok-cli architecture (import-token flow,
// no device-code polling) where the leak lives in `resolveGrokRequestDispatch()`.
import { test } from "node:test";
// Regression test for #7244: Grok Build inference used to bypass the configured
// proxy because the legacy executor overrode execute() with raw https.request().
// The official-client implementation must stay on BaseExecutor's shared fetch
// transport, which is patched by proxyFetch and receives the active proxy context.
import test from "node:test";
import assert from "node:assert/strict";
import { resolveGrokRequestDispatch } from "../../open-sse/executors/grok-cli.ts";
import { BaseExecutor } from "../../open-sse/executors/base.ts";
import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts";
const TARGET_URL = "https://grok.x.ai/rest/app-chat/conversations/new";
test("grok-cli inherits the shared proxy-aware BaseExecutor transport", () => {
const executor = new GrokCliExecutor();
test("resolveGrokRequestDispatch: no proxy configured -> direct IPv4 dispatch (unchanged behavior)", () => {
const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({
source: "direct",
proxyUrl: null,
}));
assert.equal(dispatch.family, 4);
assert.equal(dispatch.agent, undefined);
});
test("resolveGrokRequestDispatch: HTTP proxy configured -> request is dispatched through a proxy agent, not direct", () => {
const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({
source: "context",
proxyUrl: "http://proxy.internal:8080",
}));
// The fix: an agent bound to the configured proxy must be present, and the
// direct-IPv4 workaround must NOT be applied (it would race the proxy tunnel).
assert.ok(dispatch.agent, "expected a proxy agent to be constructed");
assert.notEqual(dispatch.family, 4);
});
test("resolveGrokRequestDispatch: HTTPS proxy configured -> request is dispatched through a proxy agent", () => {
const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({
source: "context",
proxyUrl: "https://user:pass@proxy.internal:8443",
}));
assert.ok(dispatch.agent, "expected a proxy agent to be constructed");
});
test("resolveGrokRequestDispatch: unsupported proxy protocol (socks5) fails closed instead of leaking direct", () => {
assert.throws(
() =>
resolveGrokRequestDispatch(TARGET_URL, () => ({
source: "context",
proxyUrl: "socks5://proxy.internal:1080",
})),
/proxy/i
);
assert.equal(Object.hasOwn(GrokCliExecutor.prototype, "execute"), false);
assert.equal(executor.execute, BaseExecutor.prototype.execute);
});

View File

@@ -3,70 +3,113 @@ import assert from "node:assert/strict";
const { GrokCliExecutor } = await import("@omniroute/open-sse/executors/grok-cli");
// Regression for #6288: Grok Build (`grok-cli` executor) returns 400 on every
// request from Claude Code because Claude Code forwards `reasoning_effort`
// (and sometimes a nested `reasoning` object), which Grok Build's upstream
// chat-proxy endpoint does not accept. transformRequest() must strip both
// before forwarding, without breaking the existing #5273 stripping.
// #6288 originally protected the legacy Chat Completions bridge from reasoning
// fields. Both current models now use Responses; grok-4.5 accepts an effort,
// while Composer does not.
test("#6288 grok-cli transformRequest strips reasoning_effort", () => {
test("grok-4.5 preserves Responses reasoning", () => {
const executor = new GrokCliExecutor();
const body = {
model: "grok-build",
messages: [{ role: "user", content: "hi" }],
reasoning_effort: "high",
model: "grok-4.5",
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
reasoning: { effort: "high", summary: "auto" },
};
const out = executor.transformRequest("grok-build", body, false, {} as never) as Record<
const out = executor.transformRequest("grok-4.5", body, true, {} as never) as Record<
string,
unknown
>;
assert.equal("reasoning_effort" in out, false, "reasoning_effort must be stripped");
assert.deepEqual(out.messages, [{ role: "user", content: "hi" }]);
assert.equal(out.model, "grok-build");
assert.deepEqual(out.reasoning, { effort: "high", summary: "auto" });
});
test("#6288 grok-cli transformRequest strips nested reasoning object", () => {
test("grok composer strips unsupported Responses reasoning", () => {
const executor = new GrokCliExecutor();
const body = {
model: "grok-build",
messages: [{ role: "user", content: "hi" }],
model: "grok-composer-2.5-fast",
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
reasoning: { effort: "high" },
};
const out = executor.transformRequest("grok-build", body, false, {} as never) as Record<
string,
unknown
>;
const out = executor.transformRequest(
"grok-composer-2.5-fast",
body,
false,
{} as never
) as Record<string, unknown>;
assert.equal("reasoning" in out, false, "reasoning must be stripped");
assert.equal("reasoning" in out, false);
});
test("#6288 grok-cli transformRequest still strips #5273 unsupported sampling params", () => {
test("grok-cli strips legacy top-level reasoning_effort after translation", () => {
const executor = new GrokCliExecutor();
const body = {
model: "grok-build",
messages: [{ role: "user", content: "hi" }],
presencePenalty: 0.5,
frequencyPenalty: 0.3,
logprobs: true,
topLogprobs: 5,
reasoning_effort: "medium",
model: "grok-4.5",
input: [],
reasoning_effort: "high",
};
const out = executor.transformRequest("grok-build", body, false, {} as never) as Record<
const out = executor.transformRequest("grok-4.5", body, false, {} as never) as Record<
string,
unknown
>;
for (const param of [
"presencePenalty",
"frequencyPenalty",
"logprobs",
"topLogprobs",
"reasoning_effort",
]) {
assert.equal(param in out, false, `${param} must be stripped`);
}
assert.equal("reasoning_effort" in out, false);
});
test("grok-cli applies official Responses defaults without mutating client input", () => {
const executor = new GrokCliExecutor();
const body = {
model: "grok-4.5",
input: [],
include: ["file_search_call.results"],
};
const out = executor.transformRequest("grok-4.5", body, true, {} as never) as Record<
string,
unknown
>;
assert.equal(out.store, false);
assert.deepEqual(out.include, ["file_search_call.results", "reasoning.encrypted_content"]);
assert.deepEqual(out.reasoning, { effort: "high" });
assert.deepEqual(body, {
model: "grok-4.5",
input: [],
include: ["file_search_call.results"],
});
});
test("grok-cli preserves explicit store and de-duplicates encrypted reasoning include", () => {
const executor = new GrokCliExecutor();
const out = executor.transformRequest(
"grok-4.5",
{
input: [],
store: true,
include: ["reasoning.encrypted_content"],
reasoning: { effort: "xhigh" },
},
false,
{} as never
) as Record<string, unknown>;
assert.equal(out.store, true);
assert.deepEqual(out.include, ["reasoning.encrypted_content"]);
assert.equal("reasoning" in out, false);
});
test("grok-cli preserves an explicit Responses reasoning summary", () => {
const executor = new GrokCliExecutor();
const out = executor.transformRequest(
"grok-4.5",
{
input: [],
reasoning: { summary: "concise" },
},
false,
{} as never
) as Record<string, unknown>;
assert.deepEqual(out.reasoning, { summary: "concise", effort: "high" });
});

View File

@@ -0,0 +1,184 @@
import test from "node:test";
import assert from "node:assert/strict";
import { grok_cliProvider } from "../../open-sse/config/providers/registry/grok-cli/index.ts";
import {
GROK_BUILD_DEFAULT_CONTEXT_WINDOW,
getGrokBuildClientVersion,
getGrokBuildUserAgent,
GROK_BUILD_MODELS_URL,
} from "../../open-sse/config/grokBuild.ts";
import { getModelTargetFormat } from "../../open-sse/config/providerModels.ts";
import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts";
import { BaseExecutor } from "../../open-sse/executors/base.ts";
import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts";
test("grok-cli exposes the authenticated grok-build model catalog", () => {
assert.deepEqual(
grok_cliProvider.models.map(({ id, name, contextLength, targetFormat }) => ({
id,
name,
contextLength,
targetFormat,
})),
[
{
id: "grok-4.5",
name: "Grok 4.5",
contextLength: 500000,
targetFormat: "openai-responses",
},
{
id: "grok-composer-2.5-fast",
name: "Composer 2.5",
contextLength: 200000,
targetFormat: "openai-responses",
},
]
);
assert.equal(getModelTargetFormat("gc", "grok-4.5"), "openai-responses");
assert.equal(getModelTargetFormat("gc", "grok-composer-2.5-fast"), "openai-responses");
assert.equal(grok_cliProvider.modelsUrl, GROK_BUILD_MODELS_URL);
});
test("grok-cli routes both models to the Responses endpoint", () => {
const executor = new GrokCliExecutor();
assert.equal(executor.buildUrl("grok-4.5", true), "https://cli-chat-proxy.grok.com/v1/responses");
assert.equal(
executor.buildUrl("grok-composer-2.5-fast", false),
"https://cli-chat-proxy.grok.com/v1/responses"
);
});
test("grok-cli sends the current grok-build session headers", () => {
const executor = new GrokCliExecutor();
const streaming = executor.buildHeaders(
{
accessToken: "token",
providerSpecificData: { userId: "user-123", email: "grok@example.com" },
},
true,
null,
"grok-4.5"
);
assert.equal(streaming.Authorization, "Bearer token");
assert.equal(streaming.Accept, "text/event-stream");
assert.equal(streaming["x-grok-client-version"], getGrokBuildClientVersion());
assert.equal(streaming["x-grok-client-identifier"], "grok-shell");
assert.equal(streaming["x-grok-client-mode"], "headless");
assert.equal(streaming["User-Agent"], getGrokBuildUserAgent());
assert.equal(streaming["X-XAI-Token-Auth"], "xai-grok-cli");
assert.equal(streaming["x-authenticateresponse"], "authenticate-response");
assert.equal(streaming["x-grok-model-override"], "grok-4.5");
assert.equal(streaming["x-userid"], "user-123");
assert.equal(streaming["x-grok-user-id"], "user-123");
assert.equal(streaming["x-email"], "grok@example.com");
const team = executor.buildHeaders(
{
accessToken: "token",
email: "member@example.com",
providerSpecificData: {
userId: "team-123",
principalType: "Team",
},
},
true,
null,
"grok-4.5"
);
assert.equal(team["x-userid"], "team-123");
assert.equal("x-email" in team, false);
const json = executor.buildHeaders({ apiKey: "token" }, false, null, "grok-composer-2.5-fast");
assert.equal(json.Authorization, "Bearer token");
assert.equal(json.Accept, "application/json");
assert.equal(json["x-grok-model-override"], "grok-composer-2.5-fast");
});
test("grok-cli renders the official Windows platform name in its user agent", () => {
if (process.platform !== "win32") return;
assert.match(getGrokBuildUserAgent(), /\(windows; /);
});
test("grok-cli inherits BaseExecutor transport instead of buffering its own response", () => {
assert.equal(Object.hasOwn(GrokCliExecutor.prototype, "execute"), false);
assert.equal(new GrokCliExecutor().execute, BaseExecutor.prototype.execute);
});
test("grok-cli live model discovery uses the authenticated session contract", () => {
const config = PROVIDER_MODELS_CONFIG["grok-cli"];
assert.equal(config.url, GROK_BUILD_MODELS_URL);
const headers = config.buildHeaders?.("token", {
providerSpecificData: { userId: "user-123" },
email: "grok@example.com",
});
assert.equal(headers?.Authorization, "Bearer token");
assert.equal(headers?.["X-XAI-Token-Auth"], "xai-grok-cli");
assert.equal(headers?.["x-userid"], "user-123");
assert.equal(headers?.["x-email"], "grok@example.com");
assert.equal(headers?.["x-grok-client-version"], getGrokBuildClientVersion());
const teamHeaders = config.buildHeaders?.("token", {
providerSpecificData: {
userId: "team-123",
email: "member@example.com",
principalType: "Team",
},
email: "member@example.com",
});
assert.equal(teamHeaders?.["x-userid"], "team-123");
assert.equal("x-email" in (teamHeaders || {}), false);
const models = config.parseResponse({
data: [
{
id: "catalog-alias",
model: "grok-4.5",
name: "Grok 4.5",
contextWindow: 500000,
apiBackend: "responses",
supportsReasoningEffort: true,
},
{
id: "hidden-experiment",
model: "hidden-experiment",
context_window: 1000,
hidden: true,
},
{
id: "legacy-chat-model",
model: "legacy-chat-model",
apiBackend: "chat_completions",
},
{
id: "session-only-alias",
name: "Session Only",
apiBackend: "responses",
supportedInApi: false,
_meta: { model: "metadata-model-must-not-override-id" },
},
],
});
assert.deepEqual(models, [
{
id: "grok-4.5",
name: "Grok 4.5",
owned_by: "grok-cli",
inputTokenLimit: 500000,
supportsThinking: true,
apiFormat: "responses",
supportedEndpoints: ["responses"],
},
{
id: "session-only-alias",
name: "Session Only",
owned_by: "grok-cli",
inputTokenLimit: GROK_BUILD_DEFAULT_CONTEXT_WINDOW,
apiFormat: "responses",
supportedEndpoints: ["responses"],
},
]);
});

View File

@@ -6,22 +6,33 @@ const { GrokCliExecutor } = await import("@omniroute/open-sse/executors/grok-cli
// Regression for #5273: Grok Build returns `400 'Model does not support parameter
// presencePenalty'` when clients (MiMoCode, Cursor, …) send OpenAI-style sampling
// params Grok Build cannot accept. transformRequest() must strip them before forwarding.
const UNSUPPORTED = ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"];
const UNSUPPORTED = [
"presencePenalty",
"frequencyPenalty",
"logprobs",
"topLogprobs",
"presence_penalty",
"frequency_penalty",
"top_logprobs",
];
test("#5273 grok-cli transformRequest strips unsupported sampling params", () => {
const executor = new GrokCliExecutor();
const body = {
model: "grok-build",
messages: [{ role: "user", content: "hi" }],
model: "grok-4.5",
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
temperature: 0.7,
top_p: 0.9,
presencePenalty: 0.5,
frequencyPenalty: 0.3,
logprobs: true,
topLogprobs: 5,
presence_penalty: 0.4,
frequency_penalty: 0.2,
top_logprobs: 3,
};
const out = executor.transformRequest("grok-build", body, false, {} as never) as Record<
const out = executor.transformRequest("grok-4.5", body, false, {} as never) as Record<
string,
unknown
>;
@@ -33,8 +44,8 @@ test("#5273 grok-cli transformRequest strips unsupported sampling params", () =>
// …while supported params + payload survive untouched.
assert.equal(out.temperature, 0.7);
assert.equal(out.top_p, 0.9);
assert.deepEqual(out.messages, [{ role: "user", content: "hi" }]);
assert.equal(out.model, "grok-build");
assert.deepEqual(out.input, [{ role: "user", content: [{ type: "input_text", text: "hi" }] }]);
assert.equal(out.model, "grok-4.5");
assert.equal(out.stream, false);
});

View File

@@ -0,0 +1,154 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const { default: OAuthModal, formatDeviceCodeRemaining } =
await import("@/shared/components/OAuthModal");
const roots: Array<{ root: ReturnType<typeof createRoot>; element: HTMLDivElement }> = [];
async function flushEffects() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function renderModal(isOpen: boolean, reauthConnection?: { id: string }) {
const element = document.createElement("div");
document.body.appendChild(element);
const root = createRoot(element);
roots.push({ root, element });
act(() => {
root.render(
<OAuthModal
isOpen={isOpen}
provider="grok-cli"
providerInfo={{ name: "Grok Build" }}
onClose={vi.fn()}
onSuccess={vi.fn()}
reauthConnection={reauthConnection}
/>
);
});
return { root, element };
}
describe("OAuthModal Grok Device Code", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-13T00:00:00Z"));
});
afterEach(() => {
for (const { root, element } of roots.splice(0)) {
act(() => root.unmount());
element.remove();
}
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("starts Browser Login, opens xAI, and renders code plus countdown", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
if (String(input).includes("/device-code")) {
return new Response(
JSON.stringify({
device_code: "opaque-device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://accounts.x.ai/oauth2/device",
verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
expires_in: 1800,
interval: 5,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
return new Response(JSON.stringify({ success: false, pending: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
const openMock = vi.spyOn(window, "open").mockImplementation(() => null);
const { element } = renderModal(true, { id: "conn-existing" });
await flushEffects();
expect(fetchMock.mock.calls[0]?.[0].toString()).toContain("/api/oauth/grok-cli/device-code");
expect(openMock).toHaveBeenCalledWith(
"https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
"oauth_verify"
);
expect(element.textContent).toContain("ABCD-EFGH");
expect(element.textContent).toContain("30:00");
expect(element.textContent).toContain("Browser Login");
expect(element.textContent).toContain("JWT Token");
expect(
element.querySelector('a[href="https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH"]')
).toBeTruthy();
expect(formatDeviceCodeRemaining(65)).toBe("1:05");
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
const pollCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/poll"));
expect(pollCall).toBeTruthy();
const pollBody = JSON.parse(String((pollCall?.[1] as RequestInit).body));
expect(pollBody.connectionId).toBe("conn-existing");
});
it("cancels the old poll when the modal closes", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
if (String(input).includes("/device-code")) {
return new Response(
JSON.stringify({
device_code: "opaque-device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://accounts.x.ai/oauth2/device",
verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
expires_in: 1800,
interval: 5,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
return new Response(JSON.stringify({ success: false, pending: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(window, "open").mockImplementation(() => null);
const { root, element } = renderModal(true);
await flushEffects();
act(() => {
root.render(
<OAuthModal
isOpen={false}
provider="grok-cli"
providerInfo={{ name: "Grok Build" }}
onClose={vi.fn()}
onSuccess={vi.fn()}
/>
);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(6000);
});
const pollCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/poll"));
expect(pollCalls).toHaveLength(0);
expect(element.textContent).toBe("");
});
});