Files
2026-07-13 13:39:12 +08:00

884 lines
28 KiB
TypeScript

/**
* GrokWebExecutor — Grok Web Session Provider
*
* Routes requests through Grok's internal NDJSON API using an X/Grok
* subscription SSO cookie, translating between OpenAI chat completions
* format and Grok's internal protocol.
*
* Derived from:
* - grok2api-merged (model mappings, payload structure, statsig, processor)
* - GrokProxy / GrokBridge (cookie auth, streaming token extraction)
* - grok-web-api (response types, chat options)
* - Grok API Research Report (headers, Cloudflare bypass techniques)
*/
import {
BaseExecutor,
mergeUpstreamExtraHeaders,
mergeAbortSignals,
type ExecuteInput,
} from "./base.ts";
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { buildGrokCookieHeader } from "@/lib/providers/webCookieAuth";
import {
tlsFetchGrok,
TlsClientUnavailableError,
isCloudflareChallenge,
type TlsFetchResult,
} from "../services/grokTlsClient.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import type { GrokStreamEvent } from "./grok-web/types.ts";
import {
type OpenAIToolCall,
type GrokToolRegistry,
buildGrokToolRegistry,
buildGrokMessage,
parseClientToolCallMarkup,
hasOpenToolCallMarkup,
} from "./grok-web/tool-bridge.ts";
import { mapGrokNativeToolToOpenAI } from "./grok-web/native-tools.ts";
import {
GrokMarkupFilter,
cleanGrokContentText,
cleanGrokThinkingText,
extractStructuredReasoning,
} from "./grok-web/text-cleanup.ts";
// ─── Constants ──────────────────────────────────────────────────────────────
const GROK_CHAT_API = "https://grok.com/rest/app-chat/conversations/new";
const GROK_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
// ─── Model mappings ─────────────────────────────────────────────────────────
// Grok Web exposes UI modes, not stable public model IDs. Keep OmniRoute model
// IDs mapped directly to Grok's modeId field.
interface GrokModelInfo {
modeId: string;
isThinking: boolean;
}
const MODEL_MAP: Record<string, GrokModelInfo> = {
fast: { modeId: "fast", isThinking: false },
expert: { modeId: "expert", isThinking: true },
heavy: { modeId: "heavy", isThinking: true },
"grok-420-computer-use-sa": { modeId: "grok-420-computer-use-sa", isThinking: true },
// Legacy aliases retained for manually-entered model IDs.
"grok-4": { modeId: "fast", isThinking: false },
"grok-4.1-fast": { modeId: "fast", isThinking: false },
"grok-4.1-expert": { modeId: "expert", isThinking: true },
"grok-4-heavy": { modeId: "heavy", isThinking: true },
"grok-4.20": { modeId: "expert", isThinking: true },
"grok-4.20-heavy": { modeId: "heavy", isThinking: true },
"grok-4.3": { modeId: "grok-420-computer-use-sa", isThinking: true },
"grok-4-3-thinking-1129": { modeId: "grok-420-computer-use-sa", isThinking: true },
};
// ─── Statsig ID generation ──────────────────────────────────────────────────
function randomString(length: number, alphanumeric = false): string {
const chars = alphanumeric
? "abcdefghijklmnopqrstuvwxyz0123456789"
: "abcdefghijklmnopqrstuvwxyz";
let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function generateStatsigId(): string {
const msg =
Math.random() < 0.5
? `e:TypeError: Cannot read properties of null (reading 'children["${randomString(5, true)}"]')`
: `e:TypeError: Cannot read properties of undefined (reading '${randomString(10)}')`;
return btoa(msg);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function randomHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
}
// ─── NDJSON parsing ─────────────────────────────────────────────────────────
async function* readGrokNdjsonEvents(
body: ReadableStream<Uint8Array>,
signal?: AbortSignal | null
): AsyncGenerator<GrokStreamEvent> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
if (signal?.aborted) return;
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const idx = buffer.indexOf("\n");
if (idx < 0) break;
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (!line) continue;
try {
yield JSON.parse(line) as GrokStreamEvent;
} catch {
// Skip non-JSON lines
}
}
}
// Flush remaining buffer
buffer += decoder.decode();
const remaining = buffer.trim();
if (remaining) {
try {
yield JSON.parse(remaining) as GrokStreamEvent;
} catch {
// ignore
}
}
} finally {
reader.releaseLock();
}
}
// ─── Content extraction ─────────────────────────────────────────────────────
interface ContentChunk {
delta?: string;
thinking?: string;
toolCalls?: OpenAIToolCall[];
fingerprint?: string;
responseId?: string;
fullMessage?: string;
error?: string;
done?: boolean;
}
async function* extractContent(
eventStream: ReadableStream<Uint8Array>,
isThinkingModel: boolean,
toolRegistry: GrokToolRegistry,
signal?: AbortSignal | null,
suppressThinkingAfterVisibleContent = false
): AsyncGenerator<ContentChunk> {
let fingerprint = "";
let responseId = "";
const contentFilter = new GrokMarkupFilter();
const thinkingFilter = new GrokMarkupFilter();
let emittedThinking = "";
let emittedVisibleContent = false;
for await (const event of readGrokNdjsonEvents(eventStream, signal)) {
// Error handling
if (event.error) {
yield { error: event.error.message || `Grok error: ${event.error.code}`, done: true };
return;
}
const resp = event.result?.response;
if (!resp) continue;
// Extract metadata
if (resp.llmInfo?.modelHash && !fingerprint) {
fingerprint = resp.llmInfo.modelHash;
}
if (resp.responseId) {
responseId = resp.responseId;
}
const nativeToolCall = mapGrokNativeToolToOpenAI(resp, toolRegistry);
if (nativeToolCall) {
yield { toolCalls: [nativeToolCall], fingerprint, responseId };
return;
}
if (resp.messageTag === "raw_function_result" || resp.messageTag === "tool_usage_card") {
continue;
}
// modelResponse = final/complete response
if (resp.modelResponse) {
const mr = resp.modelResponse;
const finalThinking = isThinkingModel ? extractStructuredReasoning(mr) : "";
if ((!suppressThinkingAfterVisibleContent || !emittedVisibleContent) && finalThinking) {
const cleanedThinking = thinkingFilter.feed(finalThinking);
const thinkingDelta = cleanedThinking.startsWith(emittedThinking)
? cleanedThinking.slice(emittedThinking.length)
: cleanedThinking;
if (thinkingDelta) {
emittedThinking += thinkingDelta;
yield { thinking: thinkingDelta };
}
}
// Extract final message
if (mr.message) {
const fullMessage = cleanGrokContentText(mr.message);
if (fullMessage) emittedVisibleContent = true;
yield { fullMessage, fingerprint, responseId };
}
// Extract fingerprint from metadata
if (mr.metadata?.llm_info?.modelHash) {
fingerprint = mr.metadata.llm_info.modelHash;
}
continue;
}
// Streaming token
const thinking = isThinkingModel ? extractStructuredReasoning(resp) : "";
if ((!suppressThinkingAfterVisibleContent || !emittedVisibleContent) && thinking) {
const cleanedThinking = thinkingFilter.feed(thinking);
const thinkingDelta = cleanedThinking.startsWith(emittedThinking)
? cleanedThinking.slice(emittedThinking.length)
: cleanedThinking;
if (thinkingDelta) {
emittedThinking += thinkingDelta;
yield { thinking: thinkingDelta, fingerprint, responseId };
}
}
if (resp.token != null) {
if (resp.isThinking) {
const thinkingDelta =
suppressThinkingAfterVisibleContent && emittedVisibleContent
? ""
: cleanGrokThinkingText(resp);
if (thinkingDelta) yield { thinking: thinkingDelta, fingerprint, responseId };
continue;
}
const cleanedDelta = contentFilter.feed(resp.token);
if (cleanedDelta) {
emittedVisibleContent = true;
yield { delta: cleanedDelta, fingerprint, responseId };
}
}
}
const trailingThinking =
suppressThinkingAfterVisibleContent && emittedVisibleContent ? "" : thinkingFilter.flush();
if (trailingThinking) {
const thinkingDelta = trailingThinking.startsWith(emittedThinking)
? trailingThinking.slice(emittedThinking.length)
: trailingThinking;
if (thinkingDelta) yield { thinking: thinkingDelta, fingerprint, responseId };
}
const trailingContent = contentFilter.flush();
const trailingContentWithTrace = trailingContent;
if (trailingContentWithTrace) yield { delta: trailingContentWithTrace, fingerprint, responseId };
yield { done: true, fingerprint, responseId };
}
// ─── OpenAI SSE format builders ─────────────────────────────────────────────
function sseChunk(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
function enqueueStreamingToolCalls(
controller: ReadableStreamDefaultController<Uint8Array>,
encoder: TextEncoder,
params: {
id: string;
created: number;
model: string;
fingerprint: string;
toolCalls: OpenAIToolCall[];
}
): void {
for (let i = 0; i < params.toolCalls.length; i++) {
controller.enqueue(
encoder.encode(
sseChunk({
id: params.id,
object: "chat.completion.chunk",
created: params.created,
model: params.model,
system_fingerprint: params.fingerprint || null,
choices: [
{
index: 0,
delta: { tool_calls: [{ index: i, ...params.toolCalls[i] }] },
finish_reason: null,
logprobs: null,
},
],
})
)
);
}
controller.enqueue(
encoder.encode(
sseChunk({
id: params.id,
object: "chat.completion.chunk",
created: params.created,
model: params.model,
system_fingerprint: params.fingerprint || null,
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls", logprobs: null }],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
}
function buildStreamingResponse(
eventStream: ReadableStream<Uint8Array>,
model: string,
cid: string,
created: number,
isThinkingModel: boolean,
toolRegistry: GrokToolRegistry,
signal?: AbortSignal | null
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream(
{
async start(controller) {
try {
// Initial role chunk
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{ index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null },
],
})
)
);
let fp = "";
let buffered = "";
for await (const chunk of extractContent(
eventStream,
isThinkingModel,
toolRegistry,
signal,
true
)) {
if (chunk.fingerprint) fp = chunk.fingerprint;
if (chunk.error) {
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: fp || null,
choices: [
{
index: 0,
delta: { content: `[Error: ${chunk.error}]` },
finish_reason: null,
logprobs: null,
},
],
})
)
);
break;
}
if (chunk.thinking) {
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: fp || null,
choices: [
{
index: 0,
delta: { reasoning_content: chunk.thinking },
finish_reason: null,
logprobs: null,
},
],
})
)
);
continue;
}
if (chunk.toolCalls) {
enqueueStreamingToolCalls(controller, encoder, {
id: cid,
created,
model,
fingerprint: fp,
toolCalls: chunk.toolCalls,
});
return;
}
if (chunk.done) break;
if (chunk.fullMessage) {
const toolCalls = parseClientToolCallMarkup(chunk.fullMessage, toolRegistry);
if (toolCalls) {
enqueueStreamingToolCalls(controller, encoder, {
id: cid,
created,
model,
fingerprint: fp,
toolCalls,
});
return;
}
}
if (chunk.delta) {
buffered += chunk.delta;
const toolCalls = parseClientToolCallMarkup(buffered, toolRegistry);
if (toolCalls) {
enqueueStreamingToolCalls(controller, encoder, {
id: cid,
created,
model,
fingerprint: fp,
toolCalls,
});
return;
}
if (hasOpenToolCallMarkup(buffered)) continue;
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: fp || null,
choices: [
{
index: 0,
delta: { content: chunk.delta },
finish_reason: null,
logprobs: null,
},
],
})
)
);
}
}
// Stop chunk
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: fp || null,
choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
} catch (err) {
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
delta: {
content: sanitizeErrorMessage(
`[Stream error: ${err instanceof Error ? err.message : String(err)}]`
),
},
finish_reason: "stop",
logprobs: null,
},
],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
} finally {
try {
controller.close();
} catch {}
}
},
},
{ highWaterMark: 16384 }
);
}
async function buildNonStreamingResponse(
eventStream: ReadableStream<Uint8Array>,
model: string,
cid: string,
created: number,
isThinkingModel: boolean,
toolRegistry: GrokToolRegistry,
signal?: AbortSignal | null
): Promise<Response> {
let fullContent = "";
let fingerprint = "";
const thinkingParts: string[] = [];
for await (const chunk of extractContent(eventStream, isThinkingModel, toolRegistry, signal)) {
if (chunk.fingerprint) fingerprint = chunk.fingerprint;
if (chunk.error) {
return new Response(
JSON.stringify({
error: { message: chunk.error, type: "upstream_error", code: "GROK_ERROR" },
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
);
}
if (chunk.thinking) {
thinkingParts.push(chunk.thinking);
continue;
}
if (chunk.toolCalls) {
return new Response(
JSON.stringify({
id: cid,
object: "chat.completion",
created,
model,
system_fingerprint: fingerprint || null,
choices: [
{
index: 0,
message: { role: "assistant", content: null, tool_calls: chunk.toolCalls },
finish_reason: "tool_calls",
logprobs: null,
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
if (chunk.done) break;
if (chunk.fullMessage) {
fullContent = chunk.fullMessage;
} else if (chunk.delta) {
fullContent += chunk.delta;
}
}
const manifestToolCalls = parseClientToolCallMarkup(fullContent, toolRegistry);
if (manifestToolCalls) {
return new Response(
JSON.stringify({
id: cid,
object: "chat.completion",
created,
model,
system_fingerprint: fingerprint || null,
choices: [
{
index: 0,
message: { role: "assistant", content: null, tool_calls: manifestToolCalls },
finish_reason: "tool_calls",
logprobs: null,
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
const msg: Record<string, unknown> = { role: "assistant", content: fullContent };
if (thinkingParts.length > 0) {
msg.reasoning_content = thinkingParts.join("\n");
}
const promptTokens = Math.ceil(fullContent.length / 4);
const completionTokens = Math.ceil(fullContent.length / 4);
return new Response(
JSON.stringify({
id: cid,
object: "chat.completion",
created,
model,
system_fingerprint: fingerprint || null,
choices: [
{
index: 0,
message: msg,
finish_reason: "stop",
logprobs: null,
},
],
usage: {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
// ─── Executor ───────────────────────────────────────────────────────────────
export class GrokWebExecutor extends BaseExecutor {
constructor() {
super("grok-web", { id: "grok-web", baseUrl: GROK_CHAT_API });
}
async execute({
model,
body,
stream,
credentials,
signal,
log,
upstreamExtraHeaders,
}: ExecuteInput) {
const messages = (body as Record<string, unknown>).messages as
Array<Record<string, unknown>> | undefined;
if (!messages || !Array.isArray(messages) || messages.length === 0) {
const errResp = new Response(
JSON.stringify({
error: { message: "Missing or empty messages array", type: "invalid_request" },
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
return { response: errResp, url: GROK_CHAT_API, headers: {}, transformedBody: body };
}
// Resolve model → Grok Web mode
const modelInfo = MODEL_MAP[model];
if (!modelInfo) {
log?.info?.("GROK-WEB", `Unmapped model ${model}, defaulting to fast mode`);
}
const toolRegistry = buildGrokToolRegistry(body as Record<string, unknown>);
const { modeId, isThinking } = modelInfo || MODEL_MAP.fast;
// Parse OpenAI messages → single Grok message string
const message = buildGrokMessage(
messages,
toolRegistry,
(body as Record<string, unknown>).tool_choice
);
if (!message.trim()) {
const errResp = new Response(
JSON.stringify({
error: { message: "Empty query after processing", type: "invalid_request" },
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
return { response: errResp, url: GROK_CHAT_API, headers: {}, transformedBody: body };
}
// Build Grok request payload
const grokPayload: Record<string, unknown> = {
temporary: true,
modeId,
message: message,
fileAttachments: [],
imageAttachments: [],
disableSearch: false,
enableImageGeneration: false,
returnImageBytes: false,
returnRawGrokInXaiRequest: false,
enableImageStreaming: false,
imageGenerationCount: 0,
forceConcise: false,
toolOverrides: {},
enableSideBySide: true,
sendFinalMetadata: true,
isReasoning: false,
disableTextFollowUps: false,
disableMemory: true,
forceSideBySide: false,
isAsyncChat: false,
disableSelfHarmShortCircuit: false,
deviceEnvInfo: {
darkModeEnabled: false,
devicePixelRatio: 2,
screenWidth: 2056,
screenHeight: 1329,
viewportWidth: 2056,
viewportHeight: 1083,
},
};
// Build headers
const traceId = randomHex(16);
const spanId = randomHex(8);
const headers: Record<string, string> = {
Accept: "*/*",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9",
Baggage:
"sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c",
"Cache-Control": "no-cache",
"Content-Type": "application/json",
Origin: "https://grok.com",
Pragma: "no-cache",
Referer: "https://grok.com/",
"Sec-Ch-Ua": '"Google Chrome";v="149", "Chromium";v="149", "Not(A:Brand";v="24"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"macOS"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": GROK_USER_AGENT,
"x-statsig-id": generateStatsigId(),
"x-xai-request-id": crypto.randomUUID(),
traceparent: `00-${traceId}-${spanId}-00`,
};
// Cookie auth — accepts a bare value, "sso=<value>", or a full DevTools
// cookie blob. Forwards both `sso` and (when present) the paired `sso-rw`
// write cookie, which Grok's anti-bot now requires (#3063).
if (credentials.apiKey) {
const cookieHeader = buildGrokCookieHeader(credentials.apiKey);
if (cookieHeader) headers["Cookie"] = cookieHeader;
}
// Apply upstream extra headers
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
log?.info?.("GROK-WEB", `Query to ${model} (modeId=${modeId}), len=${message.length}`);
// Apply fetch timeout
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal;
// Fetch from Grok via TLS-impersonating client (#3180).
// Grok sits behind Cloudflare Enterprise which rejects Node's native TLS
// fingerprint even with valid sso+sso-rw cookies. We use tls-client-node
// to send a Chrome-like handshake instead.
let tlsResult: TlsFetchResult;
try {
tlsResult = await tlsFetchGrok(GROK_CHAT_API, {
method: "POST",
headers,
body: JSON.stringify(grokPayload),
timeoutMs: FETCH_TIMEOUT_MS,
signal: combinedSignal,
stream: true,
streamEofSymbol: "[DONE]",
});
} catch (err) {
if (err instanceof TlsClientUnavailableError) {
log?.error?.("GROK-WEB", `TLS client unavailable: ${err.message}`);
const errResp = new Response(
JSON.stringify({
error: {
message: sanitizeErrorMessage(`Grok TLS client unavailable: ${err.message}`),
type: "upstream_error",
code: "TLS_CLIENT_UNAVAILABLE",
},
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
);
return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload };
}
log?.error?.("GROK-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`);
const errResp = new Response(
JSON.stringify({
error: {
message: sanitizeErrorMessage(
`Grok connection failed: ${err instanceof Error ? err.message : String(err)}`
),
type: "upstream_error",
},
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
);
return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload };
}
if (!tlsResult.body) {
// Non-streaming fallback (shouldn't happen for chat, but handle gracefully)
const status = tlsResult.status;
let errMsg = `Grok returned HTTP ${status}`;
if (status === 401 || status === 403) {
errMsg =
"Grok auth failed — SSO cookie may be expired. Re-paste your sso cookie value from grok.com.";
} else if (status === 429) {
errMsg = "Grok rate limited. Wait a moment and retry, or rotate cookies.";
}
log?.warn?.("GROK-WEB", errMsg);
const errResp = new Response(
JSON.stringify({
error: { message: errMsg, type: "upstream_error", code: `HTTP_${status}` },
}),
{ status, headers: { "Content-Type": "application/json" } }
);
return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload };
}
// Build OpenAI-compatible response
const cid = `chatcmpl-grok-${crypto.randomUUID().slice(0, 12)}`;
const created = Math.floor(Date.now() / 1000);
let finalResponse: Response;
if (stream) {
const sseStream = buildStreamingResponse(
tlsResult.body,
model,
cid,
created,
isThinking,
toolRegistry,
signal
);
finalResponse = new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
});
} else {
finalResponse = await buildNonStreamingResponse(
tlsResult.body,
model,
cid,
created,
isThinking,
toolRegistry,
signal
);
}
return { response: finalResponse, url: GROK_CHAT_API, headers, transformedBody: grokPayload };
}
}