@elvatis_com/openclaw-cli-bridge-elvatis 1.8.9 → 1.9.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/CONTRIBUTING.md +27 -4
- package/README.md +34 -2
- package/SKILL.md +1 -1
- package/eslint.config.js +26 -0
- package/index.ts +239 -33
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -2
- package/src/chrome-check.ts +52 -0
- package/src/cookie-expiry-store.ts +108 -0
- package/src/proxy-server.ts +68 -106
- package/src/status-template.ts +130 -0
package/CONTRIBUTING.md
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
**Kein Publish ohne erfolgreichen `/bridge-status` Test!**
|
|
6
6
|
|
|
7
|
-
### 1. Build
|
|
7
|
+
### 1. Lint + Build
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm run
|
|
11
|
-
# Exit 0 erwartet — TS-Fehler sind ok (--noEmitOnError false), aber Exit != 0 blockiert
|
|
10
|
+
npm run lint # ESLint — 0 errors required, warnings ok
|
|
11
|
+
npm run build # Exit 0 erwartet — TS-Fehler sind ok (--noEmitOnError false), aber Exit != 0 blockiert
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
### 2. Gateway neu starten
|
|
@@ -67,10 +67,12 @@ grep -rn "X\.Y\.Z\|version" \
|
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
Typische Stellen:
|
|
70
|
-
- `package.json` → `"version": "..."`
|
|
70
|
+
- `package.json` → `"version": "..."` (Hauptquelle — `index.ts` liest automatisch daraus)
|
|
71
71
|
- `openclaw.plugin.json` → `"version": "..."`
|
|
72
72
|
- `README.md` → `**Current version:** ...` (falls vorhanden)
|
|
73
73
|
|
|
74
|
+
> **Seit v1.9.0:** `index.ts` liest die Version automatisch aus `package.json` — kein manuelles Sync mehr nötig für die Runtime-Version.
|
|
75
|
+
|
|
74
76
|
---
|
|
75
77
|
|
|
76
78
|
## Breaking Changes
|
|
@@ -91,3 +93,24 @@ Die folgenden TS-Fehler sind bekannt und ignorierbar (kein Runtime-Problem):
|
|
|
91
93
|
- `TS7006: Parameter implicitly has 'any' type` — minor, kein Effekt
|
|
92
94
|
|
|
93
95
|
Build läuft mit `--noEmitOnError false` durch. `npm run build` → Exit 0 ist das Kriterium, nicht null TS-Fehler.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Cookie Expiry Store (seit v1.9.0)
|
|
100
|
+
|
|
101
|
+
Cookie-Expiry-Daten werden jetzt in **einer** Datei gespeichert:
|
|
102
|
+
- `~/.openclaw/cookie-expiry.json` — enthält alle 4 Provider (grok, gemini, claude, chatgpt)
|
|
103
|
+
|
|
104
|
+
Legacy-Dateien (`grok-cookie-expiry.json`, `gemini-cookie-expiry.json`, etc.) werden beim ersten Start automatisch migriert und gelöscht.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Model Fallback Chain (seit v1.9.0)
|
|
109
|
+
|
|
110
|
+
Wenn ein CLI-Modell fehlschlägt (Timeout, Fehler), wird automatisch ein leichteres Modell versucht:
|
|
111
|
+
- `gemini-2.5-pro` → `gemini-2.5-flash`
|
|
112
|
+
- `gemini-3-pro-preview` → `gemini-3-flash-preview`
|
|
113
|
+
- `claude-opus-4-6` → `claude-sonnet-4-6`
|
|
114
|
+
- `claude-sonnet-4-6` → `claude-haiku-4-5`
|
|
115
|
+
|
|
116
|
+
Die Response enthält das tatsächlich verwendete Modell im `model`-Feld.
|
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.9.1`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -307,6 +307,18 @@ Slash commands (requireAuth=false, gateway commands.allowFrom is the auth layer)
|
|
|
307
307
|
/cli-back → reads state file, restores previous model, clears state
|
|
308
308
|
/cli-test → HTTP POST → localhost:31337, no global model change
|
|
309
309
|
/cli-list → formatted table of all registered models
|
|
310
|
+
|
|
311
|
+
Proxy endpoints:
|
|
312
|
+
/health → simple {"status":"ok"}
|
|
313
|
+
/healthz → detailed JSON (version, uptime, provider status, model count)
|
|
314
|
+
/status → HTML dashboard (auto-refreshes every 30s)
|
|
315
|
+
/v1/models → OpenAI-compatible model list
|
|
316
|
+
|
|
317
|
+
Model fallback (v1.9.0):
|
|
318
|
+
cli-gemini/gemini-2.5-pro → cli-gemini/gemini-2.5-flash
|
|
319
|
+
cli-gemini/gemini-3-pro-preview → cli-gemini/gemini-3-flash-preview
|
|
320
|
+
cli-claude/claude-opus-4-6 → cli-claude/claude-sonnet-4-6
|
|
321
|
+
cli-claude/claude-sonnet-4-6 → cli-claude/claude-haiku-4-5
|
|
310
322
|
```
|
|
311
323
|
|
|
312
324
|
---
|
|
@@ -334,14 +346,34 @@ Slash commands (requireAuth=false, gateway commands.allowFrom is the auth layer)
|
|
|
334
346
|
## Development
|
|
335
347
|
|
|
336
348
|
```bash
|
|
349
|
+
npm run lint # eslint (TypeScript-aware)
|
|
337
350
|
npm run typecheck # tsc --noEmit
|
|
338
|
-
npm test # vitest run (
|
|
351
|
+
npm test # vitest run (121 tests)
|
|
352
|
+
npm run ci # lint + typecheck + test
|
|
339
353
|
```
|
|
340
354
|
|
|
341
355
|
---
|
|
342
356
|
|
|
343
357
|
## Changelog
|
|
344
358
|
|
|
359
|
+
### v1.9.1
|
|
360
|
+
- **feat:** Full slash command mapping on status page — all models now show their /cli-* command
|
|
361
|
+
- **fix:** Register missing slash commands: /cli-codex-spark, /cli-codex52, /cli-codex-mini, /cli-gemini3-flash (documented but never registered)
|
|
362
|
+
- **feat:** /cli-help command — full reference with CLI/Codex/Web/BitNet sections, expiry info, quick examples, dashboard links
|
|
363
|
+
- **feat:** /cli-list now references /cli-help and shows dashboard URL
|
|
364
|
+
|
|
365
|
+
/
|
|
366
|
+
### v1.9.0
|
|
367
|
+
- **feat:** Auto-source version from `package.json` — eliminates hardcoded version string sync issues (was stale across v1.8.2–v1.8.8)
|
|
368
|
+
- **feat:** ESLint config (`eslint.config.js`) — TypeScript-aware linting with `npm run lint`, integrated into CI pipeline
|
|
369
|
+
- **refactor:** Extract `/status` HTML dashboard into `src/status-template.ts` — easier to maintain and test
|
|
370
|
+
- **feat:** System Chrome startup check — logs a clear warning with install instructions if `google-chrome` / `chromium` is not found (required for stealth mode browser launches)
|
|
371
|
+
- **refactor:** Consolidate 4 cookie expiry files (`grok-`, `gemini-`, `claude-`, `chatgpt-cookie-expiry.json`) into single `~/.openclaw/cookie-expiry.json`. Legacy files are auto-migrated on first load.
|
|
372
|
+
- **fix:** Explicit `grokBrowser` cleanup on plugin unload — prevents orphaned Chromium processes on hot-reload. Launch promises (`_geminiLaunchPromise` etc.) are also cleared.
|
|
373
|
+
- **feat:** Model fallback chain — when a CLI model fails (timeout, error), automatically retries with a lighter variant: `gemini-2.5-pro` → `flash`, `claude-opus` → `sonnet` → `haiku`. Response includes the actual model used.
|
|
374
|
+
- **feat:** `/healthz` JSON endpoint — returns version, uptime, provider session status, and model count. Useful for monitoring scripts and health dashboards.
|
|
375
|
+
- **feat:** Status page now shows slash commands (`/cli-codex`, `/cli-sonnet`, etc.) next to model IDs
|
|
376
|
+
|
|
345
377
|
### v1.8.7
|
|
346
378
|
- **fix:** Add missing cli-gemini/gemini-3-flash-preview and all Codex models to status page model list
|
|
347
379
|
- **fix:** Remove duplicate cli-gemini/gemini-3-pro alias
|
package/SKILL.md
CHANGED
package/eslint.config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import eslint from "@eslint/js";
|
|
2
|
+
import tseslint from "typescript-eslint";
|
|
3
|
+
|
|
4
|
+
export default tseslint.config(
|
|
5
|
+
eslint.configs.recommended,
|
|
6
|
+
...tseslint.configs.recommended,
|
|
7
|
+
{
|
|
8
|
+
rules: {
|
|
9
|
+
// Relaxed — this is a plugin, not a library; pragmatism over purity
|
|
10
|
+
"@typescript-eslint/no-explicit-any": "warn",
|
|
11
|
+
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
|
12
|
+
"@typescript-eslint/no-require-imports": "off", // pre-existing dynamic require in index.ts
|
|
13
|
+
"no-console": "off",
|
|
14
|
+
"prefer-const": "warn", // pre-existing let-without-reassign patterns
|
|
15
|
+
"no-var": "error",
|
|
16
|
+
eqeqeq: ["error", "always"],
|
|
17
|
+
// Pre-existing patterns — suppress until dedicated cleanup
|
|
18
|
+
"no-useless-assignment": "off",
|
|
19
|
+
"no-misleading-character-class": "off",
|
|
20
|
+
"preserve-caught-error": "off",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
ignores: ["dist/", "node_modules/", "test/"],
|
|
25
|
+
},
|
|
26
|
+
);
|
package/index.ts
CHANGED
|
@@ -28,8 +28,20 @@
|
|
|
28
28
|
|
|
29
29
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
30
30
|
import { homedir } from "node:os";
|
|
31
|
-
import { join } from "node:path";
|
|
31
|
+
import { join, dirname } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
32
33
|
import http from "node:http";
|
|
34
|
+
|
|
35
|
+
// ── Auto-source version from package.json (eliminates hardcoded version sync) ──
|
|
36
|
+
const __dirname_local = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const PACKAGE_VERSION: string = (() => {
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(readFileSync(join(__dirname_local, "package.json"), "utf-8")) as { version: string };
|
|
40
|
+
return pkg.version;
|
|
41
|
+
} catch {
|
|
42
|
+
return "0.0.0"; // fallback — should never happen in normal operation
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
33
45
|
import type {
|
|
34
46
|
OpenClawPluginApi,
|
|
35
47
|
ProviderAuthContext,
|
|
@@ -63,6 +75,8 @@ import {
|
|
|
63
75
|
formatChatGPTExpiry,
|
|
64
76
|
} from "./src/expiry-helpers.js";
|
|
65
77
|
import type { BrowserContext, Browser } from "playwright";
|
|
78
|
+
import { checkSystemChrome } from "./src/chrome-check.js";
|
|
79
|
+
import { saveProviderExpiry, loadProviderExpiry, migrateLegacyFiles } from "./src/cookie-expiry-store.js";
|
|
66
80
|
|
|
67
81
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
68
82
|
// Types derived from SDK (not re-exported by the package)
|
|
@@ -108,23 +122,22 @@ const STEALTH_IGNORE_DEFAULTS = ["--enable-automation"] as const;
|
|
|
108
122
|
|
|
109
123
|
// ── Gemini web-session state ──────────────────────────────────────────────────
|
|
110
124
|
let geminiContext: BrowserContext | null = null;
|
|
111
|
-
const GEMINI_EXPIRY_FILE = join(homedir(), ".openclaw", "gemini-cookie-expiry.json");
|
|
112
125
|
|
|
113
126
|
// ── Claude web-session state ─────────────────────────────────────────────────
|
|
114
127
|
let claudeWebContext: BrowserContext | null = null;
|
|
115
|
-
const CLAUDE_EXPIRY_FILE = join(homedir(), ".openclaw", "claude-cookie-expiry.json");
|
|
116
128
|
|
|
117
129
|
// ── ChatGPT web-session state ────────────────────────────────────────────────
|
|
118
130
|
let chatgptContext: BrowserContext | null = null;
|
|
119
|
-
const CHATGPT_EXPIRY_FILE = join(homedir(), ".openclaw", "chatgpt-cookie-expiry.json");
|
|
120
131
|
|
|
132
|
+
// ── Consolidated cookie expiry (delegates to cookie-expiry-store.ts) ─────────
|
|
133
|
+
// Legacy per-provider files are auto-migrated on first load.
|
|
121
134
|
interface GeminiExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
|
|
122
135
|
|
|
123
136
|
function saveGeminiExpiry(info: GeminiExpiryInfo): void {
|
|
124
|
-
|
|
137
|
+
saveProviderExpiry("gemini", info);
|
|
125
138
|
}
|
|
126
139
|
function loadGeminiExpiry(): GeminiExpiryInfo | null {
|
|
127
|
-
|
|
140
|
+
return loadProviderExpiry("gemini");
|
|
128
141
|
}
|
|
129
142
|
// formatGeminiExpiry imported from ./src/expiry-helpers.js
|
|
130
143
|
async function scanGeminiCookieExpiry(ctx: BrowserContext): Promise<GeminiExpiryInfo | null> {
|
|
@@ -141,10 +154,10 @@ async function scanGeminiCookieExpiry(ctx: BrowserContext): Promise<GeminiExpiry
|
|
|
141
154
|
// ── Claude cookie expiry helpers ─────────────────────────────────────────────
|
|
142
155
|
interface ClaudeExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
|
|
143
156
|
function saveClaudeExpiry(info: ClaudeExpiryInfo): void {
|
|
144
|
-
|
|
157
|
+
saveProviderExpiry("claude", info);
|
|
145
158
|
}
|
|
146
159
|
function loadClaudeExpiry(): ClaudeExpiryInfo | null {
|
|
147
|
-
|
|
160
|
+
return loadProviderExpiry("claude");
|
|
148
161
|
}
|
|
149
162
|
// formatClaudeExpiry imported from ./src/expiry-helpers.js
|
|
150
163
|
async function scanClaudeCookieExpiry(ctx: BrowserContext): Promise<ClaudeExpiryInfo | null> {
|
|
@@ -163,10 +176,10 @@ async function scanClaudeCookieExpiry(ctx: BrowserContext): Promise<ClaudeExpiry
|
|
|
163
176
|
// ── ChatGPT cookie expiry helpers ────────────────────────────────────────────
|
|
164
177
|
interface ChatGPTExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
|
|
165
178
|
function saveChatGPTExpiry(info: ChatGPTExpiryInfo): void {
|
|
166
|
-
|
|
179
|
+
saveProviderExpiry("chatgpt", info);
|
|
167
180
|
}
|
|
168
181
|
function loadChatGPTExpiry(): ChatGPTExpiryInfo | null {
|
|
169
|
-
|
|
182
|
+
return loadProviderExpiry("chatgpt");
|
|
170
183
|
}
|
|
171
184
|
// formatChatGPTExpiry imported from ./src/expiry-helpers.js
|
|
172
185
|
async function scanChatGPTCookieExpiry(ctx: BrowserContext): Promise<ChatGPTExpiryInfo | null> {
|
|
@@ -184,9 +197,6 @@ async function scanChatGPTCookieExpiry(ctx: BrowserContext): Promise<ChatGPTExpi
|
|
|
184
197
|
|
|
185
198
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
186
199
|
|
|
187
|
-
// Cookie expiry tracking file — written on /grok-login, read on startup
|
|
188
|
-
const GROK_EXPIRY_FILE = join(homedir(), ".openclaw", "grok-cookie-expiry.json");
|
|
189
|
-
|
|
190
200
|
interface GrokExpiryInfo {
|
|
191
201
|
expiresAt: number; // epoch ms — earliest auth cookie expiry
|
|
192
202
|
loginAt: number; // epoch ms — when /grok-login was last run
|
|
@@ -194,16 +204,11 @@ interface GrokExpiryInfo {
|
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
function saveGrokExpiry(info: GrokExpiryInfo): void {
|
|
197
|
-
|
|
198
|
-
writeFileSync(GROK_EXPIRY_FILE, JSON.stringify(info, null, 2));
|
|
199
|
-
} catch { /* ignore */ }
|
|
207
|
+
saveProviderExpiry("grok", info);
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
function loadGrokExpiry(): GrokExpiryInfo | null {
|
|
203
|
-
|
|
204
|
-
const raw = readFileSync(GROK_EXPIRY_FILE, "utf-8");
|
|
205
|
-
return JSON.parse(raw) as GrokExpiryInfo;
|
|
206
|
-
} catch { return null; }
|
|
211
|
+
return loadProviderExpiry("grok");
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
// formatExpiryInfo imported from ./src/expiry-helpers.js
|
|
@@ -501,10 +506,16 @@ async function cleanupBrowsers(log: (msg: string) => void): Promise<void> {
|
|
|
501
506
|
clearInterval(_keepAliveInterval);
|
|
502
507
|
_keepAliveInterval = null;
|
|
503
508
|
}
|
|
509
|
+
// Close all browser contexts first, then the browser instances.
|
|
510
|
+
// Closing contexts before browsers prevents orphaned Chromium processes.
|
|
504
511
|
if (grokContext) {
|
|
505
512
|
try { await grokContext.close(); } catch { /* ignore */ }
|
|
506
513
|
grokContext = null;
|
|
507
514
|
}
|
|
515
|
+
if (grokBrowser) {
|
|
516
|
+
try { await grokBrowser.close(); } catch { /* ignore */ }
|
|
517
|
+
grokBrowser = null;
|
|
518
|
+
}
|
|
508
519
|
if (geminiContext) {
|
|
509
520
|
try { await geminiContext.close(); } catch { /* ignore */ }
|
|
510
521
|
geminiContext = null;
|
|
@@ -521,6 +532,11 @@ async function cleanupBrowsers(log: (msg: string) => void): Promise<void> {
|
|
|
521
532
|
try { await _cdpBrowser.close(); } catch { /* ignore */ }
|
|
522
533
|
_cdpBrowser = null;
|
|
523
534
|
}
|
|
535
|
+
// Clear any pending launch promises to prevent stale references
|
|
536
|
+
_cdpBrowserLaunchPromise = null;
|
|
537
|
+
_geminiLaunchPromise = null;
|
|
538
|
+
_claudeLaunchPromise = null;
|
|
539
|
+
_chatgptLaunchPromise = null;
|
|
524
540
|
log("[cli-bridge] browser resources cleaned up");
|
|
525
541
|
}
|
|
526
542
|
|
|
@@ -736,9 +752,13 @@ const CLI_MODEL_COMMANDS = [
|
|
|
736
752
|
{ name: "cli-gemini", model: "vllm/cli-gemini/gemini-2.5-pro", description: "Gemini 2.5 Pro (Gemini CLI)", label: "Gemini 2.5 Pro (CLI)" },
|
|
737
753
|
{ name: "cli-gemini-flash", model: "vllm/cli-gemini/gemini-2.5-flash", description: "Gemini 2.5 Flash (Gemini CLI)", label: "Gemini 2.5 Flash (CLI)" },
|
|
738
754
|
{ name: "cli-gemini3", model: "vllm/cli-gemini/gemini-3-pro-preview", description: "Gemini 3 Pro (Gemini CLI)", label: "Gemini 3 Pro (CLI)" },
|
|
755
|
+
{ name: "cli-gemini3-flash", model: "vllm/cli-gemini/gemini-3-flash-preview", description: "Gemini 3 Flash (Gemini CLI)", label: "Gemini 3 Flash (CLI)" },
|
|
739
756
|
// ── Codex CLI (openai-codex provider, OAuth auth) ────────────────────────────
|
|
740
757
|
{ name: "cli-codex", model: "openai-codex/gpt-5.3-codex", description: "GPT-5.3 Codex (Codex CLI auth)", label: "GPT-5.3 Codex" },
|
|
758
|
+
{ name: "cli-codex-spark", model: "openai-codex/gpt-5.3-codex-spark", description: "GPT-5.3 Codex Spark (Codex CLI auth)", label: "GPT-5.3 Codex Spark" },
|
|
759
|
+
{ name: "cli-codex52", model: "openai-codex/gpt-5.2-codex", description: "GPT-5.2 Codex (Codex CLI auth)", label: "GPT-5.2 Codex" },
|
|
741
760
|
{ name: "cli-codex54", model: "openai-codex/gpt-5.4", description: "GPT-5.4 (Codex CLI auth)", label: "GPT-5.4" },
|
|
761
|
+
{ name: "cli-codex-mini", model: "openai-codex/gpt-5.1-codex-mini", description: "GPT-5.1 Codex Mini (Codex CLI auth)", label: "GPT-5.1 Codex Mini" },
|
|
742
762
|
// ── BitNet local inference (via local proxy → llama-server) ─────────────────
|
|
743
763
|
{ name: "cli-bitnet", model: "vllm/local-bitnet/bitnet-2b", description: "BitNet b1.58 2B (local CPU, no API key)", label: "BitNet 2B (local)" },
|
|
744
764
|
] as const;
|
|
@@ -939,7 +959,7 @@ function proxyTestRequest(
|
|
|
939
959
|
const plugin = {
|
|
940
960
|
id: "openclaw-cli-bridge-elvatis",
|
|
941
961
|
name: "OpenClaw CLI Bridge",
|
|
942
|
-
version:
|
|
962
|
+
version: PACKAGE_VERSION,
|
|
943
963
|
description:
|
|
944
964
|
"Phase 1: openai-codex auth bridge. " +
|
|
945
965
|
"Phase 2: HTTP proxy for gemini/claude CLIs. " +
|
|
@@ -956,6 +976,43 @@ const plugin = {
|
|
|
956
976
|
const codexAuthPath = cfg.codexAuthPath ?? DEFAULT_CODEX_AUTH_PATH;
|
|
957
977
|
const grokSessionPath = cfg.grokSessionPath ?? DEFAULT_SESSION_PATH;
|
|
958
978
|
|
|
979
|
+
// ── Model → slash command mapping for status page ──────────────────────────
|
|
980
|
+
const modelCommands: Record<string, string> = {};
|
|
981
|
+
for (const entry of CLI_MODEL_COMMANDS) {
|
|
982
|
+
// Strip vllm/ prefix to match CLI_MODELS IDs
|
|
983
|
+
const modelId = entry.model.replace(/^vllm\//, "");
|
|
984
|
+
modelCommands[modelId] = `/${entry.name}`;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ── Default model fallback chain ──────────────────────────────────────────
|
|
988
|
+
// When a primary model fails (timeout, error), retry once with a lighter variant.
|
|
989
|
+
const modelFallbacks: Record<string, string> = {
|
|
990
|
+
"cli-gemini/gemini-2.5-pro": "cli-gemini/gemini-2.5-flash",
|
|
991
|
+
"cli-gemini/gemini-3-pro-preview": "cli-gemini/gemini-3-flash-preview",
|
|
992
|
+
"cli-claude/claude-opus-4-6": "cli-claude/claude-sonnet-4-6",
|
|
993
|
+
"cli-claude/claude-sonnet-4-6": "cli-claude/claude-haiku-4-5",
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
// ── Migrate legacy per-provider cookie expiry files to consolidated store ─
|
|
997
|
+
const migration = migrateLegacyFiles();
|
|
998
|
+
if (migration.migrated.length > 0) {
|
|
999
|
+
api.logger.info(`[cli-bridge] migrated cookie expiry data: ${migration.migrated.join(", ")} → cookie-expiry.json`);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ── Chrome availability check ────────────────────────────────────────────
|
|
1003
|
+
// Stealth mode uses channel: "chrome" (real system Chrome). If it's missing,
|
|
1004
|
+
// browser launches will fail or Cloudflare will block the bundled Chromium.
|
|
1005
|
+
const chromeCheck = checkSystemChrome();
|
|
1006
|
+
if (chromeCheck.available) {
|
|
1007
|
+
api.logger.info(`[cli-bridge] system Chrome found: ${chromeCheck.version ?? chromeCheck.path}`);
|
|
1008
|
+
} else {
|
|
1009
|
+
api.logger.warn(
|
|
1010
|
+
`[cli-bridge] ⚠ system Chrome not found! Web browser providers (/grok-login, /gemini-login, etc.) ` +
|
|
1011
|
+
`require Google Chrome or Chromium installed system-wide. ` +
|
|
1012
|
+
`Install with: sudo apt install google-chrome-stable (or chromium-browser)`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
959
1016
|
// ── Session restore: only on first plugin load (not on hot-reloads) ──────
|
|
960
1017
|
// The gateway polls every ~60s via openclaw status, which triggers a hot-reload
|
|
961
1018
|
// (SIGUSR1 + hybrid mode). Module-level contexts (grokContext etc.) survive
|
|
@@ -975,8 +1032,8 @@ const plugin = {
|
|
|
975
1032
|
|
|
976
1033
|
const profileProviders: Array<{
|
|
977
1034
|
name: string;
|
|
1035
|
+
providerKey: import("./src/cookie-expiry-store.js").ProviderName;
|
|
978
1036
|
profileDir: string;
|
|
979
|
-
cookieFile: string;
|
|
980
1037
|
verifySelector: string;
|
|
981
1038
|
homeUrl: string;
|
|
982
1039
|
loginCmd: string;
|
|
@@ -985,8 +1042,8 @@ const plugin = {
|
|
|
985
1042
|
}> = [
|
|
986
1043
|
{
|
|
987
1044
|
name: "grok",
|
|
1045
|
+
providerKey: "grok",
|
|
988
1046
|
profileDir: GROK_PROFILE_DIR,
|
|
989
|
-
cookieFile: GROK_EXPIRY_FILE,
|
|
990
1047
|
verifySelector: "textarea",
|
|
991
1048
|
homeUrl: "https://grok.com",
|
|
992
1049
|
loginCmd: "/grok-login",
|
|
@@ -995,8 +1052,8 @@ const plugin = {
|
|
|
995
1052
|
},
|
|
996
1053
|
{
|
|
997
1054
|
name: "gemini",
|
|
1055
|
+
providerKey: "gemini",
|
|
998
1056
|
profileDir: GEMINI_PROFILE_DIR,
|
|
999
|
-
cookieFile: join(homedir(), ".openclaw", "gemini-cookie-expiry.json"),
|
|
1000
1057
|
verifySelector: ".ql-editor",
|
|
1001
1058
|
homeUrl: "https://gemini.google.com/app",
|
|
1002
1059
|
loginCmd: "/gemini-login",
|
|
@@ -1005,8 +1062,8 @@ const plugin = {
|
|
|
1005
1062
|
},
|
|
1006
1063
|
{
|
|
1007
1064
|
name: "claude-web",
|
|
1065
|
+
providerKey: "claude",
|
|
1008
1066
|
profileDir: CLAUDE_PROFILE_DIR,
|
|
1009
|
-
cookieFile: CLAUDE_EXPIRY_FILE,
|
|
1010
1067
|
verifySelector: ".ProseMirror",
|
|
1011
1068
|
homeUrl: "https://claude.ai/new",
|
|
1012
1069
|
loginCmd: "/claude-login",
|
|
@@ -1015,8 +1072,8 @@ const plugin = {
|
|
|
1015
1072
|
},
|
|
1016
1073
|
{
|
|
1017
1074
|
name: "chatgpt",
|
|
1075
|
+
providerKey: "chatgpt",
|
|
1018
1076
|
profileDir: CHATGPT_PROFILE_DIR,
|
|
1019
|
-
cookieFile: CHATGPT_EXPIRY_FILE,
|
|
1020
1077
|
verifySelector: "#prompt-textarea",
|
|
1021
1078
|
homeUrl: "https://chatgpt.com",
|
|
1022
1079
|
loginCmd: "/chatgpt-login",
|
|
@@ -1026,22 +1083,22 @@ const plugin = {
|
|
|
1026
1083
|
];
|
|
1027
1084
|
|
|
1028
1085
|
for (const p of profileProviders) {
|
|
1029
|
-
|
|
1086
|
+
const storedExpiry = loadProviderExpiry(p.providerKey);
|
|
1087
|
+
if (!existsSync(p.profileDir) && !storedExpiry) {
|
|
1030
1088
|
api.logger.info(`[cli-bridge:${p.name}] no saved profile — skipping startup restore`);
|
|
1031
1089
|
continue;
|
|
1032
1090
|
}
|
|
1033
1091
|
if (p.getCtx()) continue; // already connected
|
|
1034
1092
|
|
|
1035
1093
|
// ── Cookie-first check ────────────────────────────────────────────
|
|
1036
|
-
// If
|
|
1094
|
+
// If cookie expiry data exists and is still valid (>1h left),
|
|
1037
1095
|
// launch the persistent context immediately without a browser-based
|
|
1038
1096
|
// selector check. Selector checks are fragile (slow pages, DOM changes).
|
|
1039
1097
|
// The keep-alive (20h) will verify the session properly later.
|
|
1040
1098
|
let cookiesValid = false;
|
|
1041
|
-
if (
|
|
1099
|
+
if (storedExpiry) {
|
|
1042
1100
|
try {
|
|
1043
|
-
const
|
|
1044
|
-
const msLeft = expInfo.expiresAt - Date.now();
|
|
1101
|
+
const msLeft = storedExpiry.expiresAt - Date.now();
|
|
1045
1102
|
if (msLeft > 3_600_000) { // >1h remaining
|
|
1046
1103
|
cookiesValid = true;
|
|
1047
1104
|
api.logger.info(`[cli-bridge:${p.name}] cookie valid (${Math.floor(msLeft / 86_400_000)}d left) — restoring context without browser check`);
|
|
@@ -1274,6 +1331,8 @@ const plugin = {
|
|
|
1274
1331
|
return chatgptContext;
|
|
1275
1332
|
},
|
|
1276
1333
|
version: plugin.version,
|
|
1334
|
+
modelCommands,
|
|
1335
|
+
modelFallbacks,
|
|
1277
1336
|
getExpiryInfo: () => ({
|
|
1278
1337
|
grok: (() => { const e = loadGrokExpiry(); return e ? formatExpiryInfo(e) : null; })(),
|
|
1279
1338
|
gemini: (() => { const e = loadGeminiExpiry(); return e ? formatGeminiExpiry(e) : null; })(),
|
|
@@ -1309,7 +1368,7 @@ const plugin = {
|
|
|
1309
1368
|
// One final attempt
|
|
1310
1369
|
try {
|
|
1311
1370
|
const server = await startProxyServer({
|
|
1312
|
-
port, apiKey, timeoutMs,
|
|
1371
|
+
port, apiKey, timeoutMs, modelCommands, modelFallbacks,
|
|
1313
1372
|
log: (msg) => api.logger.info(msg),
|
|
1314
1373
|
warn: (msg) => api.logger.warn(msg),
|
|
1315
1374
|
getGrokContext: () => grokContext,
|
|
@@ -1591,12 +1650,158 @@ const plugin = {
|
|
|
1591
1650
|
lines.push(" /cli-back Restore previous model + clear staged");
|
|
1592
1651
|
lines.push(" /cli-test [model] Health check (no model switch)");
|
|
1593
1652
|
lines.push(" /cli-list This overview");
|
|
1653
|
+
lines.push(" /cli-help Connected providers + examples + dashboard link");
|
|
1594
1654
|
lines.push("");
|
|
1595
1655
|
lines.push("*Switching safely:*");
|
|
1596
1656
|
lines.push(" /cli-sonnet → stages switch (safe, apply later)");
|
|
1597
1657
|
lines.push(" /cli-sonnet --now → immediate switch (only between sessions!)");
|
|
1598
1658
|
lines.push("");
|
|
1599
|
-
lines.push(`Proxy: \`127.0.0.1:${port}
|
|
1659
|
+
lines.push(`Proxy: \`127.0.0.1:${port}\` · Dashboard: http://127.0.0.1:${port}/status`);
|
|
1660
|
+
|
|
1661
|
+
return { text: lines.join("\n") };
|
|
1662
|
+
},
|
|
1663
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
1664
|
+
|
|
1665
|
+
// ── Phase 3e: /cli-help — context-aware help with connected providers ─────
|
|
1666
|
+
api.registerCommand({
|
|
1667
|
+
name: "cli-help",
|
|
1668
|
+
description: "Show available models based on currently connected providers, with example commands and status page link.",
|
|
1669
|
+
requireAuth: false,
|
|
1670
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
1671
|
+
const lines: string[] = [`🌉 *CLI Bridge Help* — v${plugin.version}`, ""];
|
|
1672
|
+
|
|
1673
|
+
// ── CLI models (always available — spawns local CLI) ────────────────
|
|
1674
|
+
lines.push("*CLI Models* (always available — uses locally installed CLIs)");
|
|
1675
|
+
lines.push("");
|
|
1676
|
+
const cliGroups: Record<string, typeof CLI_MODEL_COMMANDS[number][]> = {
|
|
1677
|
+
"Claude Code": [],
|
|
1678
|
+
"Gemini": [],
|
|
1679
|
+
};
|
|
1680
|
+
for (const c of CLI_MODEL_COMMANDS) {
|
|
1681
|
+
if (c.model.startsWith("vllm/cli-claude/")) cliGroups["Claude Code"].push(c);
|
|
1682
|
+
else if (c.model.startsWith("vllm/cli-gemini/")) cliGroups["Gemini"].push(c);
|
|
1683
|
+
}
|
|
1684
|
+
for (const [group, entries] of Object.entries(cliGroups)) {
|
|
1685
|
+
if (entries.length === 0) continue;
|
|
1686
|
+
lines.push(` *${group}*`);
|
|
1687
|
+
for (const c of entries) {
|
|
1688
|
+
const modelShort = c.model.replace(/^vllm\/cli-(claude|gemini)\//, "");
|
|
1689
|
+
lines.push(` \`/${c.name}\` → ${modelShort}`);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
lines.push("");
|
|
1693
|
+
|
|
1694
|
+
// ── Codex models (OAuth — always registered) ────────────────────────
|
|
1695
|
+
const codexEntries = CLI_MODEL_COMMANDS.filter(c => c.model.startsWith("openai-codex/"));
|
|
1696
|
+
if (codexEntries.length > 0) {
|
|
1697
|
+
lines.push("*Codex* (OAuth — direct API, no proxy)");
|
|
1698
|
+
for (const c of codexEntries) {
|
|
1699
|
+
const modelShort = c.model.replace(/^openai-codex\//, "");
|
|
1700
|
+
lines.push(` \`/${c.name}\` → ${modelShort}`);
|
|
1701
|
+
}
|
|
1702
|
+
lines.push("");
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// ── Web session models (only if connected) ──────────────────────────
|
|
1706
|
+
const webProviders: Array<{
|
|
1707
|
+
name: string;
|
|
1708
|
+
ctx: BrowserContext | null;
|
|
1709
|
+
models: string[];
|
|
1710
|
+
cmds: string[];
|
|
1711
|
+
loginCmd: string;
|
|
1712
|
+
expiry: string | null;
|
|
1713
|
+
}> = [
|
|
1714
|
+
{
|
|
1715
|
+
name: "Grok",
|
|
1716
|
+
ctx: grokContext,
|
|
1717
|
+
models: ["web-grok/grok-3", "web-grok/grok-3-fast", "web-grok/grok-3-mini", "web-grok/grok-3-mini-fast"],
|
|
1718
|
+
cmds: ["openclaw models set vllm/web-grok/grok-3"],
|
|
1719
|
+
loginCmd: "/grok-login",
|
|
1720
|
+
expiry: (() => { const e = loadGrokExpiry(); return e ? formatExpiryInfo(e) : null; })(),
|
|
1721
|
+
},
|
|
1722
|
+
{
|
|
1723
|
+
name: "Gemini (web)",
|
|
1724
|
+
ctx: geminiContext,
|
|
1725
|
+
models: ["web-gemini/gemini-2-5-pro", "web-gemini/gemini-2-5-flash", "web-gemini/gemini-3-pro", "web-gemini/gemini-3-flash"],
|
|
1726
|
+
cmds: ["openclaw models set vllm/web-gemini/gemini-2-5-pro"],
|
|
1727
|
+
loginCmd: "/gemini-login",
|
|
1728
|
+
expiry: (() => { const e = loadGeminiExpiry(); return e ? formatGeminiExpiry(e) : null; })(),
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
name: "Claude.ai (web)",
|
|
1732
|
+
ctx: claudeWebContext,
|
|
1733
|
+
models: ["web-claude/*"],
|
|
1734
|
+
cmds: [],
|
|
1735
|
+
loginCmd: "/claude-login",
|
|
1736
|
+
expiry: (() => { const e = loadClaudeExpiry(); return e ? formatClaudeExpiry(e) : null; })(),
|
|
1737
|
+
},
|
|
1738
|
+
{
|
|
1739
|
+
name: "ChatGPT (web)",
|
|
1740
|
+
ctx: chatgptContext,
|
|
1741
|
+
models: ["web-chatgpt/*"],
|
|
1742
|
+
cmds: [],
|
|
1743
|
+
loginCmd: "/chatgpt-login",
|
|
1744
|
+
expiry: (() => { const e = loadChatGPTExpiry(); return e ? formatChatGPTExpiry(e) : null; })(),
|
|
1745
|
+
},
|
|
1746
|
+
];
|
|
1747
|
+
|
|
1748
|
+
const connectedWeb = webProviders.filter(p => p.ctx !== null);
|
|
1749
|
+
const disconnectedWeb = webProviders.filter(p => p.ctx === null);
|
|
1750
|
+
|
|
1751
|
+
if (connectedWeb.length > 0) {
|
|
1752
|
+
lines.push("*Web Session Models* (connected)");
|
|
1753
|
+
for (const p of connectedWeb) {
|
|
1754
|
+
const expiryNote = p.expiry ? ` — ${p.expiry}` : "";
|
|
1755
|
+
lines.push(` 🟢 *${p.name}*${expiryNote}`);
|
|
1756
|
+
lines.push(` Models: ${p.models.join(", ")}`);
|
|
1757
|
+
if (p.cmds.length > 0) {
|
|
1758
|
+
lines.push(` Example: \`${p.cmds[0]}\``);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
lines.push("");
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
if (disconnectedWeb.length > 0) {
|
|
1765
|
+
lines.push("*Web Session Models* (not connected)");
|
|
1766
|
+
for (const p of disconnectedWeb) {
|
|
1767
|
+
const expiryNote = p.expiry && !p.expiry.startsWith("⚠️ EXPIRED") ? " (cookies valid, auto-connects on use)" : "";
|
|
1768
|
+
lines.push(` ⚪ *${p.name}*${expiryNote} → run \`${p.loginCmd}\``);
|
|
1769
|
+
}
|
|
1770
|
+
lines.push("");
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// ── BitNet ──────────────────────────────────────────────────────────
|
|
1774
|
+
const bitnetOk = await checkBitNetServer();
|
|
1775
|
+
if (bitnetOk) {
|
|
1776
|
+
lines.push("*Local Inference*");
|
|
1777
|
+
lines.push(" 🟢 *BitNet 2B* — running → \`/cli-bitnet\`");
|
|
1778
|
+
} else {
|
|
1779
|
+
lines.push("*Local Inference*");
|
|
1780
|
+
lines.push(" ⚪ *BitNet 2B* — not running → \`sudo systemctl start bitnet-server\`");
|
|
1781
|
+
}
|
|
1782
|
+
lines.push("");
|
|
1783
|
+
|
|
1784
|
+
// ── Utility commands ────────────────────────────────────────────────
|
|
1785
|
+
lines.push("*Utility Commands*");
|
|
1786
|
+
lines.push(" \`/cli-help\` This help");
|
|
1787
|
+
lines.push(" \`/cli-list\` All models (no status check)");
|
|
1788
|
+
lines.push(" \`/cli-test [model]\` Health check (no model switch)");
|
|
1789
|
+
lines.push(" \`/cli-back\` Restore previous model");
|
|
1790
|
+
lines.push(" \`/cli-apply\` Apply staged model switch");
|
|
1791
|
+
lines.push(" \`/bridge-status\` Full provider diagnostics");
|
|
1792
|
+
lines.push("");
|
|
1793
|
+
|
|
1794
|
+
// ── Quick examples ──────────────────────────────────────────────────
|
|
1795
|
+
lines.push("*Quick Examples*");
|
|
1796
|
+
lines.push(" \`/cli-sonnet\` Switch to Claude Sonnet (staged)");
|
|
1797
|
+
lines.push(" \`/cli-sonnet --now\` Switch immediately");
|
|
1798
|
+
lines.push(" \`/cli-gemini\` Switch to Gemini 2.5 Pro");
|
|
1799
|
+
lines.push(" \`/cli-codex\` Switch to GPT-5.3 Codex");
|
|
1800
|
+
lines.push("");
|
|
1801
|
+
|
|
1802
|
+
// ── Status page link ────────────────────────────────────────────────
|
|
1803
|
+
lines.push(`📊 Dashboard: http://127.0.0.1:${port}/status`);
|
|
1804
|
+
lines.push(`🔍 Health JSON: http://127.0.0.1:${port}/healthz`);
|
|
1600
1805
|
|
|
1601
1806
|
return { text: lines.join("\n") };
|
|
1602
1807
|
},
|
|
@@ -2207,6 +2412,7 @@ const plugin = {
|
|
|
2207
2412
|
"/chatgpt-status",
|
|
2208
2413
|
"/chatgpt-logout",
|
|
2209
2414
|
"/bridge-status",
|
|
2415
|
+
"/cli-help",
|
|
2210
2416
|
];
|
|
2211
2417
|
api.logger.info(`[cli-bridge] registered ${allCommands.length} commands: ${allCommands.join(", ")}`);
|
|
2212
2418
|
},
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"slug": "openclaw-cli-bridge-elvatis",
|
|
4
4
|
"name": "OpenClaw CLI Bridge",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.9.1",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
|
|
8
8
|
"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.9.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": {
|
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
"build": "tsc --noEmitOnError false",
|
|
13
13
|
"typecheck": "tsc -p tsconfig.check.json",
|
|
14
14
|
"test": "vitest run",
|
|
15
|
-
"
|
|
15
|
+
"lint": "eslint .",
|
|
16
|
+
"ci": "npm run lint && npm run typecheck && npm run test"
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
19
|
+
"@eslint/js": "^10.0.1",
|
|
18
20
|
"@types/node": "^25.3.2",
|
|
21
|
+
"eslint": "^10.0.3",
|
|
19
22
|
"typescript": "^5.9.3",
|
|
23
|
+
"typescript-eslint": "^8.57.0",
|
|
20
24
|
"vitest": "^4.0.18"
|
|
21
25
|
},
|
|
22
26
|
"dependencies": {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chrome-check.ts
|
|
3
|
+
*
|
|
4
|
+
* Startup check for system Chrome availability.
|
|
5
|
+
* Playwright's stealth mode uses `channel: "chrome"` which requires a real
|
|
6
|
+
* Chrome/Chromium installation. Without it, browser launches fail silently
|
|
7
|
+
* or Cloudflare flags the bundled Chromium as automation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
const CHROME_CANDIDATES = [
|
|
13
|
+
"google-chrome",
|
|
14
|
+
"google-chrome-stable",
|
|
15
|
+
"chromium-browser",
|
|
16
|
+
"chromium",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export interface ChromeCheckResult {
|
|
20
|
+
available: boolean;
|
|
21
|
+
path: string | null;
|
|
22
|
+
version: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if a system Chrome installation exists.
|
|
27
|
+
* Returns the path and version if found, or null if missing.
|
|
28
|
+
*/
|
|
29
|
+
export function checkSystemChrome(): ChromeCheckResult {
|
|
30
|
+
for (const candidate of CHROME_CANDIDATES) {
|
|
31
|
+
try {
|
|
32
|
+
const whichResult = execFileSync("which", [candidate], {
|
|
33
|
+
encoding: "utf-8",
|
|
34
|
+
timeout: 3000,
|
|
35
|
+
}).trim();
|
|
36
|
+
|
|
37
|
+
if (whichResult) {
|
|
38
|
+
let version: string | null = null;
|
|
39
|
+
try {
|
|
40
|
+
version = execFileSync(candidate, ["--version"], {
|
|
41
|
+
encoding: "utf-8",
|
|
42
|
+
timeout: 3000,
|
|
43
|
+
}).trim();
|
|
44
|
+
} catch { /* version check optional */ }
|
|
45
|
+
return { available: true, path: whichResult, version };
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// candidate not found — try next
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { available: false, path: null, version: null };
|
|
52
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cookie-expiry-store.ts
|
|
3
|
+
*
|
|
4
|
+
* Consolidated cookie expiry tracking for all web providers.
|
|
5
|
+
* Replaces 4 separate JSON files with a single unified store:
|
|
6
|
+
* ~/.openclaw/cookie-expiry.json
|
|
7
|
+
*
|
|
8
|
+
* Migration: on first load, imports data from legacy per-provider files
|
|
9
|
+
* and deletes them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
|
|
16
|
+
export type ProviderName = "grok" | "gemini" | "claude" | "chatgpt";
|
|
17
|
+
|
|
18
|
+
export interface ExpiryInfo {
|
|
19
|
+
expiresAt: number; // epoch ms
|
|
20
|
+
loginAt: number; // epoch ms
|
|
21
|
+
cookieName: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CookieExpiryData {
|
|
25
|
+
grok: ExpiryInfo | null;
|
|
26
|
+
gemini: ExpiryInfo | null;
|
|
27
|
+
claude: ExpiryInfo | null;
|
|
28
|
+
chatgpt: ExpiryInfo | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const STORE_PATH = join(homedir(), ".openclaw", "cookie-expiry.json");
|
|
32
|
+
|
|
33
|
+
// Legacy file paths (for migration)
|
|
34
|
+
const LEGACY_FILES: Record<ProviderName, string> = {
|
|
35
|
+
grok: join(homedir(), ".openclaw", "grok-cookie-expiry.json"),
|
|
36
|
+
gemini: join(homedir(), ".openclaw", "gemini-cookie-expiry.json"),
|
|
37
|
+
claude: join(homedir(), ".openclaw", "claude-cookie-expiry.json"),
|
|
38
|
+
chatgpt: join(homedir(), ".openclaw", "chatgpt-cookie-expiry.json"),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function emptyData(): CookieExpiryData {
|
|
42
|
+
return { grok: null, gemini: null, claude: null, chatgpt: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Migrate legacy per-provider files into the consolidated store.
|
|
47
|
+
* Safe to call multiple times — only imports files that exist.
|
|
48
|
+
*/
|
|
49
|
+
export function migrateLegacyFiles(): { migrated: ProviderName[] } {
|
|
50
|
+
const migrated: ProviderName[] = [];
|
|
51
|
+
let data = loadAll();
|
|
52
|
+
|
|
53
|
+
for (const [provider, legacyPath] of Object.entries(LEGACY_FILES) as [ProviderName, string][]) {
|
|
54
|
+
if (!existsSync(legacyPath)) continue;
|
|
55
|
+
try {
|
|
56
|
+
const legacy = JSON.parse(readFileSync(legacyPath, "utf-8")) as ExpiryInfo;
|
|
57
|
+
// Only import if we don't already have newer data
|
|
58
|
+
if (!data[provider] || legacy.loginAt > data[provider]!.loginAt) {
|
|
59
|
+
data[provider] = legacy;
|
|
60
|
+
migrated.push(provider);
|
|
61
|
+
}
|
|
62
|
+
unlinkSync(legacyPath);
|
|
63
|
+
} catch {
|
|
64
|
+
// Corrupted legacy file — just delete it
|
|
65
|
+
try { unlinkSync(legacyPath); } catch { /* ignore */ }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (migrated.length > 0) {
|
|
70
|
+
writeAll(data);
|
|
71
|
+
}
|
|
72
|
+
return { migrated };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Load the entire expiry store. Returns empty data if file doesn't exist. */
|
|
76
|
+
export function loadAll(): CookieExpiryData {
|
|
77
|
+
try {
|
|
78
|
+
const raw = readFileSync(STORE_PATH, "utf-8");
|
|
79
|
+
const parsed = JSON.parse(raw) as Partial<CookieExpiryData>;
|
|
80
|
+
return { ...emptyData(), ...parsed };
|
|
81
|
+
} catch {
|
|
82
|
+
return emptyData();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Save a single provider's expiry info. Merges with existing data. */
|
|
87
|
+
export function saveProviderExpiry(provider: ProviderName, info: ExpiryInfo): void {
|
|
88
|
+
const data = loadAll();
|
|
89
|
+
data[provider] = info;
|
|
90
|
+
writeAll(data);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Load a single provider's expiry info. */
|
|
94
|
+
export function loadProviderExpiry(provider: ProviderName): ExpiryInfo | null {
|
|
95
|
+
return loadAll()[provider];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Write the full store to disk. */
|
|
99
|
+
function writeAll(data: CookieExpiryData): void {
|
|
100
|
+
try {
|
|
101
|
+
writeFileSync(STORE_PATH, JSON.stringify(data, null, 2));
|
|
102
|
+
} catch { /* ignore write errors */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Get the store file path (for existsSync checks in startup restore). */
|
|
106
|
+
export function getStorePath(): string {
|
|
107
|
+
return STORE_PATH;
|
|
108
|
+
}
|
package/src/proxy-server.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { geminiComplete, geminiCompleteStream, type ChatMessage as GeminiBrowser
|
|
|
17
17
|
import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowserChatMessage } from "./claude-browser.js";
|
|
18
18
|
import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
|
|
19
19
|
import type { BrowserContext } from "playwright";
|
|
20
|
+
import { renderStatusPage, type StatusProvider } from "./status-template.js";
|
|
20
21
|
|
|
21
22
|
export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
|
|
22
23
|
export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
|
|
@@ -71,6 +72,14 @@ export interface ProxyServerOptions {
|
|
|
71
72
|
version?: string;
|
|
72
73
|
/** Returns the BitNet llama-server base URL (default: http://127.0.0.1:8082) */
|
|
73
74
|
getBitNetServerUrl?: () => string;
|
|
75
|
+
/** Maps model ID → slash command name for the status page display */
|
|
76
|
+
modelCommands?: Record<string, string>;
|
|
77
|
+
/**
|
|
78
|
+
* Model fallback chain — maps a model prefix to a fallback model.
|
|
79
|
+
* When a CLI model fails (timeout, error), the request is retried once
|
|
80
|
+
* with the fallback model. Example: "cli-gemini/gemini-2.5-pro" → "cli-gemini/gemini-2.5-flash"
|
|
81
|
+
*/
|
|
82
|
+
modelFallbacks?: Record<string, string>;
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
/** Available CLI bridge models for GET /v1/models */
|
|
@@ -158,121 +167,56 @@ async function handleRequest(
|
|
|
158
167
|
|
|
159
168
|
const url = req.url ?? "/";
|
|
160
169
|
|
|
161
|
-
// Health check
|
|
170
|
+
// Health check (simple)
|
|
162
171
|
if (url === "/health" || url === "/v1/health") {
|
|
163
172
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
164
173
|
res.end(JSON.stringify({ status: "ok", service: "openclaw-cli-bridge" }));
|
|
165
174
|
return;
|
|
166
175
|
}
|
|
167
176
|
|
|
177
|
+
// Health check (detailed JSON — for monitoring scripts)
|
|
178
|
+
if (url === "/healthz" && req.method === "GET") {
|
|
179
|
+
const expiry = opts.getExpiryInfo?.() ?? { grok: null, gemini: null, claude: null, chatgpt: null };
|
|
180
|
+
const sessionStatus = (provider: string, ctxGetter: (() => import("playwright").BrowserContext | null) | undefined, expiryStr: string | null) => {
|
|
181
|
+
const connected = ctxGetter?.() !== null && ctxGetter?.() !== undefined;
|
|
182
|
+
let status: "connected" | "logged_in" | "expired" | "not_configured" = "not_configured";
|
|
183
|
+
if (connected) status = "connected";
|
|
184
|
+
else if (expiryStr?.startsWith("⚠️ EXPIRED")) status = "expired";
|
|
185
|
+
else if (expiryStr) status = "logged_in";
|
|
186
|
+
return { status, expiry: expiryStr };
|
|
187
|
+
};
|
|
188
|
+
const health = {
|
|
189
|
+
status: "ok",
|
|
190
|
+
service: "openclaw-cli-bridge",
|
|
191
|
+
version: opts.version ?? "?",
|
|
192
|
+
port: opts.port,
|
|
193
|
+
uptime_s: Math.floor(process.uptime()),
|
|
194
|
+
providers: {
|
|
195
|
+
grok: sessionStatus("grok", opts.getGrokContext, expiry.grok),
|
|
196
|
+
gemini: sessionStatus("gemini", opts.getGeminiContext, expiry.gemini),
|
|
197
|
+
claude: sessionStatus("claude", opts.getClaudeContext, expiry.claude),
|
|
198
|
+
chatgpt: sessionStatus("chatgpt", opts.getChatGPTContext, expiry.chatgpt),
|
|
199
|
+
},
|
|
200
|
+
models: CLI_MODELS.length,
|
|
201
|
+
};
|
|
202
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
203
|
+
res.end(JSON.stringify(health, null, 2));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
168
207
|
// Browser status page — human-readable HTML dashboard
|
|
169
208
|
if ((url === "/status" || url === "/") && req.method === "GET") {
|
|
170
209
|
const expiry = opts.getExpiryInfo?.() ?? { grok: null, gemini: null, claude: null, chatgpt: null };
|
|
171
210
|
const version = opts.version ?? "?";
|
|
172
211
|
|
|
173
|
-
const providers = [
|
|
212
|
+
const providers: StatusProvider[] = [
|
|
174
213
|
{ name: "Grok", icon: "𝕏", expiry: expiry.grok, loginCmd: "/grok-login", ctx: opts.getGrokContext?.() ?? null },
|
|
175
214
|
{ name: "Gemini", icon: "✦", expiry: expiry.gemini, loginCmd: "/gemini-login", ctx: opts.getGeminiContext?.() ?? null },
|
|
176
215
|
{ name: "Claude", icon: "◆", expiry: expiry.claude, loginCmd: "/claude-login", ctx: opts.getClaudeContext?.() ?? null },
|
|
177
216
|
{ name: "ChatGPT", icon: "◉", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
|
|
178
217
|
];
|
|
179
218
|
|
|
180
|
-
|
|
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 codexModels = CLI_MODELS.filter(m => m.id.startsWith("openai-codex/"));
|
|
209
|
-
const webModels = CLI_MODELS.filter(m => m.id.startsWith("web-"));
|
|
210
|
-
const localModels = CLI_MODELS.filter(m => m.id.startsWith("local-"));
|
|
211
|
-
const modelList = (models: typeof CLI_MODELS) =>
|
|
212
|
-
models.map(m => `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code style="color:#93c5fd">${m.id}</code></li>`).join("");
|
|
213
|
-
|
|
214
|
-
const html = `<!DOCTYPE html>
|
|
215
|
-
<html lang="en">
|
|
216
|
-
<head>
|
|
217
|
-
<meta charset="UTF-8">
|
|
218
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
219
|
-
<title>CLI Bridge Status</title>
|
|
220
|
-
<meta http-equiv="refresh" content="30">
|
|
221
|
-
<style>
|
|
222
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
223
|
-
body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; padding: 32px 24px; }
|
|
224
|
-
h1 { font-size: 22px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
|
|
225
|
-
.subtitle { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
|
|
226
|
-
.card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
|
|
227
|
-
.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; }
|
|
228
|
-
table { width: 100%; border-collapse: collapse; }
|
|
229
|
-
tr:not(:last-child) td { border-bottom: 1px solid #1f2335; }
|
|
230
|
-
.models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
231
|
-
ul { list-style: none; padding: 12px 16px; }
|
|
232
|
-
.footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
233
|
-
code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
|
|
234
|
-
</style>
|
|
235
|
-
</head>
|
|
236
|
-
<body>
|
|
237
|
-
<h1>🌉 CLI Bridge</h1>
|
|
238
|
-
<p class="subtitle">v${version} · Port ${opts.port} · Auto-refreshes every 30s</p>
|
|
239
|
-
|
|
240
|
-
<div class="card">
|
|
241
|
-
<div class="card-header">Web Session Providers</div>
|
|
242
|
-
<table>
|
|
243
|
-
<thead>
|
|
244
|
-
<tr style="background:#13151f">
|
|
245
|
-
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
|
|
246
|
-
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
|
|
247
|
-
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
|
|
248
|
-
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
|
|
249
|
-
</tr>
|
|
250
|
-
</thead>
|
|
251
|
-
<tbody>${rows}</tbody>
|
|
252
|
-
</table>
|
|
253
|
-
</div>
|
|
254
|
-
|
|
255
|
-
<div class="models">
|
|
256
|
-
<div class="card">
|
|
257
|
-
<div class="card-header">CLI Models (${cliModels.length})</div>
|
|
258
|
-
<ul>${modelList(cliModels)}</ul>
|
|
259
|
-
<div class="card-header">Codex Models (${codexModels.length})</div>
|
|
260
|
-
<ul>${modelList(codexModels)}</ul>
|
|
261
|
-
</div>
|
|
262
|
-
<div class="card">
|
|
263
|
-
<div class="card-header">Web Session Models (${webModels.length})</div>
|
|
264
|
-
<ul>${modelList(webModels)}</ul>
|
|
265
|
-
</div>
|
|
266
|
-
<div class="card">
|
|
267
|
-
<div class="card-header">Local Models (${localModels.length})</div>
|
|
268
|
-
<ul>${modelList(localModels)}</ul>
|
|
269
|
-
</div>
|
|
270
|
-
</div>
|
|
271
|
-
|
|
272
|
-
<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>
|
|
273
|
-
</body>
|
|
274
|
-
</html>`;
|
|
275
|
-
|
|
219
|
+
const html = renderStatusPage({ version, port: opts.port, providers, models: CLI_MODELS, modelCommands: opts.modelCommands });
|
|
276
220
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
277
221
|
res.end(html);
|
|
278
222
|
return;
|
|
@@ -645,14 +589,32 @@ async function handleRequest(
|
|
|
645
589
|
|
|
646
590
|
// ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
|
|
647
591
|
let content: string;
|
|
592
|
+
let usedModel = model;
|
|
648
593
|
try {
|
|
649
594
|
content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000);
|
|
650
595
|
} catch (err) {
|
|
651
596
|
const msg = (err as Error).message;
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
597
|
+
// ── Model fallback: retry once with a lighter model if configured ────
|
|
598
|
+
const fallbackModel = opts.modelFallbacks?.[model];
|
|
599
|
+
if (fallbackModel) {
|
|
600
|
+
opts.warn(`[cli-bridge] ${model} failed (${msg}), falling back to ${fallbackModel}`);
|
|
601
|
+
try {
|
|
602
|
+
content = await routeToCliRunner(fallbackModel, messages, opts.timeoutMs ?? 120_000);
|
|
603
|
+
usedModel = fallbackModel;
|
|
604
|
+
opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded`);
|
|
605
|
+
} catch (fallbackErr) {
|
|
606
|
+
const fallbackMsg = (fallbackErr as Error).message;
|
|
607
|
+
opts.warn(`[cli-bridge] fallback ${fallbackModel} also failed: ${fallbackMsg}`);
|
|
608
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
609
|
+
res.end(JSON.stringify({ error: { message: `${model}: ${msg} | fallback ${fallbackModel}: ${fallbackMsg}`, type: "cli_error" } }));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
opts.warn(`[cli-bridge] CLI error for ${model}: ${msg}`);
|
|
614
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
615
|
+
res.end(JSON.stringify({ error: { message: msg, type: "cli_error" } }));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
656
618
|
}
|
|
657
619
|
|
|
658
620
|
if (stream) {
|
|
@@ -664,7 +626,7 @@ async function handleRequest(
|
|
|
664
626
|
});
|
|
665
627
|
|
|
666
628
|
// Role chunk
|
|
667
|
-
sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
|
|
629
|
+
sendSseChunk(res, { id, created, model: usedModel, delta: { role: "assistant" }, finish_reason: null });
|
|
668
630
|
|
|
669
631
|
// Content in chunks (~50 chars each for natural feel)
|
|
670
632
|
const chunkSize = 50;
|
|
@@ -672,14 +634,14 @@ async function handleRequest(
|
|
|
672
634
|
sendSseChunk(res, {
|
|
673
635
|
id,
|
|
674
636
|
created,
|
|
675
|
-
model,
|
|
637
|
+
model: usedModel,
|
|
676
638
|
delta: { content: content.slice(i, i + chunkSize) },
|
|
677
639
|
finish_reason: null,
|
|
678
640
|
});
|
|
679
641
|
}
|
|
680
642
|
|
|
681
643
|
// Stop chunk
|
|
682
|
-
sendSseChunk(res, { id, created, model, delta: {}, finish_reason: "stop" });
|
|
644
|
+
sendSseChunk(res, { id, created, model: usedModel, delta: {}, finish_reason: "stop" });
|
|
683
645
|
res.write("data: [DONE]\n\n");
|
|
684
646
|
res.end();
|
|
685
647
|
} else {
|
|
@@ -687,7 +649,7 @@ async function handleRequest(
|
|
|
687
649
|
id,
|
|
688
650
|
object: "chat.completion",
|
|
689
651
|
created,
|
|
690
|
-
model,
|
|
652
|
+
model: usedModel,
|
|
691
653
|
choices: [
|
|
692
654
|
{
|
|
693
655
|
index: 0,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-template.ts
|
|
3
|
+
*
|
|
4
|
+
* Generates the HTML dashboard for the /status endpoint.
|
|
5
|
+
* Extracted from proxy-server.ts for maintainability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BrowserContext } from "playwright";
|
|
9
|
+
|
|
10
|
+
export interface StatusProvider {
|
|
11
|
+
name: string;
|
|
12
|
+
icon: string;
|
|
13
|
+
expiry: string | null;
|
|
14
|
+
loginCmd: string;
|
|
15
|
+
ctx: BrowserContext | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StatusTemplateOptions {
|
|
19
|
+
version: string;
|
|
20
|
+
port: number;
|
|
21
|
+
providers: StatusProvider[];
|
|
22
|
+
models: Array<{ id: string; name: string; contextWindow: number; maxTokens: number }>;
|
|
23
|
+
/** Maps model ID → slash command name (e.g. "openai-codex/gpt-5.3-codex" → "/cli-codex") */
|
|
24
|
+
modelCommands?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function statusBadge(p: StatusProvider): { label: string; color: string; dot: string } {
|
|
28
|
+
if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "🟢" };
|
|
29
|
+
if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "⚪" };
|
|
30
|
+
if (p.expiry.startsWith("⚠️ EXPIRED")) return { label: "Expired", color: "#ef4444", dot: "🔴" };
|
|
31
|
+
if (p.expiry.startsWith("🚨")) return { label: "Expiring soon", color: "#f59e0b", dot: "🟡" };
|
|
32
|
+
return { label: "Logged in", color: "#3b82f6", dot: "🔵" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
36
|
+
const { version, port, providers, models } = opts;
|
|
37
|
+
|
|
38
|
+
const rows = providers.map(p => {
|
|
39
|
+
const badge = statusBadge(p);
|
|
40
|
+
const expiryText = p.expiry
|
|
41
|
+
? p.expiry.replace(/[⚠️🚨✅🕐]/gu, "").trim()
|
|
42
|
+
: `Not logged in — run <code>${p.loginCmd}</code>`;
|
|
43
|
+
return `
|
|
44
|
+
<tr>
|
|
45
|
+
<td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
|
|
46
|
+
<td style="padding:12px 16px">
|
|
47
|
+
<span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
|
|
48
|
+
border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
|
|
49
|
+
${badge.dot} ${badge.label}
|
|
50
|
+
</span>
|
|
51
|
+
</td>
|
|
52
|
+
<td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
|
|
53
|
+
<td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
|
|
54
|
+
</tr>`;
|
|
55
|
+
}).join("");
|
|
56
|
+
|
|
57
|
+
const cliModels = models.filter(m => m.id.startsWith("cli-"));
|
|
58
|
+
const codexModels = models.filter(m => m.id.startsWith("openai-codex/"));
|
|
59
|
+
const webModels = models.filter(m => m.id.startsWith("web-"));
|
|
60
|
+
const localModels = models.filter(m => m.id.startsWith("local-"));
|
|
61
|
+
const cmds = opts.modelCommands ?? {};
|
|
62
|
+
const modelList = (items: typeof models) =>
|
|
63
|
+
items.map(m => {
|
|
64
|
+
const cmd = cmds[m.id];
|
|
65
|
+
const cmdBadge = cmd ? `<span style="color:#6b7280;font-size:11px;margin-left:8px">${cmd}</span>` : "";
|
|
66
|
+
return `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code style="color:#93c5fd">${m.id}</code>${cmdBadge}</li>`;
|
|
67
|
+
}).join("");
|
|
68
|
+
|
|
69
|
+
return `<!DOCTYPE html>
|
|
70
|
+
<html lang="en">
|
|
71
|
+
<head>
|
|
72
|
+
<meta charset="UTF-8">
|
|
73
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
74
|
+
<title>CLI Bridge Status</title>
|
|
75
|
+
<meta http-equiv="refresh" content="30">
|
|
76
|
+
<style>
|
|
77
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
78
|
+
body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; padding: 32px 24px; }
|
|
79
|
+
h1 { font-size: 22px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
|
|
80
|
+
.subtitle { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
|
|
81
|
+
.card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
|
|
82
|
+
.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; }
|
|
83
|
+
table { width: 100%; border-collapse: collapse; }
|
|
84
|
+
tr:not(:last-child) td { border-bottom: 1px solid #1f2335; }
|
|
85
|
+
.models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
86
|
+
ul { list-style: none; padding: 12px 16px; }
|
|
87
|
+
.footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
88
|
+
code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
|
|
89
|
+
</style>
|
|
90
|
+
</head>
|
|
91
|
+
<body>
|
|
92
|
+
<h1>CLI Bridge</h1>
|
|
93
|
+
<p class="subtitle">v${version} · Port ${port} · Auto-refreshes every 30s</p>
|
|
94
|
+
|
|
95
|
+
<div class="card">
|
|
96
|
+
<div class="card-header">Web Session Providers</div>
|
|
97
|
+
<table>
|
|
98
|
+
<thead>
|
|
99
|
+
<tr style="background:#13151f">
|
|
100
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
|
|
101
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
|
|
102
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
|
|
103
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
|
|
104
|
+
</tr>
|
|
105
|
+
</thead>
|
|
106
|
+
<tbody>${rows}</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div class="models">
|
|
111
|
+
<div class="card">
|
|
112
|
+
<div class="card-header">CLI Models (${cliModels.length})</div>
|
|
113
|
+
<ul>${modelList(cliModels)}</ul>
|
|
114
|
+
<div class="card-header">Codex Models (${codexModels.length})</div>
|
|
115
|
+
<ul>${modelList(codexModels)}</ul>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="card">
|
|
118
|
+
<div class="card-header">Web Session Models (${webModels.length})</div>
|
|
119
|
+
<ul>${modelList(webModels)}</ul>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="card">
|
|
122
|
+
<div class="card-header">Local Models (${localModels.length})</div>
|
|
123
|
+
<ul>${modelList(localModels)}</ul>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<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> · <a href="/healthz" style="color:#4b5563">/healthz</a></p>
|
|
128
|
+
</body>
|
|
129
|
+
</html>`;
|
|
130
|
+
}
|