@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.
- package/.ai/handoff/STATUS.md +5 -5
- package/README.md +8 -1
- package/SKILL.md +1 -1
- package/index.ts +110 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -1
- package/src/grok-client.ts +428 -0
- package/src/grok-session.ts +195 -0
- package/src/proxy-server.ts +68 -3
- package/test/grok-proxy.test.ts +301 -0
- package/test/grok-session.test.ts +133 -0
package/.ai/handoff/STATUS.md
CHANGED
|
@@ -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.
|
|
15
|
-
| npm | 0.2.
|
|
16
|
-
| ClawHub | 0.2.
|
|
17
|
-
| Local | 0.2.25 |
|
|
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
|
-
-
|
|
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.
|
|
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
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.
|
|
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
|
},
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"name": "OpenClaw CLI Bridge",
|
|
4
|
-
"version": "0.2.
|
|
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.
|
|
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
|
+
}
|