@elvatis_com/openclaw-cli-bridge-elvatis 0.2.25 → 0.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,10 +11,10 @@ _Last session: 2026-03-11 — Akido (claude-sonnet-4-6)_
11
11
 
12
12
  | Platform | Version | Status |
13
13
  |----------|---------|--------|
14
- | GitHub | v0.2.23 | ✅ Tagged + Release (last published) |
15
- | npm | 0.2.23 | ✅ Published (last published) |
16
- | ClawHub | 0.2.23 | ✅ Published (last published) |
17
- | Local | 0.2.25 | Built + tested, not yet published |
14
+ | GitHub | v0.2.25 | ✅ Tagged + Release |
15
+ | npm | 0.2.25 | ✅ Published |
16
+ | ClawHub | 0.2.25 | ✅ Published (direct API — clawhub CLI v0.7.0 bug: missing acceptLicenseTerms) |
17
+ | Local | 0.2.25 | Up to date |
18
18
  <!-- /SECTION: version -->
19
19
 
20
20
  <!-- SECTION: build_health -->
@@ -55,7 +55,7 @@ _Last session: 2026-03-11 — Akido (claude-sonnet-4-6)_
55
55
  <!-- SECTION: what_is_missing -->
56
56
  ## What Is Missing / Open
57
57
 
58
- - **Publish v0.2.25** — GitHub tag + release, npm publish, ClawHub publish (T-010)
58
+ - **v0.2.25 published** — GitHub, npm, ClawHub alle auf 0.2.25
59
59
  - ℹ️ **Claude CLI auth expires ~90 days** — when `/cli-test` returns 401, run `claude auth login`
60
60
  - ℹ️ **Config patcher writes `openclaw.json` directly** — triggers one gateway restart on first install
61
61
  - ℹ️ **ClawHub publish ignores `.clawhubignore`** — use rsync workaround (see CONVENTIONS.md)
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `0.2.25`
5
+ **Current version:** `0.2.26`
6
6
 
7
7
  ---
8
8
 
@@ -287,6 +287,13 @@ npm test # vitest run (45 tests)
287
287
 
288
288
  ## Changelog
289
289
 
290
+ ### v0.2.26
291
+ - **feat:** Grok web-session bridge integrated into cli-bridge proxy — routes `web-grok/*` models through grok.com browser session (SuperGrok subscription, no API credits needed)
292
+ - **feat:** `/grok-login` — opens Chromium for X.com OAuth login, saves session to `~/.openclaw/grok-session.json`
293
+ - **feat:** `/grok-status` — check session validity
294
+ - **feat:** `/grok-logout` — clear session
295
+ - **fix:** Grok web-session plugin removed as separate plugin — consolidated into cli-bridge (fewer running processes, single proxy port)
296
+
290
297
  ### v0.2.25
291
298
  - **feat:** Staged model switching — `/cli-*` now stages the switch instead of applying it immediately. Prevents silent session corruption when switching models mid-conversation.
292
299
  - `/cli-sonnet` → stages switch, shows warning, does NOT apply
package/SKILL.md CHANGED
@@ -53,4 +53,4 @@ Each command runs `openclaw models set <model>` atomically and replies with a co
53
53
 
54
54
  See `README.md` for full configuration reference and architecture diagram.
55
55
 
56
- **Version:** 0.2.25
56
+ **Version:** 0.2.26
package/index.ts CHANGED
@@ -48,6 +48,16 @@ import {
48
48
  } from "./src/codex-auth.js";
49
49
  import { startProxyServer } from "./src/proxy-server.js";
50
50
  import { patchOpencllawConfig } from "./src/config-patcher.js";
51
+ import {
52
+ loadSession,
53
+ deleteSession,
54
+ isSessionExpiredByAge,
55
+ verifySession,
56
+ runInteractiveLogin,
57
+ createContextFromSession,
58
+ DEFAULT_SESSION_PATH,
59
+ } from "./src/grok-session.js";
60
+ import type { BrowserContext, Browser } from "playwright";
51
61
 
