@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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grok-session.ts
|
|
3
|
+
*
|
|
4
|
+
* Manages a persistent grok.com browser session using Playwright.
|
|
5
|
+
*
|
|
6
|
+
* Auth flow:
|
|
7
|
+
* 1. First run: open Chromium, navigate to grok.com → user logs in manually via X.com OAuth
|
|
8
|
+
* 2. On success: save cookies + localStorage to SESSION_PATH
|
|
9
|
+
* 3. Subsequent runs: restore session from file, verify still valid
|
|
10
|
+
* 4. If session expired: repeat step 1
|
|
11
|
+
*
|
|
12
|
+
* The saved session file is stored at ~/.openclaw/grok-session.json
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import type { Browser, BrowserContext, Cookie } from "playwright";
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_SESSION_PATH = join(
|
|
21
|
+
homedir(),
|
|
22
|
+
".openclaw",
|
|
23
|
+
"grok-session.json"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export const GROK_HOME = "https://grok.com";
|
|
27
|
+
export const GROK_API_BASE = "https://grok.com/api";
|
|
28
|
+
|
|
29
|
+
/** Stored session data */
|
|
30
|
+
export interface GrokSession {
|
|
31
|
+
cookies: Cookie[];
|
|
32
|
+
savedAt: number; // epoch ms
|
|
33
|
+
userAgent?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Result of a session check */
|
|
37
|
+
export interface SessionCheckResult {
|
|
38
|
+
valid: boolean;
|
|
39
|
+
reason?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// Persistence helpers
|
|
44
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function loadSession(sessionPath: string): GrokSession | null {
|
|
47
|
+
if (!existsSync(sessionPath)) return null;
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(sessionPath, "utf-8");
|
|
50
|
+
const parsed = JSON.parse(raw) as GrokSession;
|
|
51
|
+
if (!parsed.cookies || !Array.isArray(parsed.cookies)) return null;
|
|
52
|
+
return parsed;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function saveSession(
|
|
59
|
+
sessionPath: string,
|
|
60
|
+
session: GrokSession
|
|
61
|
+
): void {
|
|
62
|
+
mkdirSync(dirname(sessionPath), { recursive: true });
|
|
63
|
+
writeFileSync(sessionPath, JSON.stringify(session, null, 2), "utf-8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function deleteSession(sessionPath: string): void {
|
|
67
|
+
if (existsSync(sessionPath)) {
|
|
68
|
+
unlinkSync(sessionPath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
// Session validation
|
|
74
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
77
|
+
|
|
78
|
+
export function isSessionExpiredByAge(session: GrokSession): boolean {
|
|
79
|
+
return Date.now() - session.savedAt > SESSION_MAX_AGE_MS;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verify the session is still valid by making a lightweight API call.
|
|
84
|
+
* Returns {valid: true} if the session works, {valid: false, reason} otherwise.
|
|
85
|
+
*/
|
|
86
|
+
export async function verifySession(
|
|
87
|
+
context: BrowserContext,
|
|
88
|
+
log: (msg: string) => void
|
|
89
|
+
): Promise<SessionCheckResult> {
|
|
90
|
+
const page = await context.newPage();
|
|
91
|
+
try {
|
|
92
|
+
log("verifying grok session...");
|
|
93
|
+
await page.goto(GROK_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
94
|
+
|
|
95
|
+
// If we see Sign In link → not logged in
|
|
96
|
+
const signIn = page.locator('a[href*="sign-in"], a[href*="/login"]');
|
|
97
|
+
const signInVisible = await signIn.isVisible().catch(() => false);
|
|
98
|
+
if (signInVisible) {
|
|
99
|
+
return { valid: false, reason: "sign-in link visible — session expired" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// If we see the chat input → logged in
|
|
103
|
+
const chatInput = page.locator('textarea, [placeholder*="mind"], [aria-label*="message"]');
|
|
104
|
+
const chatVisible = await chatInput.isVisible().catch(() => false);
|
|
105
|
+
if (chatVisible) {
|
|
106
|
+
log("session valid ✅");
|
|
107
|
+
return { valid: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Ambiguous — try API endpoint
|
|
111
|
+
const resp = await page.evaluate(async () => {
|
|
112
|
+
try {
|
|
113
|
+
const r = await fetch("https://grok.com/rest/app-chat/conversations", {
|
|
114
|
+
method: "GET",
|
|
115
|
+
credentials: "include",
|
|
116
|
+
});
|
|
117
|
+
return { status: r.status };
|
|
118
|
+
} catch (e: unknown) {
|
|
119
|
+
return { status: 0, error: String(e) };
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (resp.status === 200 || resp.status === 204) {
|
|
124
|
+
log("session valid via API check ✅");
|
|
125
|
+
return { valid: true };
|
|
126
|
+
}
|
|
127
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
128
|
+
return { valid: false, reason: `API returned ${resp.status}` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
log(`session check ambiguous (status ${resp.status}) — assuming valid`);
|
|
132
|
+
return { valid: true };
|
|
133
|
+
} finally {
|
|
134
|
+
await page.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
// Interactive login (opens visible browser window)
|
|
140
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
export async function runInteractiveLogin(
|
|
143
|
+
browser: Browser,
|
|
144
|
+
sessionPath: string,
|
|
145
|
+
log: (msg: string) => void,
|
|
146
|
+
timeoutMs = 5 * 60 * 1000
|
|
147
|
+
): Promise<GrokSession> {
|
|
148
|
+
log("opening browser for grok.com login — please sign in with your X account...");
|
|
149
|
+
|
|
150
|
+
const context = await browser.newContext({
|
|
151
|
+
viewport: { width: 1280, height: 800 },
|
|
152
|
+
});
|
|
153
|
+
const page = await context.newPage();
|
|
154
|
+
|
|
155
|
+
await page.goto(GROK_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
156
|
+
|
|
157
|
+
// Wait for sign-in to complete: look for chat textarea to appear
|
|
158
|
+
log(`waiting for login (timeout: ${timeoutMs / 1000}s)...`);
|
|
159
|
+
await page.waitForSelector(
|
|
160
|
+
'textarea, [placeholder*="mind"], [aria-label*="message"]',
|
|
161
|
+
{ timeout: timeoutMs }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
log("login detected — saving session...");
|
|
165
|
+
const cookies = await context.cookies();
|
|
166
|
+
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
167
|
+
|
|
168
|
+
const session: GrokSession = {
|
|
169
|
+
cookies,
|
|
170
|
+
savedAt: Date.now(),
|
|
171
|
+
userAgent,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
saveSession(sessionPath, session);
|
|
175
|
+
log(`session saved to ${sessionPath}`);
|
|
176
|
+
|
|
177
|
+
await context.close();
|
|
178
|
+
return session;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
// Context factory: create a BrowserContext with restored cookies
|
|
183
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export async function createContextFromSession(
|
|
186
|
+
browser: Browser,
|
|
187
|
+
session: GrokSession
|
|
188
|
+
): Promise<BrowserContext> {
|
|
189
|
+
const context = await browser.newContext({
|
|
190
|
+
userAgent: session.userAgent,
|
|
191
|
+
viewport: { width: 1280, height: 800 },
|
|
192
|
+
});
|
|
193
|
+
await context.addCookies(session.cookies);
|
|
194
|
+
return context;
|
|
195
|
+
}
|
package/src/proxy-server.ts
CHANGED
|
@@ -12,6 +12,12 @@ import http from "node:http";
|
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
13
|
import { type ChatMessage, routeToCliRunner } from "./cli-runner.js";
|
|
14
14
|
import { scheduleTokenRefresh, setAuthLogger, stopTokenRefresh } from "./claude-auth.js";
|
|
15
|
+
import { grokComplete, grokCompleteStream, type ChatMessage as GrokChatMessage } from "./grok-client.js";
|
|
16
|
+
import type { BrowserContext } from "playwright";
|
|
17
|
+
|
|
18
|
+
export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
|
|
19
|
+
export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
|
|
20
|
+
export type GrokCompleteResult = Awaited<ReturnType<typeof grokComplete>>;
|
|
15
21
|
|
|
16
22
|
export interface ProxyServerOptions {
|
|
17
23
|
port: number;
|
|
@@ -19,6 +25,12 @@ export interface ProxyServerOptions {
|
|
|
19
25
|
timeoutMs?: number;
|
|
20
26
|
log: (msg: string) => void;
|
|
21
27
|
warn: (msg: string) => void;
|
|
28
|
+
/** Returns the current authenticated Grok BrowserContext (null if not logged in) */
|
|
29
|
+
getGrokContext?: () => BrowserContext | null;
|
|
30
|
+
/** Override for testing — replaces grokComplete */
|
|
31
|
+
_grokComplete?: typeof grokComplete;
|
|
32
|
+
/** Override for testing — replaces grokCompleteStream */
|
|
33
|
+
_grokCompleteStream?: typeof grokCompleteStream;
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
/** Available CLI bridge models for GET /v1/models */
|
|
@@ -59,6 +71,11 @@ export const CLI_MODELS = [
|
|
|
59
71
|
contextWindow: 200_000,
|
|
60
72
|
maxTokens: 8192,
|
|
61
73
|
},
|
|
74
|
+
// Grok web-session models (requires /grok-login)
|
|
75
|
+
{ id: "web-grok/grok-3", name: "Grok 3 (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
76
|
+
{ id: "web-grok/grok-3-fast", name: "Grok 3 Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
77
|
+
{ id: "web-grok/grok-3-mini", name: "Grok 3 Mini (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
78
|
+
{ id: "web-grok/grok-3-mini-fast", name: "Grok 3 Mini Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
62
79
|
];
|
|
63
80
|
|
|
64
81
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -176,6 +193,57 @@ async function handleRequest(
|
|
|
176
193
|
|
|
177
194
|
opts.log(`[cli-bridge] ${model} · ${messages.length} msg(s) · stream=${stream}`);
|
|
178
195
|
|
|
196
|
+
const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
|
|
197
|
+
const created = Math.floor(Date.now() / 1000);
|
|
198
|
+
|
|
199
|
+
// ── Grok web-session routing ──────────────────────────────────────────────
|
|
200
|
+
if (model.startsWith("web-grok/")) {
|
|
201
|
+
const grokCtx = opts.getGrokContext?.() ?? null;
|
|
202
|
+
if (!grokCtx) {
|
|
203
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
204
|
+
res.end(JSON.stringify({ error: { message: "No active grok.com session. Use /grok-login to authenticate.", code: "no_grok_session" } }));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const grokModel = model.replace("web-grok/", "");
|
|
208
|
+
const timeoutMs = opts.timeoutMs ?? 120_000;
|
|
209
|
+
const grokMessages = messages as GrokChatMessage[];
|
|
210
|
+
const doGrokComplete = opts._grokComplete ?? grokComplete;
|
|
211
|
+
const doGrokCompleteStream = opts._grokCompleteStream ?? grokCompleteStream;
|
|
212
|
+
try {
|
|
213
|
+
if (stream) {
|
|
214
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
|
|
215
|
+
sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
|
|
216
|
+
const result = await doGrokCompleteStream(
|
|
217
|
+
grokCtx,
|
|
218
|
+
{ messages: grokMessages, model: grokModel, timeoutMs },
|
|
219
|
+
(token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
|
|
220
|
+
opts.log
|
|
221
|
+
);
|
|
222
|
+
sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
|
|
223
|
+
res.write("data: [DONE]\n\n");
|
|
224
|
+
res.end();
|
|
225
|
+
} else {
|
|
226
|
+
const result = await doGrokComplete(grokCtx, { messages: grokMessages, model: grokModel, timeoutMs }, opts.log);
|
|
227
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
228
|
+
res.end(JSON.stringify({
|
|
229
|
+
id, object: "chat.completion", created, model,
|
|
230
|
+
choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
|
|
231
|
+
usage: { prompt_tokens: result.promptTokens ?? 0, completion_tokens: result.completionTokens ?? 0, total_tokens: (result.promptTokens ?? 0) + (result.completionTokens ?? 0) },
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
const msg = (err as Error).message;
|
|
236
|
+
opts.warn(`[cli-bridge] Grok error for ${model}: ${msg}`);
|
|
237
|
+
if (!res.headersSent) {
|
|
238
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
239
|
+
res.end(JSON.stringify({ error: { message: msg, type: "grok_error" } }));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
// ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
|
|
179
247
|
let content: string;
|
|
180
248
|
try {
|
|
181
249
|
content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000);
|
|
@@ -187,9 +255,6 @@ async function handleRequest(
|
|
|
187
255
|
return;
|
|
188
256
|
}
|
|
189
257
|
|
|
190
|
-
const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
|
|
191
|
-
const created = Math.floor(Date.now() / 1000);
|
|
192
|
-
|
|
193
258
|
if (stream) {
|
|
194
259
|
res.writeHead(200, {
|
|
195
260
|
"Content-Type": "text/event-stream",
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test/grok-proxy.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for Grok web-session routing integrated into the cli-bridge proxy.
|
|
5
|
+
* Uses _grokComplete/_grokCompleteStream overrides (DI) instead of vi.mock,
|
|
6
|
+
* which avoids ESM hoisting issues entirely.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import type { AddressInfo } from "node:net";
|
|
12
|
+
import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
|
|
13
|
+
import type { BrowserContext } from "playwright";
|
|
14
|
+
import type { GrokCompleteOptions, GrokCompleteResult } from "../src/proxy-server.js";
|
|
15
|
+
|
|
16
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// Stub implementations (no browser needed)
|
|
18
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const stubGrokComplete = vi.fn(async (
|
|
21
|
+
_ctx: BrowserContext,
|
|
22
|
+
opts: GrokCompleteOptions,
|
|
23
|
+
_log: (msg: string) => void
|
|
24
|
+
): Promise<GrokCompleteResult> => ({
|
|
25
|
+
content: `grok mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
|
|
26
|
+
model: opts.model ?? "grok-3",
|
|
27
|
+
finishReason: "stop",
|
|
28
|
+
promptTokens: 8,
|
|
29
|
+
completionTokens: 4,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const stubGrokCompleteStream = vi.fn(async (
|
|
33
|
+
_ctx: BrowserContext,
|
|
34
|
+
opts: GrokCompleteOptions,
|
|
35
|
+
onToken: (t: string) => void,
|
|
36
|
+
_log: (msg: string) => void
|
|
37
|
+
): Promise<GrokCompleteResult> => {
|
|
38
|
+
const tokens = ["grok ", "stream ", "mock"];
|
|
39
|
+
for (const t of tokens) onToken(t);
|
|
40
|
+
return {
|
|
41
|
+
content: tokens.join(""),
|
|
42
|
+
model: opts.model ?? "grok-3",
|
|
43
|
+
finishReason: "stop",
|
|
44
|
+
promptTokens: 8,
|
|
45
|
+
completionTokens: 3,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
// Helpers
|
|
51
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
async function httpPost(
|
|
54
|
+
url: string,
|
|
55
|
+
body: unknown,
|
|
56
|
+
headers: Record<string, string> = {}
|
|
57
|
+
): Promise<{ status: number; body: unknown }> {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const data = JSON.stringify(body);
|
|
60
|
+
const urlObj = new URL(url);
|
|
61
|
+
const req = http.request(
|
|
62
|
+
{
|
|
63
|
+
hostname: urlObj.hostname,
|
|
64
|
+
port: parseInt(urlObj.port),
|
|
65
|
+
path: urlObj.pathname,
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"Content-Length": Buffer.byteLength(data),
|
|
70
|
+
...headers,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
(res) => {
|
|
74
|
+
let resp = "";
|
|
75
|
+
res.on("data", (c) => (resp += c));
|
|
76
|
+
res.on("end", () => {
|
|
77
|
+
try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(resp) }); }
|
|
78
|
+
catch { resolve({ status: res.statusCode ?? 0, body: resp }); }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
req.on("error", reject);
|
|
83
|
+
req.write(data);
|
|
84
|
+
req.end();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function httpGet(
|
|
89
|
+
url: string,
|
|
90
|
+
headers: Record<string, string> = {}
|
|
91
|
+
): Promise<{ status: number; body: unknown }> {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const req = http.get(url, { headers }, (res) => {
|
|
94
|
+
let data = "";
|
|
95
|
+
res.on("data", (c) => (data += c));
|
|
96
|
+
res.on("end", () => {
|
|
97
|
+
try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(data) }); }
|
|
98
|
+
catch { resolve({ status: res.statusCode ?? 0, body: data }); }
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
req.on("error", reject);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
// Setup: three servers
|
|
107
|
+
// - withCtx: has mock BrowserContext + stub overrides
|
|
108
|
+
// - noCtx: no BrowserContext (returns null)
|
|
109
|
+
// - noCtxNoOverride: no context, no overrides (tests 503)
|
|
110
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const TEST_KEY = "test-grok-key";
|
|
113
|
+
const MOCK_CONTEXT = {} as BrowserContext;
|
|
114
|
+
|
|
115
|
+
let serverWithCtx: http.Server;
|
|
116
|
+
let serverNoCtx: http.Server;
|
|
117
|
+
let urlWith: string;
|
|
118
|
+
let urlNo: string;
|
|
119
|
+
|
|
120
|
+
beforeAll(async () => {
|
|
121
|
+
serverWithCtx = await startProxyServer({
|
|
122
|
+
port: 0,
|
|
123
|
+
apiKey: TEST_KEY,
|
|
124
|
+
log: () => {},
|
|
125
|
+
warn: () => {},
|
|
126
|
+
getGrokContext: () => MOCK_CONTEXT,
|
|
127
|
+
_grokComplete: stubGrokComplete,
|
|
128
|
+
_grokCompleteStream: stubGrokCompleteStream,
|
|
129
|
+
});
|
|
130
|
+
const addrWith = serverWithCtx.address() as AddressInfo;
|
|
131
|
+
urlWith = `http://127.0.0.1:${addrWith.port}`;
|
|
132
|
+
|
|
133
|
+
serverNoCtx = await startProxyServer({
|
|
134
|
+
port: 0,
|
|
135
|
+
apiKey: TEST_KEY,
|
|
136
|
+
log: () => {},
|
|
137
|
+
warn: () => {},
|
|
138
|
+
getGrokContext: () => null,
|
|
139
|
+
_grokComplete: stubGrokComplete,
|
|
140
|
+
_grokCompleteStream: stubGrokCompleteStream,
|
|
141
|
+
});
|
|
142
|
+
const addrNo = serverNoCtx.address() as AddressInfo;
|
|
143
|
+
urlNo = `http://127.0.0.1:${addrNo.port}`;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterAll(async () => {
|
|
147
|
+
await new Promise<void>((r) => serverWithCtx.close(() => r()));
|
|
148
|
+
await new Promise<void>((r) => serverNoCtx.close(() => r()));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
// Tests
|
|
153
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
describe("GET /v1/models includes Grok web-session models", () => {
|
|
156
|
+
it("lists web-grok/* models", async () => {
|
|
157
|
+
const { status, body } = await httpGet(`${urlWith}/v1/models`, {
|
|
158
|
+
Authorization: `Bearer ${TEST_KEY}`,
|
|
159
|
+
});
|
|
160
|
+
expect(status).toBe(200);
|
|
161
|
+
const b = body as { data: Array<{ id: string }> };
|
|
162
|
+
const grokModels = b.data.filter((m) => m.id.startsWith("web-grok/"));
|
|
163
|
+
expect(grokModels.length).toBe(4);
|
|
164
|
+
expect(grokModels.map((m) => m.id)).toContain("web-grok/grok-3");
|
|
165
|
+
expect(grokModels.map((m) => m.id)).toContain("web-grok/grok-3-mini");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("CLI_MODELS exports 4 grok models", () => {
|
|
169
|
+
const grok = CLI_MODELS.filter((m) => m.id.startsWith("web-grok/"));
|
|
170
|
+
expect(grok).toHaveLength(4);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("POST /v1/chat/completions — Grok routing", () => {
|
|
175
|
+
const auth = { Authorization: `Bearer ${TEST_KEY}` };
|
|
176
|
+
|
|
177
|
+
it("returns 503 when no Grok session", async () => {
|
|
178
|
+
const { status, body } = await httpPost(
|
|
179
|
+
`${urlNo}/v1/chat/completions`,
|
|
180
|
+
{ model: "web-grok/grok-3", messages: [{ role: "user", content: "Hi" }] },
|
|
181
|
+
auth
|
|
182
|
+
);
|
|
183
|
+
expect(status).toBe(503);
|
|
184
|
+
const b = body as { error: { code: string } };
|
|
185
|
+
expect(b.error.code).toBe("no_grok_session");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns 200 with mock context (non-streaming)", async () => {
|
|
189
|
+
stubGrokComplete.mockClear();
|
|
190
|
+
const { status, body } = await httpPost(
|
|
191
|
+
`${urlWith}/v1/chat/completions`,
|
|
192
|
+
{ model: "web-grok/grok-3", messages: [{ role: "user", content: "Hello Grok" }], stream: false },
|
|
193
|
+
auth
|
|
194
|
+
);
|
|
195
|
+
expect(status).toBe(200);
|
|
196
|
+
const b = body as {
|
|
197
|
+
object: string;
|
|
198
|
+
model: string;
|
|
199
|
+
choices: Array<{ message: { content: string }; finish_reason: string }>;
|
|
200
|
+
usage: { prompt_tokens: number; completion_tokens: number };
|
|
201
|
+
};
|
|
202
|
+
expect(b.object).toBe("chat.completion");
|
|
203
|
+
expect(b.model).toBe("web-grok/grok-3");
|
|
204
|
+
expect(b.choices[0].message.content).toContain("Hello Grok");
|
|
205
|
+
expect(b.choices[0].finish_reason).toBe("stop");
|
|
206
|
+
expect(b.usage.prompt_tokens).toBe(8);
|
|
207
|
+
expect(b.usage.completion_tokens).toBe(4);
|
|
208
|
+
expect(stubGrokComplete).toHaveBeenCalledOnce();
|
|
209
|
+
// stub receives stripped model name
|
|
210
|
+
expect(stubGrokComplete.mock.calls[0][1].model).toBe("grok-3");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("strips web-grok/ prefix before passing to grokComplete", async () => {
|
|
214
|
+
stubGrokComplete.mockClear();
|
|
215
|
+
const { status, body } = await httpPost(
|
|
216
|
+
`${urlWith}/v1/chat/completions`,
|
|
217
|
+
{ model: "web-grok/grok-3-mini", messages: [{ role: "user", content: "test" }] },
|
|
218
|
+
auth
|
|
219
|
+
);
|
|
220
|
+
expect(status).toBe(200);
|
|
221
|
+
// response model should still have web-grok/ prefix
|
|
222
|
+
const b = body as { model: string };
|
|
223
|
+
expect(b.model).toBe("web-grok/grok-3-mini");
|
|
224
|
+
// stub receives stripped model
|
|
225
|
+
expect(stubGrokComplete.mock.calls[0][1].model).toBe("grok-3-mini");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("returns SSE stream for web-grok models", async () => {
|
|
229
|
+
return new Promise<void>((resolve, reject) => {
|
|
230
|
+
const data = JSON.stringify({
|
|
231
|
+
model: "web-grok/grok-3",
|
|
232
|
+
messages: [{ role: "user", content: "stream test" }],
|
|
233
|
+
stream: true,
|
|
234
|
+
});
|
|
235
|
+
const urlObj = new URL(`${urlWith}/v1/chat/completions`);
|
|
236
|
+
const req = http.request(
|
|
237
|
+
{
|
|
238
|
+
hostname: urlObj.hostname,
|
|
239
|
+
port: parseInt(urlObj.port),
|
|
240
|
+
path: urlObj.pathname,
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: {
|
|
243
|
+
"Content-Type": "application/json",
|
|
244
|
+
"Content-Length": Buffer.byteLength(data),
|
|
245
|
+
Authorization: `Bearer ${TEST_KEY}`,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
(res) => {
|
|
249
|
+
expect(res.statusCode).toBe(200);
|
|
250
|
+
expect(res.headers["content-type"]).toContain("text/event-stream");
|
|
251
|
+
let raw = "";
|
|
252
|
+
res.on("data", (c) => (raw += c));
|
|
253
|
+
res.on("end", () => {
|
|
254
|
+
const lines = raw.split("\n").filter((l) => l.startsWith("data: "));
|
|
255
|
+
expect(lines[lines.length - 1]).toBe("data: [DONE]");
|
|
256
|
+
const tokens = lines
|
|
257
|
+
.filter((l) => l !== "data: [DONE]")
|
|
258
|
+
.map((l) => { try { return JSON.parse(l.slice(6)); } catch { return null; } })
|
|
259
|
+
.filter(Boolean)
|
|
260
|
+
.flatMap((c) => c.choices?.[0]?.delta?.content ?? []);
|
|
261
|
+
expect(tokens.join("")).toBe("grok stream mock");
|
|
262
|
+
resolve();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
req.on("error", reject);
|
|
267
|
+
req.write(data);
|
|
268
|
+
req.end();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("non-web-grok models bypass Grok routing (go to CLI runner)", async () => {
|
|
273
|
+
stubGrokComplete.mockClear();
|
|
274
|
+
|
|
275
|
+
// Use a separate server with very short CLI timeout so this test finishes quickly
|
|
276
|
+
const fastSrv = await startProxyServer({
|
|
277
|
+
port: 0,
|
|
278
|
+
apiKey: TEST_KEY,
|
|
279
|
+
timeoutMs: 500, // fail fast — gemini CLI won't be available in test
|
|
280
|
+
log: () => {},
|
|
281
|
+
warn: () => {},
|
|
282
|
+
getGrokContext: () => MOCK_CONTEXT,
|
|
283
|
+
_grokComplete: stubGrokComplete,
|
|
284
|
+
_grokCompleteStream: stubGrokCompleteStream,
|
|
285
|
+
});
|
|
286
|
+
const addr = fastSrv.address() as AddressInfo;
|
|
287
|
+
const fastUrl = `http://127.0.0.1:${addr.port}`;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const { status } = await httpPost(
|
|
291
|
+
`${fastUrl}/v1/chat/completions`,
|
|
292
|
+
{ model: "cli-gemini/gemini-2.5-pro", messages: [{ role: "user", content: "test" }] },
|
|
293
|
+
auth
|
|
294
|
+
);
|
|
295
|
+
expect(status).not.toBe(503); // must NOT be "no_grok_session" — routing is different
|
|
296
|
+
expect(stubGrokComplete).not.toHaveBeenCalled(); // grokComplete never called for non-grok models
|
|
297
|
+
} finally {
|
|
298
|
+
await new Promise<void>((r) => fastSrv.close(() => r()));
|
|
299
|
+
}
|
|
300
|
+
}, 10_000);
|
|
301
|
+
});
|