@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 +10 -1
- package/SKILL.md +1 -1
- package/index.ts +149 -39
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/expiry-helpers.ts +52 -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,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
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> {
|
|
@@ -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.
|
|
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,
|
|
959
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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 ─────────────────────────────────────
|
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.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.
|
|
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
|
+
});
|
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
|
+
});
|