mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
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:
142
open-sse/config/grokBuild.ts
Normal file
142
open-sse/config/grokBuild.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
201
src/shared/components/OAuthModalPanels.tsx
Normal file
201
src/shared/components/OAuthModalPanels.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
tests/unit/grok-cli-device-route.test.ts
Normal file
58
tests/unit/grok-cli-device-route.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
184
tests/unit/grok-cli-responses-compat.test.ts
Normal file
184
tests/unit/grok-cli-responses-compat.test.ts
Normal 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"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
154
tests/unit/ui/grok-device-oauth-modal.test.tsx
Normal file
154
tests/unit/ui/grok-device-oauth-modal.test.tsx
Normal 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("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user