52
62
  // ──────────────────────────────────────────────────────────────────────────────
53
63
  // Types derived from SDK (not re-exported by the package)
@@ -66,6 +76,46 @@ interface CliPluginConfig {
66
76
  proxyPort?: number;
67
77
  proxyApiKey?: string;
68
78
  proxyTimeoutMs?: number;
79
+ grokSessionPath?: string;
80
+ }
81
+
82
+ // ──────────────────────────────────────────────────────────────────────────────
83
+ // Grok web-session state (module-level, persists across commands)
84
+ // ──────────────────────────────────────────────────────────────────────────────
85
+
86
+ let grokBrowser: Browser | null = null;
87
+ let grokContext: BrowserContext | null = null;
88
+
89
+ async function launchGrokBrowser(): Promise<Browser> {
90
+ const { chromium } = await import("playwright");
91
+ return chromium.launch({ headless: false });
92
+ }
93
+
94
+ async function tryRestoreGrokSession(
95
+ sessionPath: string,
96
+ log: (msg: string) => void
97
+ ): Promise<boolean> {
98
+ const saved = loadSession(sessionPath);
99
+ if (!saved || isSessionExpiredByAge(saved)) {
100
+ log("[cli-bridge:grok] no valid saved session");
101
+ return false;
102
+ }
103
+ try {
104
+ if (!grokBrowser) grokBrowser = await launchGrokBrowser();
105
+ const ctx = await createContextFromSession(grokBrowser, saved);
106
+ const check = await verifySession(ctx, log);
107
+ if (!check.valid) {
108
+ log(`[cli-bridge:grok] saved session invalid: ${check.reason}`);
109
+ await ctx.close().catch(() => {});
110
+ return false;
111
+ }
112
+ grokContext = ctx;
113
+ log("[cli-bridge:grok] session restored ✅");
114
+ return true;
115
+ } catch (err) {
116
+ log(`[cli-bridge:grok] session restore error: ${(err as Error).message}`);
117
+ return false;
118
+ }
69
119
  }
70
120
 
71
121
  const DEFAULT_PROXY_PORT = 31337;
