@elvatis_com/openclaw-cli-bridge-elvatis 1.6.4 → 1.7.0

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 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.6.4`
5
+ **Current version:** `1.7.0`
6
6
 
7
7
  ---
8
8
 
@@ -362,6 +362,15 @@ 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
+
371
+ ### v1.6.5
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.
373
+
365
374
  ### v1.6.4
366
375
  - **chore:** version bump (1.6.3 was already published on npm with partial changes)
367
376
 
package/SKILL.md CHANGED
@@ -64,4 +64,4 @@ On gateway restart, if any session has expired, a **WhatsApp alert** is sent aut
64
64
 
65
65
  See `README.md` for full configuration reference and architecture diagram.
66
66
 
67
- **Version:** 1.6.4
67
+ **Version:** 1.7.0
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
- function formatGeminiExpiry(info: GeminiExpiryInfo): string {
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
- function formatClaudeExpiry(info: ClaudeExpiryInfo): string {
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
- function formatChatGPTExpiry(info: ChatGPTExpiryInfo): string {
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
- /** Returns human-readable expiry summary e.g. "179 days (2026-09-07)" */
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> {
@@ -249,6 +226,9 @@ let _cdpBrowserLaunchPromise: Promise<import("playwright").BrowserContext | null
249
226
  // Set to true after first run; hot-reloads see true and skip the restore loop.
250
227
  let _startupRestoreDone = false;
251
228
 
229
+ // Session keep-alive interval — refreshes browser cookies every 20h
230
+ let _keepAliveInterval: ReturnType<typeof setInterval> | null = null;
231
+
252
232
  /**
253
233
  * Connect to the OpenClaw managed browser (CDP port 18800).
254
234
  * Singleton: reuses the same connection. Falls back to persistent Chromium for Grok only.
@@ -435,8 +415,86 @@ async function getOrLaunchChatGPTContext(
435
415
  return _chatgptLaunchPromise;
436
416
  }
437
417
 
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[]> {
422
+ const providers: Array<{
423
+ name: string;
424
+ homeUrl: string;
425
+ verifySelector: string;
426
+ loginCmd: string;
427
+ getCtx: () => BrowserContext | null;
428
+ setCtx: (c: BrowserContext | null) => void;
429
+ scanExpiry: (ctx: BrowserContext) => Promise<{ expiresAt: number; loginAt: number; cookieName: string } | null>;
430
+ saveExpiry: (info: { expiresAt: number; loginAt: number; cookieName: string }) => void;
431
+ }> = [
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 },
436
+ ];
437
+
438
+ const needsLogin: string[] = [];
439
+
440
+ for (const p of providers) {
441
+ const ctx = p.getCtx();
442
+ if (!ctx) continue;
443
+ try {
444
+ const page = await ctx.newPage();
445
+ await page.goto(p.homeUrl, { waitUntil: "domcontentloaded", timeout: 15_000 });
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
+ }
455
+ await page.close();
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
+ }
482
+ } catch (err) {
483
+ log(`[cli-bridge:${p.name}] session keep-alive failed: ${(err as Error).message}`);
484
+ }
485
+ // Sequential — avoid spawning multiple pages at once
486
+ await new Promise(r => setTimeout(r, 2000));
487
+ }
488
+
489
+ return needsLogin;
490
+ }
491
+
438
492
  /** Clean up all browser resources — call on plugin teardown */
439
493
  async function cleanupBrowsers(log: (msg: string) => void): Promise<void> {
494
+ if (_keepAliveInterval) {
495
+ clearInterval(_keepAliveInterval);
496
+ _keepAliveInterval = null;
497
+ }
440
498
  if (grokContext) {
441
499
  try { await grokContext.close(); } catch { /* ignore */ }
442
500
  grokContext = null;
@@ -854,7 +912,7 @@ function proxyTestRequest(
854
912
  const plugin = {
855
913
  id: "openclaw-cli-bridge-elvatis",
856
914
  name: "OpenClaw CLI Bridge",
857
- version: "1.6.4",
915
+ version: "1.7.0",
858
916
  description:
859
917
  "Phase 1: openai-codex auth bridge. " +
860
918
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -955,17 +1013,47 @@ const plugin = {
955
1013
  });
956
1014
  const page = await ctx.newPage();
957
1015
  await page.goto(p.homeUrl, { waitUntil: "domcontentloaded", timeout: 20_000 });
958
- await new Promise(r => setTimeout(r, 3000));
959
- const ok = await page.locator(p.verifySelector).isVisible().catch(() => false);
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
+ }
960
1024
  await page.close().catch(() => {});
961
1025
  if (ok) {
962
1026
  p.setCtx(ctx);
963
1027
  ctx.on("close", () => { p.setCtx(null as unknown as BrowserContext); });
964
1028
  api.logger.info(`[cli-bridge:${p.name}] session restored from profile ✅`);
965
1029
  } else {
966
- await ctx.close().catch(() => {});
967
- api.logger.info(`[cli-bridge:${p.name}] profile exists but not logged in needs ${p.loginCmd}`);
968
- needsLogin.push(p.loginCmd);
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
+ }
969
1057
  }
970
1058
  } catch (err) {
971
1059
  api.logger.warn(`[cli-bridge:${p.name}] startup restore failed: ${(err as Error).message}`);
@@ -991,6 +1079,28 @@ const plugin = {
991
1079
  }
992
1080
  }
993
1081
  })();
1082
+
1083
+ // Start session keep-alive interval (every 20h)
1084
+ if (!_keepAliveInterval) {
1085
+ _keepAliveInterval = setInterval(() => {
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
+ })();
1102
+ }, 72_000_000);
1103
+ }
994
1104
  }
995
1105
 
996
1106
  // ── Phase 1: openai-codex auth bridge ─────────────────────────────────────
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "1.6.4",
4
+ "version": "1.7.0",
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.6.4",
3
+ "version": "1.7.0",
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
+ }
@@ -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
+ });
@@ -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
+ });