@elvatis_com/openclaw-cli-bridge-elvatis 1.6.5 → 1.7.1
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 +7 -1
- package/SKILL.md +1 -1
- package/index.ts +118 -49
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/expiry-helpers.ts +52 -0
- package/src/proxy-server.ts +114 -0
- package/test/expiry-helpers.test.ts +134 -0
- package/test/proxy-e2e.test.ts +80 -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:** `1.
|
|
5
|
+
**Current version:** `1.7.0`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -362,6 +362,12 @@ npm test # vitest run (83 tests)
|
|
|
362
362
|
|
|
363
363
|
## Changelog
|
|
364
364
|
|
|
365
|
+
### v1.7.0
|
|
366
|
+
- **fix:** Startup restore timeout 3s → 6s with one retry, eliminates false "not logged in" for slow-loading pages (Grok)
|
|
367
|
+
- **feat:** Auto-relogin on startup — if cookies truly expired, attempt headless relogin before sending WhatsApp alert
|
|
368
|
+
- **feat:** Keep-alive (20h) now verifies session after touch and attempts auto-relogin if expired
|
|
369
|
+
- **feat:** Tests (vitest) — proxy tool rejection, models endpoint, auth, cookie expiry formatters
|
|
370
|
+
|
|
365
371
|
### v1.6.5
|
|
366
372
|
- **feat:** Automatic session keep-alive — every 20h, active browser sessions are silently refreshed by navigating to the provider home page. Prevents cookie expiry on providers like ChatGPT (7-day sessions) without storing credentials.
|
|
367
373
|
|
package/SKILL.md
CHANGED
package/index.ts
CHANGED
|
@@ -56,6 +56,12 @@ import {
|
|
|
56
56
|
createContextFromSession,
|
|
57
57
|
DEFAULT_SESSION_PATH,
|
|
58
58
|
} from "./src/grok-session.js";
|
|
59
|
+
import {
|
|
60
|
+
formatExpiryInfo,
|
|
61
|
+
formatGeminiExpiry,
|
|
62
|
+
formatClaudeExpiry,
|
|
63
|
+
formatChatGPTExpiry,
|
|
64
|
+
} from "./src/expiry-helpers.js";
|
|
59
65
|
import type { BrowserContext, Browser } from "playwright";
|
|
60
66
|
|
|
61
67
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -120,14 +126,7 @@ function saveGeminiExpiry(info: GeminiExpiryInfo): void {
|
|
|
120
126
|
function loadGeminiExpiry(): GeminiExpiryInfo | null {
|
|
121
127
|
try { return JSON.parse(readFileSync(GEMINI_EXPIRY_FILE, "utf-8")) as GeminiExpiryInfo; } catch { return null; }
|
|
122
128
|
}
|
|
123
|
-
|
|
124
|
-
const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
|
|
125
|
-
const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
|
|
126
|
-
if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /gemini-login`;
|
|
127
|
-
if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /gemini-login NOW`;
|
|
128
|
-
if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /gemini-login soon`;
|
|
129
|
-
return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
|
|
130
|
-
}
|
|
129
|
+
// formatGeminiExpiry imported from ./src/expiry-helpers.js
|
|
131
130
|
async function scanGeminiCookieExpiry(ctx: BrowserContext): Promise<GeminiExpiryInfo | null> {
|
|
132
131
|
try {
|
|
133
132
|
const cookies = await ctx.cookies(["https://gemini.google.com", "https://accounts.google.com"]);
|
|
@@ -146,14 +145,7 @@ function saveClaudeExpiry(info: ClaudeExpiryInfo): void {
|
|
|
146
145
|
function loadClaudeExpiry(): ClaudeExpiryInfo | null {
|
|
147
146
|
try { return JSON.parse(readFileSync(CLAUDE_EXPIRY_FILE, "utf-8")) as ClaudeExpiryInfo; } catch { return null; }
|
|
148
147
|
}
|
|
149
|
-
|
|
150
|
-
const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
|
|
151
|
-
const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
|
|
152
|
-
if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /claude-login`;
|
|
153
|
-
if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /claude-login NOW`;
|
|
154
|
-
if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /claude-login soon`;
|
|
155
|
-
return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
|
|
156
|
-
}
|
|
148
|
+
// formatClaudeExpiry imported from ./src/expiry-helpers.js
|
|
157
149
|
async function scanClaudeCookieExpiry(ctx: BrowserContext): Promise<ClaudeExpiryInfo | null> {
|
|
158
150
|
try {
|
|
159
151
|
const cookies = await ctx.cookies(["https://claude.ai"]);
|
|
@@ -173,14 +165,7 @@ function saveChatGPTExpiry(info: ChatGPTExpiryInfo): void {
|
|
|
173
165
|
function loadChatGPTExpiry(): ChatGPTExpiryInfo | null {
|
|
174
166
|
try { return JSON.parse(readFileSync(CHATGPT_EXPIRY_FILE, "utf-8")) as ChatGPTExpiryInfo; } catch { return null; }
|
|
175
167
|
}
|
|
176
|
-
|
|
177
|
-
const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
|
|
178
|
-
const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
|
|
179
|
-
if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /chatgpt-login`;
|
|
180
|
-
if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /chatgpt-login NOW`;
|
|
181
|
-
if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /chatgpt-login soon`;
|
|
182
|
-
return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
|
|
183
|
-
}
|
|
168
|
+
// formatChatGPTExpiry imported from ./src/expiry-helpers.js
|
|
184
169
|
async function scanChatGPTCookieExpiry(ctx: BrowserContext): Promise<ChatGPTExpiryInfo | null> {
|
|
185
170
|
try {
|
|
186
171
|
const cookies = await ctx.cookies(["https://chatgpt.com", "https://auth0.openai.com"]);
|
|
@@ -216,15 +201,7 @@ function loadGrokExpiry(): GrokExpiryInfo | null {
|
|
|
216
201
|
} catch { return null; }
|
|
217
202
|
}
|
|
218
203
|
|
|
219
|
-
|
|
220
|
-
function formatExpiryInfo(info: GrokExpiryInfo): string {
|
|
221
|
-
const daysLeft = Math.ceil((info.expiresAt - Date.now()) / 86_400_000);
|
|
222
|
-
const dateStr = new Date(info.expiresAt).toISOString().split("T")[0];
|
|
223
|
-
if (daysLeft <= 0) return `⚠️ EXPIRED (was ${dateStr})`;
|
|
224
|
-
if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /grok-login NOW`;
|
|
225
|
-
if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /grok-login soon`;
|
|
226
|
-
return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
|
|
227
|
-
}
|
|
204
|
+
// formatExpiryInfo imported from ./src/expiry-helpers.js
|
|
228
205
|
|
|
229
206
|
/** Scan context cookies and return earliest auth cookie expiry */
|
|
230
207
|
async function scanCookieExpiry(ctx: import("playwright").BrowserContext): Promise<GrokExpiryInfo | null> {
|
|
@@ -438,21 +415,28 @@ async function getOrLaunchChatGPTContext(
|
|
|
438
415
|
return _chatgptLaunchPromise;
|
|
439
416
|
}
|
|
440
417
|
|
|
441
|
-
/** Session keep-alive — navigate to provider home pages to refresh cookies
|
|
442
|
-
|
|
418
|
+
/** Session keep-alive — navigate to provider home pages to refresh cookies.
|
|
419
|
+
* After each touch, verifies the session is still valid. If expired, attempts
|
|
420
|
+
* a full relogin. Returns provider login commands that need manual attention. */
|
|
421
|
+
async function sessionKeepAlive(log: (msg: string) => void): Promise<string[]> {
|
|
443
422
|
const providers: Array<{
|
|
444
423
|
name: string;
|
|
445
424
|
homeUrl: string;
|
|
425
|
+
verifySelector: string;
|
|
426
|
+
loginCmd: string;
|
|
446
427
|
getCtx: () => BrowserContext | null;
|
|
428
|
+
setCtx: (c: BrowserContext | null) => void;
|
|
447
429
|
scanExpiry: (ctx: BrowserContext) => Promise<{ expiresAt: number; loginAt: number; cookieName: string } | null>;
|
|
448
430
|
saveExpiry: (info: { expiresAt: number; loginAt: number; cookieName: string }) => void;
|
|
449
431
|
}> = [
|
|
450
|
-
{ name: "grok", homeUrl: "https://grok.com", getCtx: () => grokContext, scanExpiry: scanCookieExpiry, saveExpiry: saveGrokExpiry },
|
|
451
|
-
{ name: "gemini", homeUrl: "https://gemini.google.com/app", getCtx: () => geminiContext, scanExpiry: scanGeminiCookieExpiry, saveExpiry: saveGeminiExpiry },
|
|
452
|
-
{ name: "claude-web", homeUrl: "https://claude.ai/new", getCtx: () => claudeWebContext, scanExpiry: scanClaudeCookieExpiry, saveExpiry: saveClaudeExpiry },
|
|
453
|
-
{ name: "chatgpt", homeUrl: "https://chatgpt.com", getCtx: () => chatgptContext, scanExpiry: scanChatGPTCookieExpiry, saveExpiry: saveChatGPTExpiry },
|
|
432
|
+
{ name: "grok", homeUrl: "https://grok.com", verifySelector: "textarea", loginCmd: "/grok-login", getCtx: () => grokContext, setCtx: (c) => { grokContext = c; }, scanExpiry: scanCookieExpiry, saveExpiry: saveGrokExpiry },
|
|
433
|
+
{ name: "gemini", homeUrl: "https://gemini.google.com/app", verifySelector: ".ql-editor", loginCmd: "/gemini-login", getCtx: () => geminiContext, setCtx: (c) => { geminiContext = c; }, scanExpiry: scanGeminiCookieExpiry, saveExpiry: saveGeminiExpiry },
|
|
434
|
+
{ name: "claude-web", homeUrl: "https://claude.ai/new", verifySelector: ".ProseMirror", loginCmd: "/claude-login", getCtx: () => claudeWebContext, setCtx: (c) => { claudeWebContext = c; }, scanExpiry: scanClaudeCookieExpiry, saveExpiry: saveClaudeExpiry },
|
|
435
|
+
{ name: "chatgpt", homeUrl: "https://chatgpt.com", verifySelector: "#prompt-textarea", loginCmd: "/chatgpt-login", getCtx: () => chatgptContext, setCtx: (c) => { chatgptContext = c; }, scanExpiry: scanChatGPTCookieExpiry, saveExpiry: saveChatGPTExpiry },
|
|
454
436
|
];
|
|
455
437
|
|
|
438
|
+
const needsLogin: string[] = [];
|
|
439
|
+
|
|
456
440
|
for (const p of providers) {
|
|
457
441
|
const ctx = p.getCtx();
|
|
458
442
|
if (!ctx) continue;
|
|
@@ -460,16 +444,49 @@ async function sessionKeepAlive(log: (msg: string) => void): Promise<void> {
|
|
|
460
444
|
const page = await ctx.newPage();
|
|
461
445
|
await page.goto(p.homeUrl, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
462
446
|
await new Promise(r => setTimeout(r, 4000));
|
|
447
|
+
|
|
448
|
+
// Verify session is still valid after touch
|
|
449
|
+
let valid = await page.locator(p.verifySelector).isVisible().catch(() => false);
|
|
450
|
+
if (!valid) {
|
|
451
|
+
// Retry once
|
|
452
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
453
|
+
valid = await page.locator(p.verifySelector).isVisible().catch(() => false);
|
|
454
|
+
}
|
|
463
455
|
await page.close();
|
|
464
|
-
|
|
465
|
-
if (
|
|
466
|
-
|
|
456
|
+
|
|
457
|
+
if (valid) {
|
|
458
|
+
const expiry = await p.scanExpiry(ctx);
|
|
459
|
+
if (expiry) p.saveExpiry(expiry);
|
|
460
|
+
log(`[cli-bridge:${p.name}] session keep-alive touch ✅`);
|
|
461
|
+
} else {
|
|
462
|
+
// Session expired — attempt relogin in same persistent context
|
|
463
|
+
log(`[cli-bridge:${p.name}] session expired after keep-alive — attempting auto-relogin…`);
|
|
464
|
+
const reloginPage = await ctx.newPage();
|
|
465
|
+
await reloginPage.goto(p.homeUrl, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
|
466
|
+
await new Promise(r => setTimeout(r, 6000));
|
|
467
|
+
let reloginOk = await reloginPage.locator(p.verifySelector).isVisible().catch(() => false);
|
|
468
|
+
if (!reloginOk) {
|
|
469
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
470
|
+
reloginOk = await reloginPage.locator(p.verifySelector).isVisible().catch(() => false);
|
|
471
|
+
}
|
|
472
|
+
await reloginPage.close().catch(() => {});
|
|
473
|
+
if (reloginOk) {
|
|
474
|
+
const expiry = await p.scanExpiry(ctx);
|
|
475
|
+
if (expiry) p.saveExpiry(expiry);
|
|
476
|
+
log(`[cli-bridge:${p.name}] auto-relogin successful ✅`);
|
|
477
|
+
} else {
|
|
478
|
+
log(`[cli-bridge:${p.name}] auto-relogin failed, needs manual ${p.loginCmd}`);
|
|
479
|
+
needsLogin.push(p.loginCmd);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
467
482
|
} catch (err) {
|
|
468
483
|
log(`[cli-bridge:${p.name}] session keep-alive failed: ${(err as Error).message}`);
|
|
469
484
|
}
|
|
470
485
|
// Sequential — avoid spawning multiple pages at once
|
|
471
486
|
await new Promise(r => setTimeout(r, 2000));
|
|
472
487
|
}
|
|
488
|
+
|
|
489
|
+
return needsLogin;
|
|
473
490
|
}
|
|
474
491
|
|
|
475
492
|
/** Clean up all browser resources — call on plugin teardown */
|
|
@@ -895,7 +912,7 @@ function proxyTestRequest(
|
|
|
895
912
|
const plugin = {
|
|
896
913
|
id: "openclaw-cli-bridge-elvatis",
|
|
897
914
|
name: "OpenClaw CLI Bridge",
|
|
898
|
-
version: "1.
|
|
915
|
+
version: "1.7.0",
|
|
899
916
|
description:
|
|
900
917
|
"Phase 1: openai-codex auth bridge. " +
|
|
901
918
|
"Phase 2: HTTP proxy for gemini/claude CLIs. " +
|
|
@@ -996,17 +1013,47 @@ const plugin = {
|
|
|
996
1013
|
});
|
|
997
1014
|
const page = await ctx.newPage();
|
|
998
1015
|
await page.goto(p.homeUrl, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
|
999
|
-
await new Promise(r => setTimeout(r,
|
|
1000
|
-
|
|
1016
|
+
await new Promise(r => setTimeout(r, 6000));
|
|
1017
|
+
let ok = await page.locator(p.verifySelector).isVisible().catch(() => false);
|
|
1018
|
+
// Retry once — some pages (Grok) load slowly
|
|
1019
|
+
if (!ok) {
|
|
1020
|
+
api.logger.info(`[cli-bridge:${p.name}] verifySelector not visible after 6s, retrying (3s)…`);
|
|
1021
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
1022
|
+
ok = await page.locator(p.verifySelector).isVisible().catch(() => false);
|
|
1023
|
+
}
|
|
1001
1024
|
await page.close().catch(() => {});
|
|
1002
1025
|
if (ok) {
|
|
1003
1026
|
p.setCtx(ctx);
|
|
1004
1027
|
ctx.on("close", () => { p.setCtx(null as unknown as BrowserContext); });
|
|
1005
1028
|
api.logger.info(`[cli-bridge:${p.name}] session restored from profile ✅`);
|
|
1006
1029
|
} else {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1030
|
+
// Session may be truly expired or just slow — attempt auto-relogin:
|
|
1031
|
+
// re-navigate in the same context and check once more
|
|
1032
|
+
api.logger.info(`[cli-bridge:${p.name}] not logged in after restore — attempting auto-relogin…`);
|
|
1033
|
+
try {
|
|
1034
|
+
const reloginPage = await ctx.newPage();
|
|
1035
|
+
await reloginPage.goto(p.homeUrl, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
|
1036
|
+
await new Promise(r => setTimeout(r, 6000));
|
|
1037
|
+
let reloginOk = await reloginPage.locator(p.verifySelector).isVisible().catch(() => false);
|
|
1038
|
+
if (!reloginOk) {
|
|
1039
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
1040
|
+
reloginOk = await reloginPage.locator(p.verifySelector).isVisible().catch(() => false);
|
|
1041
|
+
}
|
|
1042
|
+
await reloginPage.close().catch(() => {});
|
|
1043
|
+
if (reloginOk) {
|
|
1044
|
+
p.setCtx(ctx);
|
|
1045
|
+
ctx.on("close", () => { p.setCtx(null as unknown as BrowserContext); });
|
|
1046
|
+
api.logger.info(`[cli-bridge:${p.name}] session restored (slow load) ✅`);
|
|
1047
|
+
} else {
|
|
1048
|
+
await ctx.close().catch(() => {});
|
|
1049
|
+
api.logger.info(`[cli-bridge:${p.name}] auto-relogin failed, needs manual ${p.loginCmd}`);
|
|
1050
|
+
needsLogin.push(p.loginCmd);
|
|
1051
|
+
}
|
|
1052
|
+
} catch (reloginErr) {
|
|
1053
|
+
await ctx.close().catch(() => {});
|
|
1054
|
+
api.logger.warn(`[cli-bridge:${p.name}] auto-relogin error: ${(reloginErr as Error).message}`);
|
|
1055
|
+
needsLogin.push(p.loginCmd);
|
|
1056
|
+
}
|
|
1010
1057
|
}
|
|
1011
1058
|
} catch (err) {
|
|
1012
1059
|
api.logger.warn(`[cli-bridge:${p.name}] startup restore failed: ${(err as Error).message}`);
|
|
@@ -1036,7 +1083,22 @@ const plugin = {
|
|
|
1036
1083
|
// Start session keep-alive interval (every 20h)
|
|
1037
1084
|
if (!_keepAliveInterval) {
|
|
1038
1085
|
_keepAliveInterval = setInterval(() => {
|
|
1039
|
-
void
|
|
1086
|
+
void (async () => {
|
|
1087
|
+
const failed = await sessionKeepAlive((msg) => api.logger.info(msg));
|
|
1088
|
+
if (failed.length > 0) {
|
|
1089
|
+
const cmds = failed.map(cmd => `• ${cmd}`).join("\n");
|
|
1090
|
+
const msg = `🔐 *cli-bridge keep-alive:* Session expired for ${failed.length} provider(s). Run to re-login:\n\n${cmds}`;
|
|
1091
|
+
try {
|
|
1092
|
+
await api.runtime.system.runCommandWithTimeout(
|
|
1093
|
+
["openclaw", "message", "send", "--channel", "whatsapp", "--to", "+4915170113694", "--message", msg],
|
|
1094
|
+
{ timeoutMs: 10_000 }
|
|
1095
|
+
);
|
|
1096
|
+
api.logger.info(`[cli-bridge] keep-alive: sent re-login notification for: ${failed.join(", ")}`);
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
api.logger.warn(`[cli-bridge] keep-alive: failed to send notification: ${(err as Error).message}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
})();
|
|
1040
1102
|
}, 72_000_000);
|
|
1041
1103
|
}
|
|
1042
1104
|
}
|
|
@@ -1178,6 +1240,13 @@ const plugin = {
|
|
|
1178
1240
|
}
|
|
1179
1241
|
return chatgptContext;
|
|
1180
1242
|
},
|
|
1243
|
+
version: plugin.version,
|
|
1244
|
+
getExpiryInfo: () => ({
|
|
1245
|
+
grok: (() => { const e = loadGrokExpiry(); return e ? formatExpiryInfo(e) : null; })(),
|
|
1246
|
+
gemini: (() => { const e = loadGeminiExpiry(); return e ? formatGeminiExpiry(e) : null; })(),
|
|
1247
|
+
claude: (() => { const e = loadClaudeExpiry(); return e ? formatClaudeExpiry(e) : null; })(),
|
|
1248
|
+
chatgpt: (() => { const e = loadChatGPTExpiry(); return e ? formatChatGPTExpiry(e) : null; })(),
|
|
1249
|
+
}),
|
|
1181
1250
|
});
|
|
1182
1251
|
proxyServer = server;
|
|
1183
1252
|
api.logger.info(
|
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": "1.
|
|
4
|
+
"version": "1.7.1",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
|
|
7
7
|
"providers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
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,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* expiry-helpers.ts
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for formatting cookie expiry info.
|
|
5
|
+
* Extracted from index.ts for testability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ExpiryInfo {
|
|
9
|
+
expiresAt: number; // epoch ms
|
|
10
|
+
loginAt: number; // epoch ms
|
|
11
|
+
cookieName: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Grok cookie expiry — uses Math.ceil for daysLeft */
|
|
15
|
+
export function formatExpiryInfo(info: ExpiryInfo): string {
|
|
16
|
+
const daysLeft = Math.ceil((info.expiresAt - Date.now()) / 86_400_000);
|
|
17
|
+
const dateStr = new Date(info.expiresAt).toISOString().split("T")[0];
|
|
18
|
+
if (daysLeft <= 0) return `⚠️ EXPIRED (was ${dateStr})`;
|
|
19
|
+
if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /grok-login NOW`;
|
|
20
|
+
if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /grok-login soon`;
|
|
21
|
+
return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Gemini cookie expiry — uses Math.floor for daysLeft */
|
|
25
|
+
export function formatGeminiExpiry(info: ExpiryInfo): string {
|
|
26
|
+
const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
|
|
27
|
+
const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
|
|
28
|
+
if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /gemini-login`;
|
|
29
|
+
if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /gemini-login NOW`;
|
|
30
|
+
if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /gemini-login soon`;
|
|
31
|
+
return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Claude cookie expiry — uses Math.floor for daysLeft */
|
|
35
|
+
export function formatClaudeExpiry(info: ExpiryInfo): string {
|
|
36
|
+
const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
|
|
37
|
+
const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
|
|
38
|
+
if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /claude-login`;
|
|
39
|
+
if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /claude-login NOW`;
|
|
40
|
+
if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /claude-login soon`;
|
|
41
|
+
return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** ChatGPT cookie expiry — uses Math.floor for daysLeft */
|
|
45
|
+
export function formatChatGPTExpiry(info: ExpiryInfo): string {
|
|
46
|
+
const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
|
|
47
|
+
const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
|
|
48
|
+
if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /chatgpt-login`;
|
|
49
|
+
if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /chatgpt-login NOW`;
|
|
50
|
+
if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /chatgpt-login soon`;
|
|
51
|
+
return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
|
|
52
|
+
}
|
package/src/proxy-server.ts
CHANGED
|
@@ -60,6 +60,15 @@ export interface ProxyServerOptions {
|
|
|
60
60
|
_chatgptComplete?: typeof chatgptComplete;
|
|
61
61
|
/** Override for testing — replaces chatgptCompleteStream */
|
|
62
62
|
_chatgptCompleteStream?: typeof chatgptCompleteStream;
|
|
63
|
+
/** Returns human-readable expiry string for each web provider (null = no login yet) */
|
|
64
|
+
getExpiryInfo?: () => {
|
|
65
|
+
grok: string | null;
|
|
66
|
+
gemini: string | null;
|
|
67
|
+
claude: string | null;
|
|
68
|
+
chatgpt: string | null;
|
|
69
|
+
};
|
|
70
|
+
/** Plugin version string for the status page */
|
|
71
|
+
version?: string;
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
/** Available CLI bridge models for GET /v1/models */
|
|
@@ -156,6 +165,111 @@ async function handleRequest(
|
|
|
156
165
|
return;
|
|
157
166
|
}
|
|
158
167
|
|
|
168
|
+
// Browser status page — human-readable HTML dashboard
|
|
169
|
+
if ((url === "/status" || url === "/") && req.method === "GET") {
|
|
170
|
+
const expiry = opts.getExpiryInfo?.() ?? { grok: null, gemini: null, claude: null, chatgpt: null };
|
|
171
|
+
const version = opts.version ?? "?";
|
|
172
|
+
|
|
173
|
+
const providers = [
|
|
174
|
+
{ name: "Grok", icon: "𝕏", expiry: expiry.grok, loginCmd: "/grok-login", ctx: opts.getGrokContext?.() ?? null },
|
|
175
|
+
{ name: "Gemini", icon: "✦", expiry: expiry.gemini, loginCmd: "/gemini-login", ctx: opts.getGeminiContext?.() ?? null },
|
|
176
|
+
{ name: "Claude", icon: "◆", expiry: expiry.claude, loginCmd: "/claude-login", ctx: opts.getClaudeContext?.() ?? null },
|
|
177
|
+
{ name: "ChatGPT", icon: "◉", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
function statusBadge(p: typeof providers[0]): { label: string; color: string; dot: string } {
|
|
181
|
+
if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "🟢" };
|
|
182
|
+
if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "⚪" };
|
|
183
|
+
if (p.expiry.startsWith("⚠️ EXPIRED")) return { label: "Expired", color: "#ef4444", dot: "🔴" };
|
|
184
|
+
if (p.expiry.startsWith("🚨")) return { label: "Expiring soon", color: "#f59e0b", dot: "🟡" };
|
|
185
|
+
return { label: "Logged in", color: "#3b82f6", dot: "🔵" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const rows = providers.map(p => {
|
|
189
|
+
const badge = statusBadge(p);
|
|
190
|
+
const expiryText = p.expiry
|
|
191
|
+
? p.expiry.replace(/[⚠️🚨✅🕐]/gu, "").trim()
|
|
192
|
+
: `Not logged in — run <code>${p.loginCmd}</code>`;
|
|
193
|
+
return `
|
|
194
|
+
<tr>
|
|
195
|
+
<td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
|
|
196
|
+
<td style="padding:12px 16px">
|
|
197
|
+
<span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
|
|
198
|
+
border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
|
|
199
|
+
${badge.dot} ${badge.label}
|
|
200
|
+
</span>
|
|
201
|
+
</td>
|
|
202
|
+
<td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
|
|
203
|
+
<td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
|
|
204
|
+
</tr>`;
|
|
205
|
+
}).join("");
|
|
206
|
+
|
|
207
|
+
const cliModels = CLI_MODELS.filter(m => m.id.startsWith("cli-"));
|
|
208
|
+
const webModels = CLI_MODELS.filter(m => m.id.startsWith("web-"));
|
|
209
|
+
const modelList = (models: typeof CLI_MODELS) =>
|
|
210
|
+
models.map(m => `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code style="color:#93c5fd">${m.id}</code></li>`).join("");
|
|
211
|
+
|
|
212
|
+
const html = `<!DOCTYPE html>
|
|
213
|
+
<html lang="en">
|
|
214
|
+
<head>
|
|
215
|
+
<meta charset="UTF-8">
|
|
216
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
217
|
+
<title>CLI Bridge Status</title>
|
|
218
|
+
<meta http-equiv="refresh" content="30">
|
|
219
|
+
<style>
|
|
220
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
221
|
+
body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; padding: 32px 24px; }
|
|
222
|
+
h1 { font-size: 22px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
|
|
223
|
+
.subtitle { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
|
|
224
|
+
.card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
|
|
225
|
+
.card-header { padding: 14px 16px; border-bottom: 1px solid #2d3148; font-size: 12px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
226
|
+
table { width: 100%; border-collapse: collapse; }
|
|
227
|
+
tr:not(:last-child) td { border-bottom: 1px solid #1f2335; }
|
|
228
|
+
.models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
229
|
+
ul { list-style: none; padding: 12px 16px; }
|
|
230
|
+
.footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
231
|
+
code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
|
|
232
|
+
</style>
|
|
233
|
+
</head>
|
|
234
|
+
<body>
|
|
235
|
+
<h1>🌉 CLI Bridge</h1>
|
|
236
|
+
<p class="subtitle">v${version} · Port ${opts.port} · Auto-refreshes every 30s</p>
|
|
237
|
+
|
|
238
|
+
<div class="card">
|
|
239
|
+
<div class="card-header">Web Session Providers</div>
|
|
240
|
+
<table>
|
|
241
|
+
<thead>
|
|
242
|
+
<tr style="background:#13151f">
|
|
243
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
|
|
244
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
|
|
245
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
|
|
246
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
|
|
247
|
+
</tr>
|
|
248
|
+
</thead>
|
|
249
|
+
<tbody>${rows}</tbody>
|
|
250
|
+
</table>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div class="models">
|
|
254
|
+
<div class="card">
|
|
255
|
+
<div class="card-header">CLI Models (${cliModels.length})</div>
|
|
256
|
+
<ul>${modelList(cliModels)}</ul>
|
|
257
|
+
</div>
|
|
258
|
+
<div class="card">
|
|
259
|
+
<div class="card-header">Web Session Models (${webModels.length})</div>
|
|
260
|
+
<ul>${modelList(webModels)}</ul>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<p class="footer">openclaw-cli-bridge-elvatis v${version} · <a href="/v1/models" style="color:#4b5563">/v1/models</a> · <a href="/health" style="color:#4b5563">/health</a></p>
|
|
265
|
+
</body>
|
|
266
|
+
</html>`;
|
|
267
|
+
|
|
268
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
269
|
+
res.end(html);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
159
273
|
// Model list
|
|
160
274
|
if (url === "/v1/models" && req.method === "GET") {
|
|
161
275
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for cookie expiry formatting helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
formatExpiryInfo,
|
|
8
|
+
formatGeminiExpiry,
|
|
9
|
+
formatClaudeExpiry,
|
|
10
|
+
formatChatGPTExpiry,
|
|
11
|
+
type ExpiryInfo,
|
|
12
|
+
} from "../src/expiry-helpers.js";
|
|
13
|
+
|
|
14
|
+
// Fix Date.now() for deterministic tests
|
|
15
|
+
const NOW = new Date("2026-03-13T12:00:00Z").getTime();
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function makeExpiry(daysFromNow: number): ExpiryInfo {
|
|
22
|
+
return {
|
|
23
|
+
expiresAt: NOW + daysFromNow * 86_400_000,
|
|
24
|
+
loginAt: NOW - 86_400_000,
|
|
25
|
+
cookieName: "test-cookie",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
// formatExpiryInfo (Grok)
|
|
31
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
describe("formatExpiryInfo (Grok)", () => {
|
|
34
|
+
it("expired → contains EXPIRED", () => {
|
|
35
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
36
|
+
const result = formatExpiryInfo(makeExpiry(-5));
|
|
37
|
+
expect(result).toContain("EXPIRED");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("3 days → contains 🚨", () => {
|
|
41
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
42
|
+
const result = formatExpiryInfo(makeExpiry(3));
|
|
43
|
+
expect(result).toContain("🚨");
|
|
44
|
+
expect(result).toContain("3d");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("20 days → contains ✅", () => {
|
|
48
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
49
|
+
const result = formatExpiryInfo(makeExpiry(20));
|
|
50
|
+
expect(result).toContain("✅");
|
|
51
|
+
expect(result).toContain("20");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("10 days → contains ⚠️ (warning zone 7-14d)", () => {
|
|
55
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
56
|
+
const result = formatExpiryInfo(makeExpiry(10));
|
|
57
|
+
expect(result).toContain("⚠️");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// formatGeminiExpiry
|
|
63
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("formatGeminiExpiry", () => {
|
|
66
|
+
it("expired → contains EXPIRED", () => {
|
|
67
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
68
|
+
const result = formatGeminiExpiry(makeExpiry(-2));
|
|
69
|
+
expect(result).toContain("EXPIRED");
|
|
70
|
+
expect(result).toContain("/gemini-login");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("3 days → contains 🚨", () => {
|
|
74
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
75
|
+
const result = formatGeminiExpiry(makeExpiry(3));
|
|
76
|
+
expect(result).toContain("🚨");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("20 days → contains ✅", () => {
|
|
80
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
81
|
+
const result = formatGeminiExpiry(makeExpiry(20));
|
|
82
|
+
expect(result).toContain("✅");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
// formatClaudeExpiry
|
|
88
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe("formatClaudeExpiry", () => {
|
|
91
|
+
it("expired → contains EXPIRED", () => {
|
|
92
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
93
|
+
const result = formatClaudeExpiry(makeExpiry(-1));
|
|
94
|
+
expect(result).toContain("EXPIRED");
|
|
95
|
+
expect(result).toContain("/claude-login");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("3 days → contains 🚨", () => {
|
|
99
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
100
|
+
const result = formatClaudeExpiry(makeExpiry(3));
|
|
101
|
+
expect(result).toContain("🚨");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("20 days → contains ✅", () => {
|
|
105
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
106
|
+
const result = formatClaudeExpiry(makeExpiry(20));
|
|
107
|
+
expect(result).toContain("✅");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
// formatChatGPTExpiry
|
|
113
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe("formatChatGPTExpiry", () => {
|
|
116
|
+
it("expired → contains EXPIRED", () => {
|
|
117
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
118
|
+
const result = formatChatGPTExpiry(makeExpiry(-3));
|
|
119
|
+
expect(result).toContain("EXPIRED");
|
|
120
|
+
expect(result).toContain("/chatgpt-login");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("3 days → contains 🚨", () => {
|
|
124
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
125
|
+
const result = formatChatGPTExpiry(makeExpiry(3));
|
|
126
|
+
expect(result).toContain("🚨");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("20 days → contains ✅", () => {
|
|
130
|
+
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
|
131
|
+
const result = formatChatGPTExpiry(makeExpiry(20));
|
|
132
|
+
expect(result).toContain("✅");
|
|
133
|
+
});
|
|
134
|
+
});
|
package/test/proxy-e2e.test.ts
CHANGED
|
@@ -395,3 +395,83 @@ describe("Error handling", () => {
|
|
|
395
395
|
expect(JSON.parse(res.body).error.type).toBe("not_found");
|
|
396
396
|
});
|
|
397
397
|
});
|
|
398
|
+
|
|
399
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
400
|
+
// Tool/function call rejection for CLI-proxy models
|
|
401
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
describe("Tool call rejection", () => {
|
|
404
|
+
it("rejects tools for cli-gemini models with tools_not_supported", async () => {
|
|
405
|
+
const res = await json("/v1/chat/completions", {
|
|
406
|
+
model: "cli-gemini/gemini-2.5-pro",
|
|
407
|
+
messages: [{ role: "user", content: "hi" }],
|
|
408
|
+
tools: [{ type: "function", function: { name: "test", parameters: {} } }],
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(res.status).toBe(400);
|
|
412
|
+
const body = JSON.parse(res.body);
|
|
413
|
+
expect(body.error.code).toBe("tools_not_supported");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("rejects tools for cli-claude models with tools_not_supported", async () => {
|
|
417
|
+
const res = await json("/v1/chat/completions", {
|
|
418
|
+
model: "cli-claude/claude-sonnet-4-6",
|
|
419
|
+
messages: [{ role: "user", content: "hi" }],
|
|
420
|
+
tools: [{ type: "function", function: { name: "test", parameters: {} } }],
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
expect(res.status).toBe(400);
|
|
424
|
+
const body = JSON.parse(res.body);
|
|
425
|
+
expect(body.error.code).toBe("tools_not_supported");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("does NOT reject tools for web-grok models (returns 503 no session)", async () => {
|
|
429
|
+
const res = await json("/v1/chat/completions", {
|
|
430
|
+
model: "web-grok/grok-3",
|
|
431
|
+
messages: [{ role: "user", content: "hi" }],
|
|
432
|
+
tools: [{ type: "function", function: { name: "test", parameters: {} } }],
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// Should NOT be 400 tools_not_supported — reaches provider logic, gets 503 (no session)
|
|
436
|
+
expect(res.status).not.toBe(400);
|
|
437
|
+
expect(res.status).toBe(503);
|
|
438
|
+
const body = JSON.parse(res.body);
|
|
439
|
+
expect(body.error.code).toBe("no_grok_session");
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
444
|
+
// Model capabilities
|
|
445
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
describe("Model capabilities", () => {
|
|
448
|
+
it("cli-gemini models have capabilities.tools===false", async () => {
|
|
449
|
+
const res = await fetch("/v1/models");
|
|
450
|
+
const body = JSON.parse(res.body);
|
|
451
|
+
const cliGeminiModels = body.data.filter((m: { id: string }) => m.id.startsWith("cli-gemini/"));
|
|
452
|
+
expect(cliGeminiModels.length).toBeGreaterThan(0);
|
|
453
|
+
for (const m of cliGeminiModels) {
|
|
454
|
+
expect(m.capabilities.tools).toBe(false);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("cli-claude models have capabilities.tools===false", async () => {
|
|
459
|
+
const res = await fetch("/v1/models");
|
|
460
|
+
const body = JSON.parse(res.body);
|
|
461
|
+
const cliClaudeModels = body.data.filter((m: { id: string }) => m.id.startsWith("cli-claude/"));
|
|
462
|
+
expect(cliClaudeModels.length).toBeGreaterThan(0);
|
|
463
|
+
for (const m of cliClaudeModels) {
|
|
464
|
+
expect(m.capabilities.tools).toBe(false);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("web-grok models have capabilities.tools===true", async () => {
|
|
469
|
+
const res = await fetch("/v1/models");
|
|
470
|
+
const body = JSON.parse(res.body);
|
|
471
|
+
const webGrokModels = body.data.filter((m: { id: string }) => m.id.startsWith("web-grok/"));
|
|
472
|
+
expect(webGrokModels.length).toBeGreaterThan(0);
|
|
473
|
+
for (const m of webGrokModels) {
|
|
474
|
+
expect(m.capabilities.tools).toBe(true);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
});
|