@@ -393,7 +443,7 @@ function proxyTestRequest(
393
443
  const plugin = {
394
444
  id: "openclaw-cli-bridge-elvatis",
395
445
  name: "OpenClaw CLI Bridge",
396
- version: "0.2.25",
446
+ version: "0.2.26",
397
447
  description:
398
448
  "Phase 1: openai-codex auth bridge. " +
399
449
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -407,6 +457,10 @@ const plugin = {
407
457
  const apiKey = cfg.proxyApiKey ?? DEFAULT_PROXY_API_KEY;
408
458
  const timeoutMs = cfg.proxyTimeoutMs ?? 120_000;
409
459
  const codexAuthPath = cfg.codexAuthPath ?? DEFAULT_CODEX_AUTH_PATH;
460
+ const grokSessionPath = cfg.grokSessionPath ?? DEFAULT_SESSION_PATH;
461
+
462
+ // ── Grok session restore (non-blocking) ───────────────────────────────────
463
+ void tryRestoreGrokSession(grokSessionPath, (msg) => api.logger.info(msg));
410
464
 
411
465
  // ── Phase 1: openai-codex auth bridge ─────────────────────────────────────
412
466
  if (enableCodex) {
@@ -503,6 +557,7 @@ const plugin = {
503
557
  timeoutMs,
504
558
  log: (msg) => api.logger.info(msg),
505
559
  warn: (msg) => api.logger.warn(msg),
560
+ getGrokContext: () => grokContext,
506
561
  });
507
562
  proxyServer = server;
508
563
  api.logger.info(
@@ -526,6 +581,7 @@ const plugin = {
526
581
  port, apiKey, timeoutMs,
527
582
  log: (msg) => api.logger.info(msg),
528
583
  warn: (msg) => api.logger.warn(msg),
584
+ getGrokContext: () => grokContext,
529
585
  });
530
586
  proxyServer = server;
531
587
  api.logger.info(`[cli-bridge] proxy ready on :${port} (retry)`);
@@ -771,11 +827,64 @@ const plugin = {
771
827
  },
772
828
  } satisfies OpenClawPluginCommandDefinition);
773
829
 
830
+ // ── Phase 4: Grok web-session commands ────────────────────────────────────
831
+
832
+ api.registerCommand({
833
+ name: "grok-login",
834
+ description: "Open browser to log in to grok.com (X/Twitter account)",
835
+ handler: async (): Promise<PluginCommandResult> => {
836
+ if (grokContext) {
837
+ return { text: "✅ Already logged in to grok.com. Use /grok-logout first to re-authenticate." };
838
+ }
839
+ api.logger.info("[cli-bridge:grok] starting interactive login...");
840
+ try {
841
+ if (!grokBrowser) grokBrowser = await launchGrokBrowser();
842
+ const session = await runInteractiveLogin(grokBrowser, grokSessionPath, (msg) => api.logger.info(msg));
843
+ grokContext = await createContextFromSession(grokBrowser, session);
844
+ return { text: "✅ Logged in to grok.com!\n\nGrok models available:\n• `vllm/web-grok/grok-3`\n• `vllm/web-grok/grok-3-fast`\n• `vllm/web-grok/grok-3-mini`\n\nUse `/cli-grok` to switch." };
845
+ } catch (err) {
846
+ return { text: `❌ Login failed: ${(err as Error).message}` };
847
+ }
848
+ },
849
+ } satisfies OpenClawPluginCommandDefinition);
850
+
851
+ api.registerCommand({
852
+ name: "grok-status",
853
+ description: "Check grok.com session status",
854
+ handler: async (): Promise<PluginCommandResult> => {
855
+ if (!grokContext) {
856
+ return { text: "❌ No active grok.com session\nRun `/grok-login` to authenticate." };
857
+ }
858
+ const check = await verifySession(grokContext, (msg) => api.logger.info(msg));
859
+ if (check.valid) {
860
+ return { text: `✅ grok.com session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-grok/grok-3, web-grok/grok-3-fast, web-grok/grok-3-mini, web-grok/grok-3-mini-fast` };
861
+ }
862
+ grokContext = null;
863
+ return { text: `❌ Session expired: ${check.reason}\nRun \`/grok-login\` to re-authenticate.` };
864
+ },
865
+ } satisfies OpenClawPluginCommandDefinition);
866
+
867
+ api.registerCommand({
868
+ name: "grok-logout",
869
+ description: "Clear saved grok.com session",
870
+ handler: async (): Promise<PluginCommandResult> => {
871
+ if (grokContext) {
872
+ await grokContext.close().catch(() => {});
873
+ grokContext = null;
874
+ }
875
+ deleteSession(grokSessionPath);
876
+ return { text: "✅ Logged out from grok.com. Session file deleted." };
877
+ },
878
+ } satisfies OpenClawPluginCommandDefinition);
879
+
774
880
  const allCommands = [
775
881
  ...CLI_MODEL_COMMANDS.map((c) => `/${c.name}`),
776
882
  "/cli-back",
777
883
  "/cli-test",
778
884
  "/cli-list",
885
+ "/grok-login",
886
+ "/grok-status",
887
+ "/grok-logout",
779
888
  ];
780
889
  api.logger.info(`[cli-bridge] registered ${allCommands.length} commands: ${allCommands.join(", ")}`);
781
890
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "0.2.25",
4
+ "version": "0.2.26",
5
5
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
6
6
  "providers": [
7
7
  "openai-codex"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
5
5
  "type": "module",
6
6
  "openclaw": {
@@ -18,5 +18,8 @@
18
18
  "@types/node": "^25.3.2",
19
19
  "typescript": "^5.9.3",
20
20
  "vitest": "^4.0.18"
21
+ },
22
+ "dependencies": {
23
+ "playwright": "^1.58.2"
21
24
  }
22
25
  }
@@ -0,0 +1,428 @@
1
+ /**
2
+ * grok-client.ts
3
+ *
4
+ * HTTP client that sends chat completion requests to grok.com's internal REST API
5
+ * using an authenticated browser session (cookies).
6
+ *
7
+ * Endpoint: POST https://grok.com/rest/app-chat/conversations/new
8
+ * Response: Server-Sent Events (SSE) stream
9
+ *
10
+ * This mimics what the grok.com web UI does internally.
11
+ */
12
+
13
+ import type { BrowserContext } from "playwright";
14
+
15
+ // ──────────────────────────────────────────────────────────────────────────────
16
+ // Types
17
+ // ──────────────────────────────────────────────────────────────────────────────
18
+
19
+ export interface ChatMessage {
20
+ role: "system" | "user" | "assistant";
21
+ content: string;
22
+ }
23
+
24
+ export interface GrokCompleteOptions {
25
+ messages: ChatMessage[];
26
+ model?: string; // "grok-3" | "grok-3-fast" | "grok-3-mini" | "grok-3-mini-fast"
27
+ stream?: boolean;
28
+ maxTokens?: number;
29
+ temperature?: number;
30
+ timeoutMs?: number;
31
+ }
32
+
33
+ export interface GrokCompleteResult {
34
+ content: string;
35
+ model: string;
36
+ finishReason: string;
37
+ /** estimated — grok.com doesn't expose exact token counts */
38
+ promptTokens?: number;
39
+ completionTokens?: number;
40
+ }
41
+
42
+ /** SSE token event from grok.com */
43
+ interface GrokTokenEvent {
44
+ result?: {
45
+ response?: {
46
+ token?: string;
47
+ finalMetadata?: {
48
+ inputTokenCount?: number;
49
+ outputTokenCount?: number;
50
+ };
51
+ modelResponse?: {
52
+ responseId?: string;
53
+ message?: string;
54
+ };
55
+ };
56
+ isSoftStop?: boolean;
57
+ };
58
+ error?: string;
59
+ }
60
+
61
+ // ──────────────────────────────────────────────────────────────────────────────
62
+ // Model ID mapping: OpenAI-style → grok.com internal IDs
63
+ // ──────────────────────────────────────────────────────────────────────────────
64
+
65
+ const MODEL_MAP: Record<string, string> = {
66
+ "grok-3": "grok-3",
67
+ "grok-3-fast": "grok-3-fast",
68
+ "grok-3-mini": "grok-3-mini",
69
+ "grok-3-mini-fast": "grok-3-mini-fast",
70
+ // aliases
71
+ "grok": "grok-3",
72
+ "grok-fast": "grok-3-fast",
73
+ "grok-mini": "grok-3-mini",
74
+ };
75
+
76
+ function resolveModel(model?: string): string {
77
+ if (!model) return "grok-3";
78
+ return MODEL_MAP[model] ?? model;
79
+ }
80
+
81
+ // ──────────────────────────────────────────────────────────────────────────────
82
+ // Request builder
83
+ // ──────────────────────────────────────────────────────────────────────────────
84
+
85
+ /** Build the request body for grok.com's internal API */
86
+ function buildRequestBody(opts: GrokCompleteOptions): Record<string, unknown> {
87
+ const model = resolveModel(opts.model);
88
+
89
+ // Combine messages into a single user prompt (grok.com web doesn't expose multi-turn directly)
90
+ // System prompt → prepended to first user message
91
+ const systemMsgs = opts.messages.filter((m) => m.role === "system");
92
+ const convMsgs = opts.messages.filter((m) => m.role !== "system");
93
+
94
+ let userPrompt = "";
95
+ if (systemMsgs.length > 0) {
96
+ userPrompt = systemMsgs.map((m) => m.content).join("\n") + "\n\n";
97
+ }
98
+
99
+ // Build conversation history for multi-turn
100
+ const history: Array<{ role: string; content: string }> = [];
101
+ for (let i = 0; i < convMsgs.length - 1; i++) {
102
+ history.push({ role: convMsgs[i].role, content: convMsgs[i].content });
103
+ }
104
+ const lastMsg = convMsgs[convMsgs.length - 1];
105
+ userPrompt += lastMsg?.content ?? "";
106
+
107
+ return {
108
+ temporary: false,
109
+ modelName: model,
110
+ message: userPrompt,
111
+ fileAttachments: [],
112
+ imageAttachments: [],
113
+ disableSearch: false,
114
+ enableImageGeneration: false,
115
+ returnImageBytes: false,
116
+ returnRawGrokInXaiRequest: false,
117
+ enableSideBySide: false,
118
+ isReasoning: model.includes("mini"), // mini models support reasoning
119
+ conversationHistory: history,
120
+ toolOverrides: {},
121
+ enableCustomization: false,
122
+ deepsearchPreset: "",
123
+ isPreset: false,
124
+ };
125
+ }
126
+
127
+ // ──────────────────────────────────────────────────────────────────────────────
128
+ // SSE parser
129
+ // ──────────────────────────────────────────────────────────────────────────────
130
+
131
+ function parseSSELine(line: string): GrokTokenEvent | null {
132
+ if (!line.startsWith("data: ")) return null;
133
+ const data = line.slice(6).trim();
134
+ if (data === "[DONE]") return null;
135
+ try {
136
+ return JSON.parse(data) as GrokTokenEvent;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ // ──────────────────────────────────────────────────────────────────────────────
143
+ // Main client function
144
+ // ──────────────────────────────────────────────────────────────────────────────
145
+
146
+ const GROK_API_URL = "https://grok.com/rest/app-chat/conversations/new";
147
+ const DEFAULT_TIMEOUT_MS = 120_000;
148
+
149
+ /**
150
+ * Complete a chat via grok.com's internal API using a browser session context.
151
+ * Uses page.evaluate to make the fetch from inside the authenticated browser context.
152
+ */
153
+ export async function grokComplete(
154
+ context: BrowserContext,
155
+ opts: GrokCompleteOptions,
156
+ log: (msg: string) => void
157
+ ): Promise<GrokCompleteResult> {
158
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
159
+ const model = resolveModel(opts.model);
160
+ const body = buildRequestBody(opts);
161
+
162
+ log(`grok-client: POST ${GROK_API_URL} model=${model}`);
163
+
164
+ // Open a background page in the authenticated context
165
+ const page = await context.newPage();
166
+
167
+ try {
168
+ // Navigate to grok.com first to ensure cookies are sent correctly
169
+ await page.goto("https://grok.com", {
170
+ waitUntil: "domcontentloaded",
171
+ timeout: 15_000,
172
+ });
173
+
174
+ // Make the API call from within the page (inherits cookies automatically)
175
+ const result = await page.evaluate(
176
+ async ({ url, requestBody, timeout }: { url: string; requestBody: unknown; timeout: number }) => {
177
+ const controller = new AbortController();
178
+ const timer = setTimeout(() => controller.abort(), timeout);
179
+
180
+ try {
181
+ const resp = await fetch(url, {
182
+ method: "POST",
183
+ headers: {
184
+ "Content-Type": "application/json",
185
+ Accept: "text/event-stream",
186
+ },
187
+ body: JSON.stringify(requestBody),
188
+ credentials: "include",
189
+ signal: controller.signal,
190
+ });
191
+
192
+ if (!resp.ok) {
193
+ const errText = await resp.text().catch(() => "");
194
+ return {
195
+ error: `HTTP ${resp.status}: ${errText.substring(0, 300)}`,
196
+ content: "",
197
+ };
198
+ }
199
+
200
+ const reader = resp.body!.getReader();
201
+ const decoder = new TextDecoder();
202
+ let fullText = "";
203
+ let buffer = "";
204
+ let inputTokens = 0;
205
+ let outputTokens = 0;
206
+ let finishReason = "stop";
207
+
208
+ while (true) {
209
+ const { done, value } = await reader.read();
210
+ if (done) break;
211
+
212
+ buffer += decoder.decode(value, { stream: true });
213
+ const lines = buffer.split("\n");
214
+ buffer = lines.pop() ?? "";
215
+
216
+ for (const line of lines) {
217
+ if (!line.startsWith("data: ")) continue;
218
+ const data = line.slice(6).trim();
219
+ if (data === "[DONE]") {
220
+ finishReason = "stop";
221
+ continue;
222
+ }
223
+ try {
224
+ const evt = JSON.parse(data);
225
+ const response = evt?.result?.response;
226
+ if (response?.token) {
227
+ fullText += response.token;
228
+ }
229
+ if (response?.finalMetadata) {
230
+ inputTokens = response.finalMetadata.inputTokenCount ?? 0;
231
+ outputTokens = response.finalMetadata.outputTokenCount ?? 0;
232
+ }
233
+ if (evt?.result?.isSoftStop) {
234
+ finishReason = "stop";
235
+ }
236
+ if (evt?.error) {
237
+ return { error: String(evt.error), content: fullText };
238
+ }
239
+ } catch {
240
+ // ignore parse errors on individual SSE lines
241
+ }
242
+ }
243
+ }
244
+
245
+ return {
246
+ content: fullText,
247
+ inputTokens,
248
+ outputTokens,
249
+ finishReason,
250
+ };
251
+ } finally {
252
+ clearTimeout(timer);
253
+ }
254
+ },
255
+ { url: GROK_API_URL, requestBody: body, timeout: timeoutMs }
256
+ );
257
+
258
+ if ("error" in result && result.error) {
259
+ throw new Error(`grok.com API error: ${result.error}`);
260
+ }
261
+
262
+ log(
263
+ `grok-client: done — ${result.outputTokens ?? "?"} output tokens`
264
+ );
265
+
266
+ return {
267
+ content: result.content ?? "",
268
+ model,
269
+ finishReason: result.finishReason ?? "stop",
270
+ promptTokens: result.inputTokens,
271
+ completionTokens: result.outputTokens,
272
+ };
273
+ } finally {
274
+ await page.close();
275
+ }
276
+ }
277
+
278
+ // ──────────────────────────────────────────────────────────────────────────────
279
+ // Streaming variant — yields tokens via callback
280
+ // ──────────────────────────────────────────────────────────────────────────────
281
+
282
+ export async function grokCompleteStream(
283
+ context: BrowserContext,
284
+ opts: GrokCompleteOptions,
285
+ onToken: (token: string) => void,
286
+ log: (msg: string) => void
287
+ ): Promise<GrokCompleteResult> {
288
+ // grok.com streams via SSE; we accumulate on the JS side and call onToken per chunk.
289
+ // Because page.evaluate can't stream back to Node, we use a polling approach:
290
+ // write tokens to window.__grokTokenBuf, poll from Node side.
291
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
292
+ const model = resolveModel(opts.model);
293
+ const body = buildRequestBody(opts);
294
+
295
+ log(`grok-client: streaming POST ${GROK_API_URL} model=${model}`);
296
+
297
+ const page = await context.newPage();
298
+
299
+ try {
300
+ await page.goto("https://grok.com", {
301
+ waitUntil: "domcontentloaded",
302
+ timeout: 15_000,
303
+ });
304
+
305
+ // Initialize token buffer on the page
306
+ await page.evaluate(() => {
307
+ (window as unknown as Record<string, unknown>).__grokTokenBuf = [];
308
+ (window as unknown as Record<string, unknown>).__grokDone = false;
309
+ (window as unknown as Record<string, unknown>).__grokError = null;
310
+ (window as unknown as Record<string, unknown>).__grokMeta = null;
311
+ });
312
+
313
+ // Start the fetch in the page (non-blocking — we poll from Node)
314
+ await page.evaluate(
315
+ async ({ url, requestBody, timeout }: { url: string; requestBody: unknown; timeout: number }) => {
316
+ const w = window as unknown as Record<string, unknown>;
317
+ const controller = new AbortController();
318
+ setTimeout(() => controller.abort(), timeout);
319
+
320
+ (async () => {
321
+ try {
322
+ const resp = await fetch(url, {
323
+ method: "POST",
324
+ headers: {
325
+ "Content-Type": "application/json",
326
+ Accept: "text/event-stream",
327
+ },
328
+ body: JSON.stringify(requestBody),
329
+ credentials: "include",
330
+ signal: controller.signal,
331
+ });
332
+
333
+ if (!resp.ok) {
334
+ const errText = await resp.text().catch(() => "");
335
+ w.__grokError = `HTTP ${resp.status}: ${errText.substring(0, 300)}`;
336
+ w.__grokDone = true;
337
+ return;
338
+ }
339
+
340
+ const reader = resp.body!.getReader();
341
+ const decoder = new TextDecoder();
342
+ let buffer = "";
343
+
344
+ while (true) {
345
+ const { done, value } = await reader.read();
346
+ if (done) break;
347
+ buffer += decoder.decode(value, { stream: true });
348
+ const lines = buffer.split("\n");
349
+ buffer = lines.pop() ?? "";
350
+
351
+ for (const line of lines) {
352
+ if (!line.startsWith("data: ")) continue;
353
+ const data = line.slice(6).trim();
354
+ if (data === "[DONE]") continue;
355
+ try {
356
+ const evt = JSON.parse(data);
357
+ const response = evt?.result?.response;
358
+ if (response?.token) {
359
+ (w.__grokTokenBuf as string[]).push(response.token);
360
+ }
361
+ if (response?.finalMetadata) {
362
+ w.__grokMeta = response.finalMetadata;
363
+ }
364
+ if (evt?.error) {
365
+ w.__grokError = String(evt.error);
366
+ }
367
+ } catch {
368
+ // ignore
369
+ }
370
+ }
371
+ }
372
+ } catch (e: unknown) {
373
+ w.__grokError = String(e);
374
+ } finally {
375
+ w.__grokDone = true;
376
+ }
377
+ })();
378
+ },
379
+ { url: GROK_API_URL, requestBody: body, timeout: timeoutMs }
380
+ );
381
+
382
+ // Poll the token buffer from Node side
383
+ let fullContent = "";
384
+ const pollInterval = 100; // ms
385
+ const deadline = Date.now() + timeoutMs;
386
+
387
+ while (Date.now() < deadline) {
388
+ const state = await page.evaluate(() => {
389
+ const w = window as unknown as Record<string, unknown>;
390
+ const tokens = (w.__grokTokenBuf as string[]).splice(0);
391
+ return {
392
+ tokens,
393
+ done: w.__grokDone as boolean,
394
+ error: w.__grokError as string | null,
395
+ meta: w.__grokMeta as { inputTokenCount?: number; outputTokenCount?: number } | null,
396
+ };
397
+ });
398
+
399
+ for (const token of state.tokens) {
400
+ onToken(token);
401
+ fullContent += token;
402
+ }
403
+
404
+ if (state.error) {
405
+ throw new Error(`grok.com stream error: ${state.error}`);
406
+ }
407
+
408
+ if (state.done) {
409
+ log(
410
+ `grok-client: stream done — ${state.meta?.outputTokenCount ?? "?"} tokens`
411
+ );
412
+ return {
413
+ content: fullContent,
414
+ model,
415
+ finishReason: "stop",
416
+ promptTokens: state.meta?.inputTokenCount,
417
+ completionTokens: state.meta?.outputTokenCount,
418
+ };
419
+ }
420
+
421
+ await new Promise((r) => setTimeout(r, pollInterval));
422
+ }
423
+
424
+ throw new Error(`grok.com stream timeout after ${timeoutMs}ms`);
425
+ } finally {
426
+ await page.close();
427
+ }
428
+ }