feat: add Grok Web (Subscription) provider
Adds a new provider that routes through Grok's internal NDJSON API using an X/Grok subscription SSO cookie, enabling access to Grok 3, 4, 4.1, 4.2/4.20, and 4 Heavy via grok.com without xAI API costs. - GrokWebExecutor with full NDJSON→OpenAI translation - 12 models incl. grok-4.2 (4.20 beta), grok-4-heavy (SuperGrok) - Dynamic x-statsig-id generation (base64 fake TypeError) - W3C traceparent headers for Cloudflare compatibility - Thinking/reasoning mode for mini/thinking/expert/heavy variants - SSO cookie auth with auto-strip of 'sso=' prefix - Fetch timeout + upstreamExtraHeaders (BaseExecutor parity) - 16 unit tests, all passing Derived from: GrokProxy, GrokBridge, grok-web-api, grok2api-merged, Grok API Research Report
This commit is contained in:
@@ -1039,6 +1039,30 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
"grok-web": {
|
||||
id: "grok-web",
|
||||
alias: "grok-web",
|
||||
format: "openai",
|
||||
executor: "grok-web",
|
||||
baseUrl: "https://grok.com/rest/app-chat/conversations/new",
|
||||
authType: "apikey",
|
||||
authHeader: "cookie",
|
||||
models: [
|
||||
{ id: "grok-3", name: "Grok 3" },
|
||||
{ id: "grok-3-mini", name: "Grok 3 Mini (Thinking)" },
|
||||
{ id: "grok-3-thinking", name: "Grok 3 Thinking" },
|
||||
{ id: "grok-4", name: "Grok 4" },
|
||||
{ id: "grok-4-mini", name: "Grok 4 Mini (Thinking)" },
|
||||
{ id: "grok-4-thinking", name: "Grok 4 Thinking" },
|
||||
{ id: "grok-4-heavy", name: "Grok 4 Heavy (SuperGrok)" },
|
||||
{ id: "grok-4.1-mini", name: "Grok 4.1 Mini (Thinking)" },
|
||||
{ id: "grok-4.1-fast", name: "Grok 4.1 Fast" },
|
||||
{ id: "grok-4.1-expert", name: "Grok 4.1 Expert" },
|
||||
{ id: "grok-4.1-thinking", name: "Grok 4.1 Thinking" },
|
||||
{ id: "grok-4.2", name: "Grok 4.2 (4.20 Beta)" },
|
||||
],
|
||||
},
|
||||
|
||||
mistral: {
|
||||
id: "mistral",
|
||||
alias: "mistral",
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const GROK_CHAT_API = "https://grok.com/rest/app-chat/conversations/new";
|
||||
const GROK_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
|
||||
|
||||
// ─── Model mappings ─────────────────────────────────────────────────────────
|
||||
// Maps OmniRoute model IDs → [grokModel, modelMode]
|
||||
|
||||
interface GrokModelInfo {
|
||||
grokModel: string;
|
||||
modelMode: string;
|
||||
isThinking: boolean;
|
||||
}
|
||||
|
||||
const MODEL_MAP: Record<string, GrokModelInfo> = {
|
||||
"grok-3": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3", isThinking: false },
|
||||
"grok-3-mini": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3_MINI_THINKING", isThinking: true },
|
||||
"grok-3-thinking": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3_THINKING", isThinking: true },
|
||||
"grok-4": { grokModel: "grok-4", modelMode: "MODEL_MODE_GROK_4", isThinking: false },
|
||||
"grok-4-mini": { grokModel: "grok-4-mini", modelMode: "MODEL_MODE_GROK_4_MINI_THINKING", isThinking: true },
|
||||
"grok-4-thinking": { grokModel: "grok-4", modelMode: "MODEL_MODE_GROK_4_THINKING", isThinking: true },
|
||||
"grok-4-heavy": { grokModel: "grok-4", modelMode: "MODEL_MODE_HEAVY", isThinking: true },
|
||||
"grok-4.1-mini": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_GROK_4_1_MINI_THINKING", isThinking: true },
|
||||
"grok-4.1-fast": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_FAST", isThinking: false },
|
||||
"grok-4.1-expert": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_EXPERT", isThinking: true },
|
||||
"grok-4.1-thinking": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_GROK_4_1_THINKING", isThinking: true },
|
||||
"grok-4.2": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false },
|
||||
"grok-4.20": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false },
|
||||
"grok-4.20-beta": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false },
|
||||
};
|
||||
|
||||
// ─── 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("");
|
||||
}
|
||||
|
||||
// ─── OpenAI message → Grok query translation ───────────────────────────────
|
||||
|
||||
function parseOpenAIMessages(messages: Array<Record<string, unknown>>): string {
|
||||
const parts: string[] = [];
|
||||
let lastUserIdx = -1;
|
||||
|
||||
// Extract text from each message
|
||||
const extracted: Array<{ role: string; text: string }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
let role = String(msg.role || "user");
|
||||
if (role === "developer") role = "system";
|
||||
|
||||
let content = "";
|
||||
if (typeof msg.content === "string") {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = (msg.content as Array<Record<string, unknown>>)
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => String(c.text || ""))
|
||||
.join(" ");
|
||||
}
|
||||
if (!content.trim()) continue;
|
||||
extracted.push({ role, text: content });
|
||||
}
|
||||
|
||||
// Find last user message index
|
||||
for (let i = extracted.length - 1; i >= 0; i--) {
|
||||
if (extracted[i].role === "user") {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build combined message — last user message is raw, others are prefixed
|
||||
for (let i = 0; i < extracted.length; i++) {
|
||||
const { role, text } = extracted[i];
|
||||
if (i === lastUserIdx) {
|
||||
parts.push(text);
|
||||
} else {
|
||||
parts.push(`${role}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
// ─── NDJSON stream types ────────────────────────────────────────────────────
|
||||
|
||||
interface GrokStreamResponse {
|
||||
token?: string;
|
||||
responseId?: string;
|
||||
llmInfo?: { modelHash?: string };
|
||||
modelResponse?: {
|
||||
message?: string;
|
||||
responseId?: string;
|
||||
generatedImageUrls?: string[];
|
||||
metadata?: { llm_info?: { modelHash?: string } };
|
||||
pipelineToken?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GrokStreamEvent {
|
||||
result?: { response?: GrokStreamResponse };
|
||||
error?: { message?: string; code?: string };
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
fingerprint?: string;
|
||||
responseId?: string;
|
||||
fullMessage?: string;
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
async function* extractContent(
|
||||
eventStream: ReadableStream<Uint8Array>,
|
||||
isThinkingModel: boolean,
|
||||
signal?: AbortSignal | null,
|
||||
): AsyncGenerator<ContentChunk> {
|
||||
let fingerprint = "";
|
||||
let responseId = "";
|
||||
let thinkOpened = 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;
|
||||
}
|
||||
|
||||
// modelResponse = final/complete response
|
||||
if (resp.modelResponse) {
|
||||
const mr = resp.modelResponse;
|
||||
|
||||
// Close thinking block if open
|
||||
if (thinkOpened && isThinkingModel) {
|
||||
if (mr.message) {
|
||||
yield { thinking: mr.message };
|
||||
}
|
||||
thinkOpened = false;
|
||||
}
|
||||
|
||||
// Extract final message
|
||||
if (mr.message) {
|
||||
yield { fullMessage: mr.message, fingerprint, responseId };
|
||||
}
|
||||
|
||||
// Extract fingerprint from metadata
|
||||
if (mr.metadata?.llm_info?.modelHash) {
|
||||
fingerprint = mr.metadata.llm_info.modelHash;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Streaming token
|
||||
if (resp.token != null) {
|
||||
yield { delta: resp.token, fingerprint, responseId };
|
||||
}
|
||||
}
|
||||
|
||||
yield { done: true, fingerprint, responseId };
|
||||
}
|
||||
|
||||
// ─── OpenAI SSE format builders ─────────────────────────────────────────────
|
||||
|
||||
function sseChunk(data: unknown): string {
|
||||
return `data: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function buildStreamingResponse(
|
||||
eventStream: ReadableStream<Uint8Array>,
|
||||
model: string,
|
||||
cid: string,
|
||||
created: number,
|
||||
isThinkingModel: boolean,
|
||||
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 = "";
|
||||
|
||||
for await (const chunk of extractContent(eventStream, isThinkingModel, signal)) {
|
||||
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.done) break;
|
||||
|
||||
if (chunk.delta) {
|
||||
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: `[Stream error: ${err instanceof Error ? err.message : String(err)}]` }, finish_reason: "stop", logprobs: null }],
|
||||
})));
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function buildNonStreamingResponse(
|
||||
eventStream: ReadableStream<Uint8Array>,
|
||||
model: string,
|
||||
cid: string,
|
||||
created: number,
|
||||
isThinkingModel: boolean,
|
||||
signal?: AbortSignal | null,
|
||||
): Promise<Response> {
|
||||
let fullContent = "";
|
||||
let fingerprint = "";
|
||||
const thinkingParts: string[] = [];
|
||||
|
||||
for await (const chunk of extractContent(eventStream, isThinkingModel, 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.done) break;
|
||||
if (chunk.fullMessage) {
|
||||
fullContent = chunk.fullMessage;
|
||||
} else if (chunk.delta) {
|
||||
fullContent += chunk.delta;
|
||||
}
|
||||
}
|
||||
|
||||
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 internal model/mode
|
||||
const modelInfo = MODEL_MAP[model];
|
||||
if (!modelInfo) {
|
||||
log?.info?.("GROK-WEB", `Unmapped model ${model}, defaulting to grok-4.1-fast`);
|
||||
}
|
||||
const { grokModel, modelMode, isThinking } = modelInfo || MODEL_MAP["grok-4.1-fast"];
|
||||
|
||||
// Parse OpenAI messages → single Grok message string
|
||||
const message = parseOpenAIMessages(messages);
|
||||
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,
|
||||
modelName: grokModel,
|
||||
modelMode: modelMode,
|
||||
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="136", "Chromium";v="136", "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 — strip "sso=" prefix if user included it
|
||||
if (credentials.apiKey) {
|
||||
let token = credentials.apiKey;
|
||||
if (token.startsWith("sso=")) token = token.slice(4);
|
||||
headers["Cookie"] = `sso=${token}`;
|
||||
}
|
||||
|
||||
// Apply upstream extra headers
|
||||
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
|
||||
|
||||
log?.info?.(
|
||||
"GROK-WEB",
|
||||
`Query to ${model} (grok=${grokModel}, mode=${modelMode}), len=${message.length}`,
|
||||
);
|
||||
|
||||
// Apply fetch timeout
|
||||
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||
const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal;
|
||||
|
||||
// Fetch from Grok
|
||||
const fetchOptions: RequestInit = {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(grokPayload),
|
||||
signal: combinedSignal,
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(GROK_CHAT_API, fetchOptions);
|
||||
} catch (err) {
|
||||
log?.error?.(
|
||||
"GROK-WEB",
|
||||
`Fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
const errResp = new Response(
|
||||
JSON.stringify({
|
||||
error: { message: `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 (!response.ok) {
|
||||
const status = response.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 };
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
const errResp = new Response(
|
||||
JSON.stringify({ error: { message: "Grok returned empty response body", type: "upstream_error" } }),
|
||||
{ status: 502, 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(
|
||||
response.body, model, cid, created, isThinking, 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(
|
||||
response.body, model, cid, created, isThinking, signal,
|
||||
);
|
||||
}
|
||||
|
||||
return { response: finalResponse, url: GROK_CHAT_API, headers, transformedBody: grokPayload };
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { OpencodeExecutor } from "./opencode.ts";
|
||||
import { PuterExecutor } from "./puter.ts";
|
||||
import { VertexExecutor } from "./vertex.ts";
|
||||
import { CliproxyapiExecutor } from "./cliproxyapi.ts";
|
||||
import { GrokWebExecutor } from "./grok-web.ts";
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
@@ -33,6 +34,7 @@ const executors = {
|
||||
vertex: new VertexExecutor(),
|
||||
cliproxyapi: new CliproxyapiExecutor(),
|
||||
cpa: new CliproxyapiExecutor(), // Alias
|
||||
"grok-web": new GrokWebExecutor(),
|
||||
};
|
||||
|
||||
const defaultCache = new Map();
|
||||
@@ -62,3 +64,4 @@ export { OpencodeExecutor } from "./opencode.ts";
|
||||
export { PuterExecutor } from "./puter.ts";
|
||||
export { CliproxyapiExecutor } from "./cliproxyapi.ts";
|
||||
export { VertexExecutor } from "./vertex.ts";
|
||||
export { GrokWebExecutor } from "./grok-web.ts";
|
||||
|
||||
@@ -214,6 +214,16 @@ export const APIKEY_PROVIDERS = {
|
||||
textIcon: "XA",
|
||||
website: "https://x.ai",
|
||||
},
|
||||
"grok-web": {
|
||||
id: "grok-web",
|
||||
alias: "grok-web",
|
||||
name: "Grok Web (Subscription)",
|
||||
icon: "auto_awesome",
|
||||
color: "#1DA1F2",
|
||||
textIcon: "GW",
|
||||
website: "https://grok.com",
|
||||
authHint: "Paste your sso cookie value from grok.com (found in browser DevTools → Cookies → sso)",
|
||||
},
|
||||
mistral: {
|
||||
id: "mistral",
|
||||
alias: "mistral",
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { GrokWebExecutor } = await import("../../open-sse/executors/grok-web.ts");
|
||||
const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockGrokStream(events: unknown[]) {
|
||||
const encoder = new TextEncoder();
|
||||
const lines = events.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(lines));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mockFetch(status: number, events: unknown[]) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(mockGrokStream(events), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return () => { globalThis.fetch = original; };
|
||||
}
|
||||
|
||||
function mockFetchCapture(events: unknown[]) {
|
||||
const original = globalThis.fetch;
|
||||
let capturedUrl: string | null = null;
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
let capturedBody: Record<string, unknown> = {};
|
||||
globalThis.fetch = async (url: any, opts: any) => {
|
||||
capturedUrl = String(url);
|
||||
capturedHeaders = opts?.headers || {};
|
||||
capturedBody = JSON.parse(opts?.body || "{}");
|
||||
return new Response(mockGrokStream(events), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
return {
|
||||
restore: () => { globalThis.fetch = original; },
|
||||
get url() { return capturedUrl; },
|
||||
get headers() { return capturedHeaders; },
|
||||
get body() { return capturedBody; },
|
||||
};
|
||||
}
|
||||
|
||||
const SIMPLE_RESPONSE = [
|
||||
{ result: { response: { token: "Hello" } } },
|
||||
{ result: { response: { token: " world!" } } },
|
||||
{ result: { response: { modelResponse: { message: "Hello world!", responseId: "resp-123" } } } },
|
||||
];
|
||||
|
||||
// ─── Registration ───────────────────────────────────────────────────────────
|
||||
|
||||
test("GrokWebExecutor is registered in executor index", () => {
|
||||
assert.ok(hasSpecializedExecutor("grok-web"));
|
||||
const executor = getExecutor("grok-web");
|
||||
assert.ok(executor instanceof GrokWebExecutor);
|
||||
});
|
||||
|
||||
test("GrokWebExecutor sets correct provider name", () => {
|
||||
const executor = new GrokWebExecutor();
|
||||
assert.equal(executor.getProvider(), "grok-web");
|
||||
});
|
||||
|
||||
// ─── Non-streaming ──────────────────────────────────────────────────────────
|
||||
|
||||
test("Non-streaming: simple response", async () => {
|
||||
const restore = mockFetch(200, SIMPLE_RESPONSE);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "grok-4.1-fast",
|
||||
body: { messages: [{ role: "user", content: "hi" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test-sso-token" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(result.response.status, 200);
|
||||
const json = await result.response.json();
|
||||
assert.equal(json.object, "chat.completion");
|
||||
assert.equal(json.choices[0].message.role, "assistant");
|
||||
assert.equal(json.choices[0].message.content, "Hello world!");
|
||||
assert.equal(json.choices[0].finish_reason, "stop");
|
||||
assert.ok(json.id.startsWith("chatcmpl-grok-"));
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
// ─── Streaming ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("Streaming: produces valid SSE chunks", async () => {
|
||||
const restore = mockFetch(200, SIMPLE_RESPONSE);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "grok-4.1-fast",
|
||||
body: { messages: [{ role: "user", content: "hello" }], stream: true },
|
||||
stream: true,
|
||||
credentials: { apiKey: "test-sso" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
|
||||
|
||||
const text = await result.response.text();
|
||||
const lines = text.split("\n").filter((l: string) => l.startsWith("data: "));
|
||||
assert.ok(lines.length >= 3, `Expected at least 3 SSE data lines, got ${lines.length}`);
|
||||
|
||||
// First chunk has role
|
||||
const first = JSON.parse(lines[0].slice(6));
|
||||
assert.equal(first.choices[0].delta.role, "assistant");
|
||||
|
||||
// Last line is [DONE]
|
||||
const lastLine = text.trim().split("\n").filter(Boolean).pop();
|
||||
assert.equal(lastLine, "data: [DONE]");
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
// ─── Error handling ─────────────────────────────────────────────────────────
|
||||
|
||||
test("Error: 401 returns auth error", async () => {
|
||||
const restore = mockFetch(401, []);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "grok-4",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "expired" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(result.response.status, 401);
|
||||
const json = await result.response.json();
|
||||
assert.ok(json.error.message.includes("auth failed"));
|
||||
assert.ok(json.error.message.includes("sso"));
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test("Error: 429 returns rate limit message", async () => {
|
||||
const restore = mockFetch(429, []);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "grok-4",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(result.response.status, 429);
|
||||
const json = await result.response.json();
|
||||
assert.ok(json.error.message.includes("rate limited"));
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test("Error: empty messages returns 400", async () => {
|
||||
const executor = new GrokWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "grok-4",
|
||||
body: { messages: [] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(result.response.status, 400);
|
||||
});
|
||||
|
||||
test("Error: Grok stream error returns 502", async () => {
|
||||
const restore = mockFetch(200, [{ error: { message: "Internal error", code: "500" } }]);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "grok-4",
|
||||
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(result.response.status, 502);
|
||||
const json = await result.response.json();
|
||||
assert.ok(json.error.message.includes("Internal error"));
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
// ─── Auth headers ───────────────────────────────────────────────────────────
|
||||
|
||||
test("Auth: cookie sends sso= header", async () => {
|
||||
const cap = mockFetchCapture(SIMPLE_RESPONSE);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
await executor.execute({
|
||||
model: "grok-4.1-fast",
|
||||
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "my-sso-token-value" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(cap.headers["Cookie"], "sso=my-sso-token-value");
|
||||
} finally { cap.restore(); }
|
||||
});
|
||||
|
||||
test("Auth: strips sso= prefix if user included it", async () => {
|
||||
const cap = mockFetchCapture(SIMPLE_RESPONSE);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
await executor.execute({
|
||||
model: "grok-4.1-fast",
|
||||
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "sso=my-token" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(cap.headers["Cookie"], "sso=my-token");
|
||||
assert.ok(!cap.headers["Cookie"].includes("sso=sso="));
|
||||
} finally { cap.restore(); }
|
||||
});
|
||||
|
||||
// ─── Request format ─────────────────────────────────────────────────────────
|
||||
|
||||
test("Request: posts to correct Grok endpoint", async () => {
|
||||
const cap = mockFetchCapture(SIMPLE_RESPONSE);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
await executor.execute({
|
||||
model: "grok-4.1-fast",
|
||||
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(cap.url, "https://grok.com/rest/app-chat/conversations/new");
|
||||
assert.equal(cap.headers["Origin"], "https://grok.com");
|
||||
assert.ok(cap.headers["x-statsig-id"], "Should have x-statsig-id header");
|
||||
assert.ok(cap.headers["x-xai-request-id"], "Should have x-xai-request-id header");
|
||||
assert.ok(cap.headers["traceparent"]?.startsWith("00-"), "Should have W3C traceparent");
|
||||
} finally { cap.restore(); }
|
||||
});
|
||||
|
||||
test("Request: payload has correct model mapping", async () => {
|
||||
const cap = mockFetchCapture(SIMPLE_RESPONSE);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
await executor.execute({
|
||||
model: "grok-4.1-expert",
|
||||
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(cap.body.modelName, "grok-4-1-thinking-1129");
|
||||
assert.equal(cap.body.modelMode, "MODEL_MODE_EXPERT");
|
||||
assert.equal(cap.body.temporary, true);
|
||||
} finally { cap.restore(); }
|
||||
});
|
||||
|
||||
test("Request: grok-4-heavy maps to heavy mode", async () => {
|
||||
const cap = mockFetchCapture(SIMPLE_RESPONSE);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
await executor.execute({
|
||||
model: "grok-4-heavy",
|
||||
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
assert.equal(cap.body.modelName, "grok-4");
|
||||
assert.equal(cap.body.modelMode, "MODEL_MODE_HEAVY");
|
||||
} finally { cap.restore(); }
|
||||
});
|
||||
|
||||
// ─── Message parsing ────────────────────────────────────────────────────────
|
||||
|
||||
test("Message parsing: combines system + history + user", async () => {
|
||||
const cap = mockFetchCapture(SIMPLE_RESPONSE);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
await executor.execute({
|
||||
model: "grok-4.1-fast",
|
||||
body: {
|
||||
messages: [
|
||||
{ role: "system", content: "Be helpful" },
|
||||
{ role: "user", content: "First question" },
|
||||
{ role: "assistant", content: "First answer" },
|
||||
{ role: "user", content: "Follow up" },
|
||||
],
|
||||
stream: false,
|
||||
},
|
||||
stream: false,
|
||||
credentials: { apiKey: "test" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
const msg = cap.body.message as string;
|
||||
assert.ok(msg.includes("Follow up"), "Should contain current user message");
|
||||
assert.ok(msg.includes("Be helpful"), "Should contain system message");
|
||||
assert.ok(msg.includes("First answer"), "Should contain assistant history");
|
||||
} finally { cap.restore(); }
|
||||
});
|
||||
|
||||
// ─── Provider registry ──────────────────────────────────────────────────────
|
||||
|
||||
test("Provider registry: grok-web has correct models", async () => {
|
||||
const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts");
|
||||
const models = PROVIDER_MODELS["grok-web"];
|
||||
assert.ok(models, "grok-web should be in PROVIDER_MODELS");
|
||||
assert.equal(models.length, 12, `Expected 12 models, got ${models.length}`);
|
||||
const ids = models.map((m: any) => m.id);
|
||||
assert.ok(ids.includes("grok-3"));
|
||||
assert.ok(ids.includes("grok-4"));
|
||||
assert.ok(ids.includes("grok-4-heavy"));
|
||||
assert.ok(ids.includes("grok-4.1-fast"));
|
||||
assert.ok(ids.includes("grok-4.1-expert"));
|
||||
assert.ok(ids.includes("grok-4.1-thinking"));
|
||||
assert.ok(ids.includes("grok-4.2"));
|
||||
});
|
||||
|
||||
// ─── Statsig header ─────────────────────────────────────────────────────────
|
||||
|
||||
test("Statsig: x-statsig-id is valid base64", async () => {
|
||||
const cap = mockFetchCapture(SIMPLE_RESPONSE);
|
||||
try {
|
||||
const executor = new GrokWebExecutor();
|
||||
await executor.execute({
|
||||
model: "grok-4.1-fast",
|
||||
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
const statsig = cap.headers["x-statsig-id"];
|
||||
assert.ok(statsig, "Should have statsig header");
|
||||
const decoded = atob(statsig);
|
||||
assert.ok(decoded.startsWith("e:TypeError:"), `Decoded statsig should start with e:TypeError:, got: ${decoded}`);
|
||||
} finally { cap.restore(); }
|
||||
});
|
||||
Reference in New Issue
Block a user