From 97dca71c2cd06cafc66d31cd905f6ce352c91de5 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Wed, 15 Apr 2026 23:01:25 +0200 Subject: [PATCH] feat: add Grok Web (Subscription) provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- open-sse/config/providerRegistry.ts | 24 ++ open-sse/executors/grok-web.ts | 581 ++++++++++++++++++++++++++++ open-sse/executors/index.ts | 3 + src/shared/constants/providers.ts | 10 + tests/unit/grok-web.test.ts | 353 +++++++++++++++++ 5 files changed, 971 insertions(+) create mode 100644 open-sse/executors/grok-web.ts create mode 100644 tests/unit/grok-web.test.ts diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 9de66365..fce6b480 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1039,6 +1039,30 @@ export const REGISTRY: Record = { ], }, + "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", diff --git a/open-sse/executors/grok-web.ts b/open-sse/executors/grok-web.ts new file mode 100644 index 00000000..c11e509c --- /dev/null +++ b/open-sse/executors/grok-web.ts @@ -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 = { + "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>): 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>) + .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, + signal?: AbortSignal | null, +): AsyncGenerator { + 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, + isThinkingModel: boolean, + signal?: AbortSignal | null, +): AsyncGenerator { + 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, + model: string, + cid: string, + created: number, + isThinkingModel: boolean, + signal?: AbortSignal | null, +): ReadableStream { + 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, + model: string, + cid: string, + created: number, + isThinkingModel: boolean, + signal?: AbortSignal | null, +): Promise { + 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 = { 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).messages as + | Array> + | 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 = { + 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 = { + "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 }; + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index b3bbb134..46ef4543 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 073d958f..f3a28aac 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.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", diff --git a/tests/unit/grok-web.test.ts b/tests/unit/grok-web.test.ts new file mode 100644 index 00000000..7aec0978 --- /dev/null +++ b/tests/unit/grok-web.test.ts @@ -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 = {}; + let capturedBody: Record = {}; + 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(); } +});