@elvatis_com/openclaw-cli-bridge-elvatis 1.8.8 → 1.9.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/CONTRIBUTING.md +27 -4
- package/README.md +27 -2
- package/SKILL.md +1 -1
- package/eslint.config.js +26 -0
- package/index.ts +87 -32
- 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 -103
- 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.0`
|
|
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,27 @@ 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.0
|
|
360
|
+
- **feat:** Auto-source version from `package.json` — eliminates hardcoded version string sync issues (was stale across v1.8.2–v1.8.8)
|
|
361
|
+
- **feat:** ESLint config (`eslint.config.js`) — TypeScript-aware linting with `npm run lint`, integrated into CI pipeline
|
|
362
|
+
- **refactor:** Extract `/status` HTML dashboard into `src/status-template.ts` — easier to maintain and test
|
|
363
|
+
- **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)
|
|
364
|
+
- **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.
|
|
365
|
+
- **fix:** Explicit `grokBrowser` cleanup on plugin unload — prevents orphaned Chromium processes on hot-reload. Launch promises (`_geminiLaunchPromise` etc.) are also cleared.
|
|
366
|
+
- **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.
|
|
367
|
+
- **feat:** `/healthz` JSON endpoint — returns version, uptime, provider session status, and model count. Useful for monitoring scripts and health dashboards.
|
|
368
|
+
- **feat:** Status page now shows slash commands (`/cli-codex`, `/cli-sonnet`, etc.) next to model IDs
|
|
369
|
+
|
|
345
370
|
### v1.8.7
|
|
346
371
|
- **fix:** Add missing cli-gemini/gemini-3-flash-preview and all Codex models to status page model list
|
|
347
372
|
- **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
|
|
|
@@ -939,7 +955,7 @@ function proxyTestRequest(
|
|
|
939
955
|
const plugin = {
|
|
940
956
|
id: "openclaw-cli-bridge-elvatis",
|
|
941
957
|
name: "OpenClaw CLI Bridge",
|
|
942
|
-
version:
|
|
958
|
+
version: PACKAGE_VERSION,
|
|
943
959
|
description:
|
|
944
960
|
"Phase 1: openai-codex auth bridge. " +
|
|
945
961
|
"Phase 2: HTTP proxy for gemini/claude CLIs. " +
|
|
@@ -956,6 +972,43 @@ const plugin = {
|
|
|
956
972
|
const codexAuthPath = cfg.codexAuthPath ?? DEFAULT_CODEX_AUTH_PATH;
|
|
957
973
|
const grokSessionPath = cfg.grokSessionPath ?? DEFAULT_SESSION_PATH;
|
|
958
974
|
|
|
975
|
+
// ── Model → slash command mapping for status page ──────────────────────────
|
|
976
|
+
const modelCommands: Record<string, string> = {};
|
|
977
|
+
for (const entry of CLI_MODEL_COMMANDS) {
|
|
978
|
+
// Strip vllm/ prefix to match CLI_MODELS IDs
|
|
979
|
+
const modelId = entry.model.replace(/^vllm\//, "");
|
|
980
|
+
modelCommands[modelId] = `/${entry.name}`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ── Default model fallback chain ──────────────────────────────────────────
|
|
984
|
+
// When a primary model fails (timeout, error), retry once with a lighter variant.
|
|
985
|
+
const modelFallbacks: Record<string, string> = {
|
|
986
|
+
"cli-gemini/gemini-2.5-pro": "cli-gemini/gemini-2.5-flash",
|
|
987
|
+
"cli-gemini/gemini-3-pro-preview": "cli-gemini/gemini-3-flash-preview",
|
|
988
|
+
"cli-claude/claude-opus-4-6": "cli-claude/claude-sonnet-4-6",
|
|
989
|
+
"cli-claude/claude-sonnet-4-6": "cli-claude/claude-haiku-4-5",
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
// ── Migrate legacy per-provider cookie expiry files to consolidated store ─
|
|
993
|
+
const migration = migrateLegacyFiles();
|
|
994
|
+
if (migration.migrated.length > 0) {
|
|
995
|
+
api.logger.info(`[cli-bridge] migrated cookie expiry data: ${migration.migrated.join(", ")} → cookie-expiry.json`);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ── Chrome availability check ────────────────────────────────────────────
|
|
999
|
+
// Stealth mode uses channel: "chrome" (real system Chrome). If it's missing,
|
|
1000
|
+
// browser launches will fail or Cloudflare will block the bundled Chromium.
|
|
1001
|
+
const chromeCheck = checkSystemChrome();
|
|
1002
|
+
if (chromeCheck.available) {
|
|
1003
|
+
api.logger.info(`[cli-bridge] system Chrome found: ${chromeCheck.version ?? chromeCheck.path}`);
|
|
1004
|
+
} else {
|
|
1005
|
+
api.logger.warn(
|
|
1006
|
+
`[cli-bridge] ⚠ system Chrome not found! Web browser providers (/grok-login, /gemini-login, etc.) ` +
|
|
1007
|
+
`require Google Chrome or Chromium installed system-wide. ` +
|
|
1008
|
+
`Install with: sudo apt install google-chrome-stable (or chromium-browser)`
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
959
1012
|
// ── Session restore: only on first plugin load (not on hot-reloads) ──────
|
|
960
1013
|
// The gateway polls every ~60s via openclaw status, which triggers a hot-reload
|
|
961
1014
|
// (SIGUSR1 + hybrid mode). Module-level contexts (grokContext etc.) survive
|
|
@@ -975,8 +1028,8 @@ const plugin = {
|
|
|
975
1028
|
|
|
976
1029
|
const profileProviders: Array<{
|
|
977
1030
|
name: string;
|
|
1031
|
+
providerKey: import("./src/cookie-expiry-store.js").ProviderName;
|
|
978
1032
|
profileDir: string;
|
|
979
|
-
cookieFile: string;
|
|
980
1033
|
verifySelector: string;
|
|
981
1034
|
homeUrl: string;
|
|
982
1035
|
loginCmd: string;
|
|
@@ -985,8 +1038,8 @@ const plugin = {
|
|
|
985
1038
|
}> = [
|
|
986
1039
|
{
|
|
987
1040
|
name: "grok",
|
|
1041
|
+
providerKey: "grok",
|
|
988
1042
|
profileDir: GROK_PROFILE_DIR,
|
|
989
|
-
cookieFile: GROK_EXPIRY_FILE,
|
|
990
1043
|
verifySelector: "textarea",
|
|
991
1044
|
homeUrl: "https://grok.com",
|
|
992
1045
|
loginCmd: "/grok-login",
|
|
@@ -995,8 +1048,8 @@ const plugin = {
|
|
|
995
1048
|
},
|
|
996
1049
|
{
|
|
997
1050
|
name: "gemini",
|
|
1051
|
+
providerKey: "gemini",
|
|
998
1052
|
profileDir: GEMINI_PROFILE_DIR,
|
|
999
|
-
cookieFile: join(homedir(), ".openclaw", "gemini-cookie-expiry.json"),
|
|
1000
1053
|
verifySelector: ".ql-editor",
|
|
1001
1054
|
homeUrl: "https://gemini.google.com/app",
|
|
1002
1055
|
loginCmd: "/gemini-login",
|
|
@@ -1005,8 +1058,8 @@ const plugin = {
|
|
|
1005
1058
|
},
|
|
1006
1059
|
{
|
|
1007
1060
|
name: "claude-web",
|
|
1061
|
+
providerKey: "claude",
|
|
1008
1062
|
profileDir: CLAUDE_PROFILE_DIR,
|
|
1009
|
-
cookieFile: CLAUDE_EXPIRY_FILE,
|
|
1010
1063
|
verifySelector: ".ProseMirror",
|
|
1011
1064
|
homeUrl: "https://claude.ai/new",
|
|
1012
1065
|
loginCmd: "/claude-login",
|
|
@@ -1015,8 +1068,8 @@ const plugin = {
|
|
|
1015
1068
|
},
|
|
1016
1069
|
{
|
|
1017
1070
|
name: "chatgpt",
|
|
1071
|
+
providerKey: "chatgpt",
|
|
1018
1072
|
profileDir: CHATGPT_PROFILE_DIR,
|
|
1019
|
-
cookieFile: CHATGPT_EXPIRY_FILE,
|
|
1020
1073
|
verifySelector: "#prompt-textarea",
|
|
1021
1074
|
homeUrl: "https://chatgpt.com",
|
|
1022
1075
|
loginCmd: "/chatgpt-login",
|
|
@@ -1026,22 +1079,22 @@ const plugin = {
|
|
|
1026
1079
|
];
|
|
1027
1080
|
|
|
1028
1081
|
for (const p of profileProviders) {
|
|
1029
|
-
|
|
1082
|
+
const storedExpiry = loadProviderExpiry(p.providerKey);
|
|
1083
|
+
if (!existsSync(p.profileDir) && !storedExpiry) {
|
|
1030
1084
|
api.logger.info(`[cli-bridge:${p.name}] no saved profile — skipping startup restore`);
|
|
1031
1085
|
continue;
|
|
1032
1086
|
}
|
|
1033
1087
|
if (p.getCtx()) continue; // already connected
|
|
1034
1088
|
|
|
1035
1089
|
// ── Cookie-first check ────────────────────────────────────────────
|
|
1036
|
-
// If
|
|
1090
|
+
// If cookie expiry data exists and is still valid (>1h left),
|
|
1037
1091
|
// launch the persistent context immediately without a browser-based
|
|
1038
1092
|
// selector check. Selector checks are fragile (slow pages, DOM changes).
|
|
1039
1093
|
// The keep-alive (20h) will verify the session properly later.
|
|
1040
1094
|
let cookiesValid = false;
|
|
1041
|
-
if (
|
|
1095
|
+
if (storedExpiry) {
|
|
1042
1096
|
try {
|
|
1043
|
-
const
|
|
1044
|
-
const msLeft = expInfo.expiresAt - Date.now();
|
|
1097
|
+
const msLeft = storedExpiry.expiresAt - Date.now();
|
|
1045
1098
|
if (msLeft > 3_600_000) { // >1h remaining
|
|
1046
1099
|
cookiesValid = true;
|
|
1047
1100
|
api.logger.info(`[cli-bridge:${p.name}] cookie valid (${Math.floor(msLeft / 86_400_000)}d left) — restoring context without browser check`);
|
|
@@ -1274,6 +1327,8 @@ const plugin = {
|
|
|
1274
1327
|
return chatgptContext;
|
|
1275
1328
|
},
|
|
1276
1329
|
version: plugin.version,
|
|
1330
|
+
modelCommands,
|
|
1331
|
+
modelFallbacks,
|
|
1277
1332
|
getExpiryInfo: () => ({
|
|
1278
1333
|
grok: (() => { const e = loadGrokExpiry(); return e ? formatExpiryInfo(e) : null; })(),
|
|
1279
1334
|
gemini: (() => { const e = loadGeminiExpiry(); return e ? formatGeminiExpiry(e) : null; })(),
|
|
@@ -1309,7 +1364,7 @@ const plugin = {
|
|
|
1309
1364
|
// One final attempt
|
|
1310
1365
|
try {
|
|
1311
1366
|
const server = await startProxyServer({
|
|
1312
|
-
port, apiKey, timeoutMs,
|
|
1367
|
+
port, apiKey, timeoutMs, modelCommands, modelFallbacks,
|
|
1313
1368
|
log: (msg) => api.logger.info(msg),
|
|
1314
1369
|
warn: (msg) => api.logger.warn(msg),
|
|
1315
1370
|
getGrokContext: () => grokContext,
|
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.0",
|
|
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.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": {
|
|
@@ -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,118 +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 webModels = CLI_MODELS.filter(m => m.id.startsWith("web-"));
|
|
209
|
-
const localModels = CLI_MODELS.filter(m => m.id.startsWith("local-"));
|
|
210
|
-
const modelList = (models: typeof CLI_MODELS) =>
|
|
211
|
-
models.map(m => `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code style="color:#93c5fd">${m.id}</code></li>`).join("");
|
|
212
|
-
|
|
213
|
-
const html = `<!DOCTYPE html>
|
|
214
|
-
<html lang="en">
|
|
215
|
-
<head>
|
|
216
|
-
<meta charset="UTF-8">
|
|
217
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
218
|
-
<title>CLI Bridge Status</title>
|
|
219
|
-
<meta http-equiv="refresh" content="30">
|
|
220
|
-
<style>
|
|
221
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
222
|
-
body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; padding: 32px 24px; }
|
|
223
|
-
h1 { font-size: 22px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
|
|
224
|
-
.subtitle { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
|
|
225
|
-
.card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
|
|
226
|
-
.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; }
|
|
227
|
-
table { width: 100%; border-collapse: collapse; }
|
|
228
|
-
tr:not(:last-child) td { border-bottom: 1px solid #1f2335; }
|
|
229
|
-
.models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
230
|
-
ul { list-style: none; padding: 12px 16px; }
|
|
231
|
-
.footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
232
|
-
code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
|
|
233
|
-
</style>
|
|
234
|
-
</head>
|
|
235
|
-
<body>
|
|
236
|
-
<h1>🌉 CLI Bridge</h1>
|
|
237
|
-
<p class="subtitle">v${version} · Port ${opts.port} · Auto-refreshes every 30s</p>
|
|
238
|
-
|
|
239
|
-
<div class="card">
|
|
240
|
-
<div class="card-header">Web Session Providers</div>
|
|
241
|
-
<table>
|
|
242
|
-
<thead>
|
|
243
|
-
<tr style="background:#13151f">
|
|
244
|
-
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
|
|
245
|
-
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
|
|
246
|
-
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
|
|
247
|
-
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
|
|
248
|
-
</tr>
|
|
249
|
-
</thead>
|
|
250
|
-
<tbody>${rows}</tbody>
|
|
251
|
-
</table>
|
|
252
|
-
</div>
|
|
253
|
-
|
|
254
|
-
<div class="models">
|
|
255
|
-
<div class="card">
|
|
256
|
-
<div class="card-header">CLI Models (${cliModels.length})</div>
|
|
257
|
-
<ul>${modelList(cliModels)}</ul>
|
|
258
|
-
</div>
|
|
259
|
-
<div class="card">
|
|
260
|
-
<div class="card-header">Web Session Models (${webModels.length})</div>
|
|
261
|
-
<ul>${modelList(webModels)}</ul>
|
|
262
|
-
</div>
|
|
263
|
-
<div class="card">
|
|
264
|
-
<div class="card-header">Local Models (${localModels.length})</div>
|
|
265
|
-
<ul>${modelList(localModels)}</ul>
|
|
266
|
-
</div>
|
|
267
|
-
</div>
|
|
268
|
-
|
|
269
|
-
<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>
|
|
270
|
-
</body>
|
|
271
|
-
</html>`;
|
|
272
|
-
|
|
219
|
+
const html = renderStatusPage({ version, port: opts.port, providers, models: CLI_MODELS, modelCommands: opts.modelCommands });
|
|
273
220
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
274
221
|
res.end(html);
|
|
275
222
|
return;
|
|
@@ -642,14 +589,32 @@ async function handleRequest(
|
|
|
642
589
|
|
|
643
590
|
// ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
|
|
644
591
|
let content: string;
|
|
592
|
+
let usedModel = model;
|
|
645
593
|
try {
|
|
646
594
|
content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000);
|
|
647
595
|
} catch (err) {
|
|
648
596
|
const msg = (err as Error).message;
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
+
}
|
|
653
618
|
}
|
|
654
619
|
|
|
655
620
|
if (stream) {
|
|
@@ -661,7 +626,7 @@ async function handleRequest(
|
|
|
661
626
|
});
|
|
662
627
|
|
|
663
628
|
// Role chunk
|
|
664
|
-
sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
|
|
629
|
+
sendSseChunk(res, { id, created, model: usedModel, delta: { role: "assistant" }, finish_reason: null });
|
|
665
630
|
|
|
666
631
|
// Content in chunks (~50 chars each for natural feel)
|
|
667
632
|
const chunkSize = 50;
|
|
@@ -669,14 +634,14 @@ async function handleRequest(
|
|
|
669
634
|
sendSseChunk(res, {
|
|
670
635
|
id,
|
|
671
636
|
created,
|
|
672
|
-
model,
|
|
637
|
+
model: usedModel,
|
|
673
638
|
delta: { content: content.slice(i, i + chunkSize) },
|
|
674
639
|
finish_reason: null,
|
|
675
640
|
});
|
|
676
641
|
}
|
|
677
642
|
|
|
678
643
|
// Stop chunk
|
|
679
|
-
sendSseChunk(res, { id, created, model, delta: {}, finish_reason: "stop" });
|
|
644
|
+
sendSseChunk(res, { id, created, model: usedModel, delta: {}, finish_reason: "stop" });
|
|
680
645
|
res.write("data: [DONE]\n\n");
|
|
681
646
|
res.end();
|
|
682
647
|
} else {
|
|
@@ -684,7 +649,7 @@ async function handleRequest(
|
|
|
684
649
|
id,
|
|
685
650
|
object: "chat.completion",
|
|
686
651
|
created,
|
|
687
|
-
model,
|
|
652
|
+
model: usedModel,
|
|
688
653
|
choices: [
|
|
689
654
|
{
|
|
690
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
|
+
}
|