@elvatis_com/openclaw-cli-bridge-elvatis 0.2.29 → 0.2.30
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/README.md +9 -1
- package/SKILL.md +1 -1
- package/index.ts +211 -54
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/gemini-browser.ts +242 -0
- package/src/proxy-server.ts +63 -0
- package/test/gemini-proxy.test.ts +139 -0
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.30`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -287,6 +287,14 @@ npm test # vitest run (45 tests)
|
|
|
287
287
|
|
|
288
288
|
## Changelog
|
|
289
289
|
|
|
290
|
+
### v0.2.30
|
|
291
|
+
- **feat:** `gemini-browser.ts` — gemini.google.com DOM-automation (Quill editor + message-content polling)
|
|
292
|
+
- **feat:** `web-gemini/*` models in proxy (gemini-2-5-pro, gemini-2-5-flash, gemini-3-pro, gemini-3-flash)
|
|
293
|
+
- **feat:** `/gemini-login`, `/gemini-status`, `/gemini-logout` commands + cookie-expiry tracking
|
|
294
|
+
- **fix:** Singleton CDP connection — no more zombie Chromium processes
|
|
295
|
+
- **fix:** `cleanupBrowsers()` called on plugin stop — all browser resources released
|
|
296
|
+
- **test:** 90/90 tests green (+6 gemini-proxy tests)
|
|
297
|
+
|
|
290
298
|
### v0.2.29
|
|
291
299
|
- **feat:** `claude-browser.ts` — claude.ai DOM-automation (ProseMirror + `[data-test-render-count]` polling)
|
|
292
300
|
- **feat:** `web-claude/*` models in proxy (web-claude/claude-sonnet, claude-opus, claude-haiku)
|
package/SKILL.md
CHANGED
package/index.ts
CHANGED
|
@@ -89,6 +89,38 @@ let grokContext: BrowserContext | null = null;
|
|
|
89
89
|
// Persistent profile dir — survives gateway restarts, keeps cookies intact
|
|
90
90
|
const GROK_PROFILE_DIR = join(homedir(), ".openclaw", "grok-profile");
|
|
91
91
|
|
|
92
|
+
// ── Gemini web-session state ──────────────────────────────────────────────────
|
|
93
|
+
let geminiContext: BrowserContext | null = null;
|
|
94
|
+
const GEMINI_EXPIRY_FILE = join(homedir(), ".openclaw", "gemini-cookie-expiry.json");
|
|
95
|
+
|
|
96
|
+
interface GeminiExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
|
|
97
|
+
|
|
98
|
+
function saveGeminiExpiry(info: GeminiExpiryInfo): void {
|
|
99
|
+
try { writeFileSync(GEMINI_EXPIRY_FILE, JSON.stringify(info, null, 2)); } catch { /* ignore */ }
|
|
100
|
+
}
|
|
101
|
+
function loadGeminiExpiry(): GeminiExpiryInfo | null {
|
|
102
|
+
try { return JSON.parse(readFileSync(GEMINI_EXPIRY_FILE, "utf-8")) as GeminiExpiryInfo; } catch { return null; }
|
|
103
|
+
}
|
|
104
|
+
function formatGeminiExpiry(info: GeminiExpiryInfo): string {
|
|
105
|
+
const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
|
|
106
|
+
const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
|
|
107
|
+
if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /gemini-login`;
|
|
108
|
+
if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /gemini-login NOW`;
|
|
109
|
+
if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /gemini-login soon`;
|
|
110
|
+
return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
|
|
111
|
+
}
|
|
112
|
+
async function scanGeminiCookieExpiry(ctx: BrowserContext): Promise<GeminiExpiryInfo | null> {
|
|
113
|
+
try {
|
|
114
|
+
const cookies = await ctx.cookies(["https://gemini.google.com", "https://accounts.google.com"]);
|
|
115
|
+
const auth = cookies.filter(c => ["__Secure-1PSID", "__Secure-3PSID", "SID"].includes(c.name) && c.expires && c.expires > 0);
|
|
116
|
+
if (!auth.length) return null;
|
|
117
|
+
auth.sort((a, b) => (a.expires ?? 0) - (b.expires ?? 0));
|
|
118
|
+
const earliest = auth[0];
|
|
119
|
+
return { expiresAt: (earliest.expires ?? 0) * 1000, loginAt: Date.now(), cookieName: earliest.name };
|
|
120
|
+
} catch { return null; }
|
|
121
|
+
}
|
|
122
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
92
124
|
// ── Claude web-session state ──────────────────────────────────────────────────
|
|
93
125
|
let claudeContext: BrowserContext | null = null;
|
|
94
126
|
const CLAUDE_EXPIRY_FILE = join(homedir(), ".openclaw", "claude-cookie-expiry.json");
|
|
@@ -172,60 +204,96 @@ async function scanCookieExpiry(ctx: import("playwright").BrowserContext): Promi
|
|
|
172
204
|
} catch { return null; }
|
|
173
205
|
}
|
|
174
206
|
|
|
207
|
+
// Singleton CDP connection — one browser object shared across grok + claude
|
|
208
|
+
let _cdpBrowser: import("playwright").Browser | null = null;
|
|
209
|
+
let _cdpBrowserLaunchPromise: Promise<import("playwright").BrowserContext | null> | null = null;
|
|
210
|
+
|
|
175
211
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
212
|
+
* Connect to the OpenClaw managed browser (CDP port 18800).
|
|
213
|
+
* Singleton: reuses the same connection. Falls back to persistent Chromium for Grok only.
|
|
214
|
+
* NEVER launches a new browser for Claude — Claude requires the OpenClaw browser.
|
|
179
215
|
*/
|
|
180
|
-
async function
|
|
216
|
+
async function connectToOpenClawBrowser(
|
|
181
217
|
log: (msg: string) => void
|
|
182
218
|
): Promise<BrowserContext | null> {
|
|
183
|
-
//
|
|
184
|
-
if (
|
|
219
|
+
// Reuse existing CDP connection if still alive
|
|
220
|
+
if (_cdpBrowser) {
|
|
185
221
|
try {
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
return grokContext;
|
|
222
|
+
_cdpBrowser.contexts(); // ping
|
|
223
|
+
return _cdpBrowser.contexts()[0] ?? null;
|
|
189
224
|
} catch {
|
|
190
|
-
|
|
225
|
+
_cdpBrowser = null;
|
|
191
226
|
}
|
|
192
227
|
}
|
|
193
|
-
|
|
194
228
|
const { chromium } = await import("playwright");
|
|
195
|
-
|
|
196
|
-
// 1. Try connecting to the OpenClaw managed browser first (user may have grok.com open)
|
|
197
229
|
try {
|
|
198
|
-
const browser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return ctx;
|
|
204
|
-
}
|
|
230
|
+
const browser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 3000 });
|
|
231
|
+
_cdpBrowser = browser;
|
|
232
|
+
browser.on("disconnected", () => { _cdpBrowser = null; log("[cli-bridge] OpenClaw browser disconnected"); });
|
|
233
|
+
log("[cli-bridge] connected to OpenClaw browser via CDP");
|
|
234
|
+
return browser.contexts()[0] ?? null;
|
|
205
235
|
} catch {
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// 2. Launch our own persistent headless Chromium with saved profile
|
|
210
|
-
log("[cli-bridge:grok] launching persistent Chromium…");
|
|
211
|
-
try {
|
|
212
|
-
const ctx = await chromium.launchPersistentContext(GROK_PROFILE_DIR, {
|
|
213
|
-
headless: true,
|
|
214
|
-
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
215
|
-
});
|
|
216
|
-
grokContext = ctx;
|
|
217
|
-
log("[cli-bridge:grok] persistent context ready");
|
|
218
|
-
return ctx;
|
|
219
|
-
} catch (err) {
|
|
220
|
-
log(`[cli-bridge:grok] failed to launch browser: ${(err as Error).message}`);
|
|
236
|
+
log("[cli-bridge] OpenClaw browser not available (CDP 18800)");
|
|
221
237
|
return null;
|
|
222
238
|
}
|
|
223
239
|
}
|
|
224
240
|
|
|
225
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Launch (or reuse) a persistent headless Chromium context for grok.com.
|
|
243
|
+
* ONLY used for Grok — Grok has a saved persistent profile with cookies.
|
|
244
|
+
* Claude does NOT use this — it requires the OpenClaw browser.
|
|
245
|
+
*/
|
|
246
|
+
async function getOrLaunchGrokContext(
|
|
226
247
|
log: (msg: string) => void
|
|
227
248
|
): Promise<BrowserContext | null> {
|
|
228
|
-
|
|
249
|
+
// Already have a live context?
|
|
250
|
+
if (grokContext) {
|
|
251
|
+
try { grokContext.pages(); return grokContext; } catch { grokContext = null; }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Try OpenClaw browser first (singleton CDP)
|
|
255
|
+
const cdpCtx = await connectToOpenClawBrowser(log);
|
|
256
|
+
if (cdpCtx) return cdpCtx;
|
|
257
|
+
|
|
258
|
+
// Coalesce concurrent launch requests into one
|
|
259
|
+
if (_cdpBrowserLaunchPromise) return _cdpBrowserLaunchPromise;
|
|
260
|
+
|
|
261
|
+
_cdpBrowserLaunchPromise = (async () => {
|
|
262
|
+
const { chromium } = await import("playwright");
|
|
263
|
+
log("[cli-bridge:grok] launching persistent Chromium…");
|
|
264
|
+
try {
|
|
265
|
+
const ctx = await chromium.launchPersistentContext(GROK_PROFILE_DIR, {
|
|
266
|
+
headless: true,
|
|
267
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
268
|
+
});
|
|
269
|
+
grokContext = ctx;
|
|
270
|
+
// Auto-cleanup on browser crash
|
|
271
|
+
ctx.on("close", () => { grokContext = null; log("[cli-bridge:grok] persistent context closed"); });
|
|
272
|
+
log("[cli-bridge:grok] persistent context ready");
|
|
273
|
+
return ctx;
|
|
274
|
+
} catch (err) {
|
|
275
|
+
log(`[cli-bridge:grok] failed to launch browser: ${(err as Error).message}`);
|
|
276
|
+
return null;
|
|
277
|
+
} finally {
|
|
278
|
+
_cdpBrowserLaunchPromise = null;
|
|
279
|
+
}
|
|
280
|
+
})();
|
|
281
|
+
return _cdpBrowserLaunchPromise;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Clean up all browser resources — call on plugin teardown */
|
|
285
|
+
async function cleanupBrowsers(log: (msg: string) => void): Promise<void> {
|
|
286
|
+
if (grokContext) {
|
|
287
|
+
try { await grokContext.close(); } catch { /* ignore */ }
|
|
288
|
+
grokContext = null;
|
|
289
|
+
}
|
|
290
|
+
if (_cdpBrowser) {
|
|
291
|
+
try { await _cdpBrowser.close(); } catch { /* ignore */ }
|
|
292
|
+
_cdpBrowser = null;
|
|
293
|
+
}
|
|
294
|
+
claudeContext = null;
|
|
295
|
+
geminiContext = null;
|
|
296
|
+
log("[cli-bridge] browser resources cleaned up");
|
|
229
297
|
}
|
|
230
298
|
|
|
231
299
|
async function tryRestoreGrokSession(
|
|
@@ -583,7 +651,7 @@ function proxyTestRequest(
|
|
|
583
651
|
const plugin = {
|
|
584
652
|
id: "openclaw-cli-bridge-elvatis",
|
|
585
653
|
name: "OpenClaw CLI Bridge",
|
|
586
|
-
version: "0.2.
|
|
654
|
+
version: "0.2.30",
|
|
587
655
|
description:
|
|
588
656
|
"Phase 1: openai-codex auth bridge. " +
|
|
589
657
|
"Phase 2: HTTP proxy for gemini/claude CLIs. " +
|
|
@@ -717,6 +785,17 @@ const plugin = {
|
|
|
717
785
|
}
|
|
718
786
|
return null;
|
|
719
787
|
},
|
|
788
|
+
getGeminiContext: () => geminiContext,
|
|
789
|
+
connectGeminiContext: async () => {
|
|
790
|
+
const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
|
|
791
|
+
if (ctx) {
|
|
792
|
+
const { getOrCreateGeminiPage } = await import("./src/gemini-browser.js");
|
|
793
|
+
const { page } = await getOrCreateGeminiPage(ctx);
|
|
794
|
+
const editor = await page.locator(".ql-editor").isVisible().catch(() => false);
|
|
795
|
+
if (editor) { geminiContext = ctx; return ctx; }
|
|
796
|
+
}
|
|
797
|
+
return null;
|
|
798
|
+
},
|
|
720
799
|
});
|
|
721
800
|
proxyServer = server;
|
|
722
801
|
api.logger.info(
|
|
@@ -760,6 +839,17 @@ const plugin = {
|
|
|
760
839
|
}
|
|
761
840
|
return null;
|
|
762
841
|
},
|
|
842
|
+
getGeminiContext: () => geminiContext,
|
|
843
|
+
connectGeminiContext: async () => {
|
|
844
|
+
const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
|
|
845
|
+
if (ctx) {
|
|
846
|
+
const { getOrCreateGeminiPage } = await import("./src/gemini-browser.js");
|
|
847
|
+
const { page } = await getOrCreateGeminiPage(ctx);
|
|
848
|
+
const editor = await page.locator(".ql-editor").isVisible().catch(() => false);
|
|
849
|
+
if (editor) { geminiContext = ctx; return ctx; }
|
|
850
|
+
}
|
|
851
|
+
return null;
|
|
852
|
+
},
|
|
763
853
|
});
|
|
764
854
|
proxyServer = server;
|
|
765
855
|
api.logger.info(`[cli-bridge] proxy ready on :${port} (retry)`);
|
|
@@ -781,6 +871,9 @@ const plugin = {
|
|
|
781
871
|
id: "cli-bridge-proxy",
|
|
782
872
|
start: async () => { /* proxy already started above */ },
|
|
783
873
|
stop: async () => {
|
|
874
|
+
// Clean up browser resources first
|
|
875
|
+
await cleanupBrowsers((msg) => api.logger.info(msg));
|
|
876
|
+
|
|
784
877
|
if (proxyServer) {
|
|
785
878
|
// closeAllConnections() forcefully terminates keep-alive connections
|
|
786
879
|
// so that server.close() releases the port immediately rather than
|
|
@@ -1113,23 +1206,8 @@ const plugin = {
|
|
|
1113
1206
|
}
|
|
1114
1207
|
|
|
1115
1208
|
api.logger.info("[cli-bridge:claude] /claude-login: connecting to OpenClaw browser…");
|
|
1116
|
-
const { chromium } = await import("playwright");
|
|
1117
|
-
|
|
1118
|
-
// Import cookies from OpenClaw browser
|
|
1119
|
-
let importedCookies: unknown[] = [];
|
|
1120
|
-
try {
|
|
1121
|
-
const ocBrowser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 3000 });
|
|
1122
|
-
const ocCtx = ocBrowser.contexts()[0];
|
|
1123
|
-
if (ocCtx) {
|
|
1124
|
-
importedCookies = await ocCtx.cookies(["https://claude.ai", "https://anthropic.com"]);
|
|
1125
|
-
api.logger.info(`[cli-bridge:claude] imported ${importedCookies.length} cookies`);
|
|
1126
|
-
}
|
|
1127
|
-
await ocBrowser.close().catch(() => {});
|
|
1128
|
-
} catch {
|
|
1129
|
-
api.logger.info("[cli-bridge:claude] OpenClaw browser not available");
|
|
1130
|
-
}
|
|
1131
1209
|
|
|
1132
|
-
// Connect to OpenClaw browser context for session
|
|
1210
|
+
// Connect to OpenClaw browser context for session (singleton CDP)
|
|
1133
1211
|
const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
|
|
1134
1212
|
if (!ctx) return { text: "❌ Could not connect to OpenClaw browser.\nMake sure claude.ai is open in your browser." };
|
|
1135
1213
|
|
|
@@ -1192,6 +1270,82 @@ const plugin = {
|
|
|
1192
1270
|
return { text: "✅ Disconnected from claude.ai. Run `/claude-login` to reconnect." };
|
|
1193
1271
|
},
|
|
1194
1272
|
} satisfies OpenClawPluginCommandDefinition);
|
|
1273
|
+
|
|
1274
|
+
// ── Gemini web-session commands ───────────────────────────────────────────
|
|
1275
|
+
api.registerCommand({
|
|
1276
|
+
name: "gemini-login",
|
|
1277
|
+
description: "Authenticate gemini.google.com: imports session from OpenClaw browser",
|
|
1278
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
1279
|
+
if (geminiContext) {
|
|
1280
|
+
const { getOrCreateGeminiPage } = await import("./src/gemini-browser.js");
|
|
1281
|
+
try {
|
|
1282
|
+
const { page } = await getOrCreateGeminiPage(geminiContext);
|
|
1283
|
+
const editor = await page.locator(".ql-editor").isVisible().catch(() => false);
|
|
1284
|
+
if (editor) return { text: "✅ Already connected to gemini.google.com. Use `/gemini-logout` first to reset." };
|
|
1285
|
+
} catch { /* fall through */ }
|
|
1286
|
+
geminiContext = null;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
api.logger.info("[cli-bridge:gemini] /gemini-login: connecting to OpenClaw browser…");
|
|
1290
|
+
const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
|
|
1291
|
+
if (!ctx) return { text: "❌ Could not connect to OpenClaw browser.\nMake sure gemini.google.com is open in your browser." };
|
|
1292
|
+
|
|
1293
|
+
const { getOrCreateGeminiPage } = await import("./src/gemini-browser.js");
|
|
1294
|
+
let page;
|
|
1295
|
+
try {
|
|
1296
|
+
({ page } = await getOrCreateGeminiPage(ctx));
|
|
1297
|
+
} catch (err) {
|
|
1298
|
+
return { text: `❌ Failed to open gemini.google.com: ${(err as Error).message}` };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const editor = await page.locator(".ql-editor").isVisible().catch(() => false);
|
|
1302
|
+
if (!editor) {
|
|
1303
|
+
return { text: "❌ Gemini editor not visible — are you logged in?\nOpen gemini.google.com in your browser and try again." };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
geminiContext = ctx;
|
|
1307
|
+
|
|
1308
|
+
const expiry = await scanGeminiCookieExpiry(ctx);
|
|
1309
|
+
if (expiry) {
|
|
1310
|
+
saveGeminiExpiry(expiry);
|
|
1311
|
+
api.logger.info(`[cli-bridge:gemini] cookie expiry: ${new Date(expiry.expiresAt).toISOString()}`);
|
|
1312
|
+
}
|
|
1313
|
+
const expiryLine = expiry ? `\n\n🕐 Cookie expiry: ${formatGeminiExpiry(expiry)}` : "";
|
|
1314
|
+
|
|
1315
|
+
return { text: `✅ Gemini session ready!\n\nModels available:\n• \`vllm/web-gemini/gemini-2-5-pro\`\n• \`vllm/web-gemini/gemini-2-5-flash\`\n• \`vllm/web-gemini/gemini-3-pro\`\n• \`vllm/web-gemini/gemini-3-flash\`${expiryLine}` };
|
|
1316
|
+
},
|
|
1317
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
1318
|
+
|
|
1319
|
+
api.registerCommand({
|
|
1320
|
+
name: "gemini-status",
|
|
1321
|
+
description: "Check gemini.google.com session status",
|
|
1322
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
1323
|
+
if (!geminiContext) {
|
|
1324
|
+
return { text: "❌ No active gemini.google.com session\nRun `/gemini-login` to authenticate." };
|
|
1325
|
+
}
|
|
1326
|
+
const { getOrCreateGeminiPage } = await import("./src/gemini-browser.js");
|
|
1327
|
+
try {
|
|
1328
|
+
const { page } = await getOrCreateGeminiPage(geminiContext);
|
|
1329
|
+
const editor = await page.locator(".ql-editor").isVisible().catch(() => false);
|
|
1330
|
+
if (editor) {
|
|
1331
|
+
const expiry = loadGeminiExpiry();
|
|
1332
|
+
const expiryLine = expiry ? `\n🕐 ${formatGeminiExpiry(expiry)}` : "";
|
|
1333
|
+
return { text: `✅ gemini.google.com session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-gemini/gemini-2-5-pro, gemini-2-5-flash, gemini-3-pro, gemini-3-flash${expiryLine}` };
|
|
1334
|
+
}
|
|
1335
|
+
} catch { /* fall through */ }
|
|
1336
|
+
geminiContext = null;
|
|
1337
|
+
return { text: "❌ Session lost — run `/gemini-login` to re-authenticate." };
|
|
1338
|
+
},
|
|
1339
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
1340
|
+
|
|
1341
|
+
api.registerCommand({
|
|
1342
|
+
name: "gemini-logout",
|
|
1343
|
+
description: "Disconnect from gemini.google.com session",
|
|
1344
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
1345
|
+
geminiContext = null;
|
|
1346
|
+
return { text: "✅ Disconnected from gemini.google.com. Run `/gemini-login` to reconnect." };
|
|
1347
|
+
},
|
|
1348
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
1195
1349
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1196
1350
|
|
|
1197
1351
|
const allCommands = [
|
|
@@ -1205,6 +1359,9 @@ const plugin = {
|
|
|
1205
1359
|
"/claude-login",
|
|
1206
1360
|
"/claude-status",
|
|
1207
1361
|
"/claude-logout",
|
|
1362
|
+
"/gemini-login",
|
|
1363
|
+
"/gemini-status",
|
|
1364
|
+
"/gemini-logout",
|
|
1208
1365
|
];
|
|
1209
1366
|
api.logger.info(`[cli-bridge] registered ${allCommands.length} commands: ${allCommands.join(", ")}`);
|
|
1210
1367
|
},
|
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.30",
|
|
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.30",
|
|
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": {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gemini-browser.ts
|
|
3
|
+
*
|
|
4
|
+
* Gemini web automation via Playwright DOM-polling.
|
|
5
|
+
* Strategy identical to grok-client.ts / claude-browser.ts.
|
|
6
|
+
*
|
|
7
|
+
* DOM structure (confirmed 2026-03-11):
|
|
8
|
+
* Editor: .ql-editor (Quill — use page.type(), NOT execCommand)
|
|
9
|
+
* Response: message-content (custom element, innerText = clean response)
|
|
10
|
+
* Also: .markdown (same content, markdown-rendered)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { BrowserContext, Page } from "playwright";
|
|
14
|
+
|
|
15
|
+
export interface ChatMessage {
|
|
16
|
+
role: "system" | "user" | "assistant";
|
|
17
|
+
content: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GeminiBrowserOptions {
|
|
21
|
+
messages: ChatMessage[];
|
|
22
|
+
model?: string;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GeminiBrowserResult {
|
|
27
|
+
content: string;
|
|
28
|
+
model: string;
|
|
29
|
+
finishReason: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
33
|
+
const STABLE_CHECKS = 3;
|
|
34
|
+
const STABLE_INTERVAL_MS = 600; // slightly longer — Gemini streams slower
|
|
35
|
+
const GEMINI_HOME = "https://gemini.google.com/app";
|
|
36
|
+
|
|
37
|
+
const MODEL_MAP: Record<string, string> = {
|
|
38
|
+
"gemini-2-5-pro": "gemini-2.5-pro",
|
|
39
|
+
"gemini-2-5-flash": "gemini-2.5-flash",
|
|
40
|
+
"gemini-flash": "gemini-flash",
|
|
41
|
+
"gemini-pro": "gemini-pro",
|
|
42
|
+
"gemini-3-pro": "gemini-3-pro",
|
|
43
|
+
"gemini-3-flash": "gemini-3-flash",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function resolveModel(m?: string): string {
|
|
47
|
+
const clean = (m ?? "gemini-2-5-pro").replace("web-gemini/", "").replace(/\./g, "-");
|
|
48
|
+
return MODEL_MAP[clean] ?? "gemini-2.5-pro";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function flattenMessages(messages: ChatMessage[]): string {
|
|
52
|
+
if (messages.length === 1) return messages[0].content;
|
|
53
|
+
return messages
|
|
54
|
+
.map((m) => {
|
|
55
|
+
if (m.role === "system") return `[System]: ${m.content}`;
|
|
56
|
+
if (m.role === "assistant") return `[Assistant]: ${m.content}`;
|
|
57
|
+
return m.content;
|
|
58
|
+
})
|
|
59
|
+
.join("\n\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get or create a Gemini page in the given context.
|
|
64
|
+
*/
|
|
65
|
+
export async function getOrCreateGeminiPage(
|
|
66
|
+
context: BrowserContext
|
|
67
|
+
): Promise<{ page: Page; owned: boolean }> {
|
|
68
|
+
const existing = context.pages().filter((p) => p.url().startsWith("https://gemini.google.com"));
|
|
69
|
+
if (existing.length > 0) return { page: existing[0], owned: false };
|
|
70
|
+
const page = await context.newPage();
|
|
71
|
+
await page.goto(GEMINI_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
72
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
73
|
+
return { page, owned: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Count model-response elements on the page (= number of assistant turns).
|
|
78
|
+
*/
|
|
79
|
+
async function countResponses(page: Page): Promise<number> {
|
|
80
|
+
return page.evaluate(() => document.querySelectorAll("model-response").length);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the text of the last model-response via message-content element.
|
|
85
|
+
* Uses message-content (cleanest, no "Gemini hat gesagt" prefix).
|
|
86
|
+
*/
|
|
87
|
+
async function getLastResponseText(page: Page): Promise<string> {
|
|
88
|
+
return page.evaluate(() => {
|
|
89
|
+
const els = [...document.querySelectorAll("message-content")];
|
|
90
|
+
if (!els.length) return "";
|
|
91
|
+
return els[els.length - 1].textContent?.trim() ?? "";
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if Gemini is still generating (streaming indicator present).
|
|
97
|
+
*/
|
|
98
|
+
async function isStreaming(page: Page): Promise<boolean> {
|
|
99
|
+
return page.evaluate(() => {
|
|
100
|
+
// Gemini shows a stop button while streaming
|
|
101
|
+
const stopBtn = document.querySelector('button[aria-label*="stop"], button[aria-label*="Stop"], button[aria-label*="stopp"]');
|
|
102
|
+
return !!stopBtn;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Send a message and wait for a stable response via DOM-polling.
|
|
108
|
+
*/
|
|
109
|
+
async function sendAndWait(
|
|
110
|
+
page: Page,
|
|
111
|
+
message: string,
|
|
112
|
+
timeoutMs: number,
|
|
113
|
+
log: (msg: string) => void
|
|
114
|
+
): Promise<string> {
|
|
115
|
+
const countBefore = await countResponses(page);
|
|
116
|
+
|
|
117
|
+
// Quill editor: use page.type() (not execCommand — Quill ignores it)
|
|
118
|
+
const editor = page.locator(".ql-editor");
|
|
119
|
+
await editor.click();
|
|
120
|
+
await editor.type(message, { delay: 10 });
|
|
121
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
122
|
+
await page.keyboard.press("Enter");
|
|
123
|
+
|
|
124
|
+
log(`gemini-browser: message sent (${message.length} chars), waiting…`);
|
|
125
|
+
|
|
126
|
+
const deadline = Date.now() + timeoutMs;
|
|
127
|
+
let lastText = "";
|
|
128
|
+
let stableCount = 0;
|
|
129
|
+
|
|
130
|
+
while (Date.now() < deadline) {
|
|
131
|
+
await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
|
|
132
|
+
|
|
133
|
+
// Wait for new response to appear
|
|
134
|
+
const currentCount = await countResponses(page);
|
|
135
|
+
if (currentCount <= countBefore) continue;
|
|
136
|
+
|
|
137
|
+
// Still streaming? Don't start stable-check yet
|
|
138
|
+
const streaming = await isStreaming(page);
|
|
139
|
+
if (streaming) {
|
|
140
|
+
stableCount = 0;
|
|
141
|
+
lastText = await getLastResponseText(page);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const text = await getLastResponseText(page);
|
|
146
|
+
if (!text) continue;
|
|
147
|
+
|
|
148
|
+
if (text === lastText) {
|
|
149
|
+
stableCount++;
|
|
150
|
+
if (stableCount >= STABLE_CHECKS) {
|
|
151
|
+
log(`gemini-browser: response stable (${text.length} chars)`);
|
|
152
|
+
return text;
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
stableCount = 0;
|
|
156
|
+
lastText = text;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throw new Error(`gemini.google.com response timeout after ${timeoutMs}ms`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export async function geminiComplete(
|
|
166
|
+
context: BrowserContext,
|
|
167
|
+
opts: GeminiBrowserOptions,
|
|
168
|
+
log: (msg: string) => void
|
|
169
|
+
): Promise<GeminiBrowserResult> {
|
|
170
|
+
const { page, owned } = await getOrCreateGeminiPage(context);
|
|
171
|
+
const model = resolveModel(opts.model);
|
|
172
|
+
const prompt = flattenMessages(opts.messages);
|
|
173
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
174
|
+
|
|
175
|
+
log(`gemini-browser: complete model=${model}`);
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const content = await sendAndWait(page, prompt, timeoutMs, log);
|
|
179
|
+
return { content, model, finishReason: "stop" };
|
|
180
|
+
} finally {
|
|
181
|
+
if (owned) await page.close().catch(() => {});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function geminiCompleteStream(
|
|
186
|
+
context: BrowserContext,
|
|
187
|
+
opts: GeminiBrowserOptions,
|
|
188
|
+
onToken: (token: string) => void,
|
|
189
|
+
log: (msg: string) => void
|
|
190
|
+
): Promise<GeminiBrowserResult> {
|
|
191
|
+
const { page, owned } = await getOrCreateGeminiPage(context);
|
|
192
|
+
const model = resolveModel(opts.model);
|
|
193
|
+
const prompt = flattenMessages(opts.messages);
|
|
194
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
195
|
+
|
|
196
|
+
log(`gemini-browser: stream model=${model}`);
|
|
197
|
+
|
|
198
|
+
const countBefore = await countResponses(page);
|
|
199
|
+
|
|
200
|
+
const editor = page.locator(".ql-editor");
|
|
201
|
+
await editor.click();
|
|
202
|
+
await editor.type(prompt, { delay: 10 });
|
|
203
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
204
|
+
await page.keyboard.press("Enter");
|
|
205
|
+
|
|
206
|
+
const deadline = Date.now() + timeoutMs;
|
|
207
|
+
let emittedLength = 0;
|
|
208
|
+
let lastText = "";
|
|
209
|
+
let stableCount = 0;
|
|
210
|
+
|
|
211
|
+
while (Date.now() < deadline) {
|
|
212
|
+
await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
|
|
213
|
+
|
|
214
|
+
const currentCount = await countResponses(page);
|
|
215
|
+
if (currentCount <= countBefore) continue;
|
|
216
|
+
|
|
217
|
+
const text = await getLastResponseText(page);
|
|
218
|
+
|
|
219
|
+
if (text.length > emittedLength) {
|
|
220
|
+
onToken(text.slice(emittedLength));
|
|
221
|
+
emittedLength = text.length;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const streaming = await isStreaming(page);
|
|
225
|
+
if (streaming) { stableCount = 0; continue; }
|
|
226
|
+
|
|
227
|
+
if (text && text === lastText) {
|
|
228
|
+
stableCount++;
|
|
229
|
+
if (stableCount >= STABLE_CHECKS) {
|
|
230
|
+
log(`gemini-browser: stream done (${text.length} chars)`);
|
|
231
|
+
if (owned) await page.close().catch(() => {});
|
|
232
|
+
return { content: text, model, finishReason: "stop" };
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
stableCount = 0;
|
|
236
|
+
lastText = text;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (owned) await page.close().catch(() => {});
|
|
241
|
+
throw new Error(`gemini.google.com stream timeout after ${timeoutMs}ms`);
|
|
242
|
+
}
|
package/src/proxy-server.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { type ChatMessage, routeToCliRunner } from "./cli-runner.js";
|
|
|
14
14
|
import { scheduleTokenRefresh, setAuthLogger, stopTokenRefresh } from "./claude-auth.js";
|
|
15
15
|
import { grokComplete, grokCompleteStream, type ChatMessage as GrokChatMessage } from "./grok-client.js";
|
|
16
16
|
import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowserChatMessage } from "./claude-browser.js";
|
|
17
|
+
import { geminiComplete, geminiCompleteStream, type ChatMessage as GeminiBrowserChatMessage } from "./gemini-browser.js";
|
|
17
18
|
import type { BrowserContext } from "playwright";
|
|
18
19
|
|
|
19
20
|
export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
|
|
@@ -42,6 +43,14 @@ export interface ProxyServerOptions {
|
|
|
42
43
|
_claudeComplete?: typeof claudeComplete;
|
|
43
44
|
/** Override for testing — replaces claudeCompleteStream */
|
|
44
45
|
_claudeCompleteStream?: typeof claudeCompleteStream;
|
|
46
|
+
/** Returns the current authenticated Gemini BrowserContext (null if not logged in) */
|
|
47
|
+
getGeminiContext?: () => BrowserContext | null;
|
|
48
|
+
/** Async lazy connect — called when getGeminiContext returns null */
|
|
49
|
+
connectGeminiContext?: () => Promise<BrowserContext | null>;
|
|
50
|
+
/** Override for testing — replaces geminiComplete */
|
|
51
|
+
_geminiComplete?: typeof geminiComplete;
|
|
52
|
+
/** Override for testing — replaces geminiCompleteStream */
|
|
53
|
+
_geminiCompleteStream?: typeof geminiCompleteStream;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
/** Available CLI bridge models for GET /v1/models */
|
|
@@ -91,6 +100,11 @@ export const CLI_MODELS = [
|
|
|
91
100
|
{ id: "web-claude/claude-sonnet", name: "Claude Sonnet (web session)", contextWindow: 200_000, maxTokens: 8192 },
|
|
92
101
|
{ id: "web-claude/claude-opus", name: "Claude Opus (web session)", contextWindow: 200_000, maxTokens: 8192 },
|
|
93
102
|
{ id: "web-claude/claude-haiku", name: "Claude Haiku (web session)", contextWindow: 200_000, maxTokens: 8192 },
|
|
103
|
+
// Gemini web-session models (requires /gemini-login)
|
|
104
|
+
{ id: "web-gemini/gemini-2-5-pro", name: "Gemini 2.5 Pro (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
|
|
105
|
+
{ id: "web-gemini/gemini-2-5-flash", name: "Gemini 2.5 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
|
|
106
|
+
{ id: "web-gemini/gemini-3-pro", name: "Gemini 3 Pro (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
|
|
107
|
+
{ id: "web-gemini/gemini-3-flash", name: "Gemini 3 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
|
|
94
108
|
];
|
|
95
109
|
|
|
96
110
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -311,6 +325,55 @@ async function handleRequest(
|
|
|
311
325
|
}
|
|
312
326
|
// ─────────────────────────────────────────────────────────────────────────
|
|
313
327
|
|
|
328
|
+
// ── Gemini web-session routing ────────────────────────────────────────────
|
|
329
|
+
if (model.startsWith("web-gemini/")) {
|
|
330
|
+
let geminiCtx = opts.getGeminiContext?.() ?? null;
|
|
331
|
+
if (!geminiCtx && opts.connectGeminiContext) {
|
|
332
|
+
geminiCtx = await opts.connectGeminiContext();
|
|
333
|
+
}
|
|
334
|
+
if (!geminiCtx) {
|
|
335
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
336
|
+
res.end(JSON.stringify({ error: { message: "No active gemini.google.com session. Use /gemini-login to authenticate.", code: "no_gemini_session" } }));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const timeoutMs = opts.timeoutMs ?? 120_000;
|
|
340
|
+
const geminiMessages = messages as GeminiBrowserChatMessage[];
|
|
341
|
+
const doGeminiComplete = opts._geminiComplete ?? geminiComplete;
|
|
342
|
+
const doGeminiCompleteStream = opts._geminiCompleteStream ?? geminiCompleteStream;
|
|
343
|
+
try {
|
|
344
|
+
if (stream) {
|
|
345
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
|
|
346
|
+
sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
|
|
347
|
+
const result = await doGeminiCompleteStream(
|
|
348
|
+
geminiCtx,
|
|
349
|
+
{ messages: geminiMessages, model, timeoutMs },
|
|
350
|
+
(token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
|
|
351
|
+
opts.log
|
|
352
|
+
);
|
|
353
|
+
sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
|
|
354
|
+
res.write("data: [DONE]\n\n");
|
|
355
|
+
res.end();
|
|
356
|
+
} else {
|
|
357
|
+
const result = await doGeminiComplete(geminiCtx, { messages: geminiMessages, model, timeoutMs }, opts.log);
|
|
358
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
359
|
+
res.end(JSON.stringify({
|
|
360
|
+
id, object: "chat.completion", created, model,
|
|
361
|
+
choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
|
|
362
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
} catch (err) {
|
|
366
|
+
const msg = (err as Error).message;
|
|
367
|
+
opts.warn(`[cli-bridge] Gemini browser error for ${model}: ${msg}`);
|
|
368
|
+
if (!res.headersSent) {
|
|
369
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
370
|
+
res.end(JSON.stringify({ error: { message: msg, type: "gemini_browser_error" } }));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
314
377
|
// ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
|
|
315
378
|
let content: string;
|
|
316
379
|
try {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test/gemini-proxy.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for Gemini web-session routing in the cli-bridge proxy.
|
|
5
|
+
* Uses _geminiComplete/_geminiCompleteStream DI overrides (no real browser).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import type { AddressInfo } from "node:net";
|
|
11
|
+
import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
|
|
12
|
+
import type { BrowserContext } from "playwright";
|
|
13
|
+
|
|
14
|
+
type GeminiCompleteOptions = { messages: { role: string; content: string }[]; model?: string; timeoutMs?: number };
|
|
15
|
+
type GeminiCompleteResult = { content: string; model: string; finishReason: string };
|
|
16
|
+
|
|
17
|
+
const stubGeminiComplete = vi.fn(async (
|
|
18
|
+
_ctx: BrowserContext,
|
|
19
|
+
opts: GeminiCompleteOptions,
|
|
20
|
+
_log: (msg: string) => void
|
|
21
|
+
): Promise<GeminiCompleteResult> => ({
|
|
22
|
+
content: `gemini mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
|
|
23
|
+
model: opts.model ?? "web-gemini/gemini-2-5-pro",
|
|
24
|
+
finishReason: "stop",
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const stubGeminiCompleteStream = vi.fn(async (
|
|
28
|
+
_ctx: BrowserContext,
|
|
29
|
+
opts: GeminiCompleteOptions,
|
|
30
|
+
onToken: (t: string) => void,
|
|
31
|
+
_log: (msg: string) => void
|
|
32
|
+
): Promise<GeminiCompleteResult> => {
|
|
33
|
+
const tokens = ["gemini ", "stream ", "mock"];
|
|
34
|
+
for (const t of tokens) onToken(t);
|
|
35
|
+
return { content: tokens.join(""), model: opts.model ?? "web-gemini/gemini-2-5-pro", finishReason: "stop" };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
async function httpPost(url: string, body: unknown): Promise<{ status: number; body: unknown }> {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const data = JSON.stringify(body);
|
|
41
|
+
const u = new URL(url);
|
|
42
|
+
const req = http.request(
|
|
43
|
+
{ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
|
|
44
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } },
|
|
45
|
+
(res) => { let raw = ""; res.on("data", c => raw += c); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, body: raw }); } }); }
|
|
46
|
+
);
|
|
47
|
+
req.on("error", reject); req.write(data); req.end();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async function httpGet(url: string): Promise<{ status: number; body: unknown }> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const u = new URL(url);
|
|
53
|
+
const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "GET" },
|
|
54
|
+
(res) => { let raw = ""; res.on("data", c => raw += c); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, body: raw }); } }); }
|
|
55
|
+
);
|
|
56
|
+
req.on("error", reject); req.end();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const fakeCtx = {} as BrowserContext;
|
|
61
|
+
let server: http.Server;
|
|
62
|
+
let baseUrl: string;
|
|
63
|
+
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
server = await startProxyServer({
|
|
66
|
+
port: 0, log: () => {}, warn: () => {},
|
|
67
|
+
getGeminiContext: () => fakeCtx,
|
|
68
|
+
// @ts-expect-error — stub types close enough for testing
|
|
69
|
+
_geminiComplete: stubGeminiComplete,
|
|
70
|
+
// @ts-expect-error — stub types close enough for testing
|
|
71
|
+
_geminiCompleteStream: stubGeminiCompleteStream,
|
|
72
|
+
});
|
|
73
|
+
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
74
|
+
});
|
|
75
|
+
afterAll(() => server.close());
|
|
76
|
+
|
|
77
|
+
describe("Gemini web-session routing — model list", () => {
|
|
78
|
+
it("includes web-gemini/* models in /v1/models", async () => {
|
|
79
|
+
const res = await httpGet(`${baseUrl}/v1/models`);
|
|
80
|
+
expect(res.status).toBe(200);
|
|
81
|
+
const ids = (res.body as { data: { id: string }[] }).data.map(m => m.id);
|
|
82
|
+
expect(ids).toContain("web-gemini/gemini-2-5-pro");
|
|
83
|
+
expect(ids).toContain("web-gemini/gemini-3-pro");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("web-gemini/* models listed in CLI_MODELS constant", () => {
|
|
87
|
+
expect(CLI_MODELS.some(m => m.id.startsWith("web-gemini/"))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("Gemini web-session routing — non-streaming", () => {
|
|
92
|
+
it("returns assistant message for web-gemini/gemini-2-5-pro", async () => {
|
|
93
|
+
const res = await httpPost(`${baseUrl}/v1/chat/completions`, {
|
|
94
|
+
model: "web-gemini/gemini-2-5-pro",
|
|
95
|
+
messages: [{ role: "user", content: "hello gemini" }],
|
|
96
|
+
stream: false,
|
|
97
|
+
});
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
const body = res.body as { choices: { message: { content: string } }[] };
|
|
100
|
+
expect(body.choices[0].message.content).toContain("gemini mock");
|
|
101
|
+
expect(body.choices[0].message.content).toContain("hello gemini");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("passes correct model to stub", async () => {
|
|
105
|
+
stubGeminiComplete.mockClear();
|
|
106
|
+
await httpPost(`${baseUrl}/v1/chat/completions`, {
|
|
107
|
+
model: "web-gemini/gemini-3-flash",
|
|
108
|
+
messages: [{ role: "user", content: "test" }],
|
|
109
|
+
});
|
|
110
|
+
expect(stubGeminiComplete).toHaveBeenCalledOnce();
|
|
111
|
+
expect(stubGeminiComplete.mock.calls[0][1].model).toBe("web-gemini/gemini-3-flash");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns 503 when no gemini context", async () => {
|
|
115
|
+
const s = await startProxyServer({ port: 0, log: () => {}, warn: () => {}, getGeminiContext: () => null });
|
|
116
|
+
const url = `http://127.0.0.1:${(s.address() as AddressInfo).port}`;
|
|
117
|
+
const res = await httpPost(`${url}/v1/chat/completions`, {
|
|
118
|
+
model: "web-gemini/gemini-2-5-pro",
|
|
119
|
+
messages: [{ role: "user", content: "hi" }],
|
|
120
|
+
});
|
|
121
|
+
expect(res.status).toBe(503);
|
|
122
|
+
expect((res.body as { error: { code: string } }).error.code).toBe("no_gemini_session");
|
|
123
|
+
s.close();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("Gemini web-session routing — streaming", () => {
|
|
128
|
+
it("returns SSE stream", async () => {
|
|
129
|
+
return new Promise<void>((resolve, reject) => {
|
|
130
|
+
const body = JSON.stringify({ model: "web-gemini/gemini-2-5-pro", messages: [{ role: "user", content: "stream" }], stream: true });
|
|
131
|
+
const u = new URL(`${baseUrl}/v1/chat/completions`);
|
|
132
|
+
const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
|
|
133
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
|
|
134
|
+
(res) => { expect(res.statusCode).toBe(200); let raw = ""; res.on("data", c => raw += c); res.on("end", () => { expect(raw).toContain("[DONE]"); resolve(); }); }
|
|
135
|
+
);
|
|
136
|
+
req.on("error", reject); req.write(body); req.end();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|