@elvatis_com/openclaw-cli-bridge-elvatis 2.10.1 → 3.1.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/CLAUDE.md +113 -0
- package/README.md +16 -1
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli-runner.ts +12 -6
- package/src/config.ts +15 -9
- package/src/debug-log.ts +47 -1
- package/src/proxy-server.ts +101 -35
- package/src/status-template.ts +346 -104
- package/test/config.test.ts +9 -5
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# OpenClaw CLI Bridge
|
|
2
|
+
|
|
3
|
+
## Project Overview
|
|
4
|
+
|
|
5
|
+
OpenClaw plugin that bridges AI CLIs (Claude, Gemini, Codex, Grok, ChatGPT) as model providers via a local OpenAI-compatible HTTP proxy on `127.0.0.1:31337`. The gateway routes `vllm/` model requests here; the bridge spawns CLI subprocesses and translates between OpenAI protocol and CLI text I/O.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
OpenClaw Gateway ──(HTTP)──> proxy-server.ts ──(spawn)──> claude/gemini/codex CLI
|
|
11
|
+
:31337 │
|
|
12
|
+
├── cli-runner.ts (subprocess spawn, stdin prompt, timeout, stale-output detection)
|
|
13
|
+
├── tool-protocol.ts (tool schema injection, JSON response parsing, tool_calls rescue)
|
|
14
|
+
├── config.ts (all timeouts, thresholds, paths — single source of truth)
|
|
15
|
+
├── debug-log.ts (file-based log: ~/.openclaw/cli-bridge/debug.log)
|
|
16
|
+
├── metrics.ts (request metrics, persisted to ~/.openclaw/cli-bridge/metrics.json)
|
|
17
|
+
├── provider-sessions.ts (persistent session registry per model)
|
|
18
|
+
├── status-template.ts (HTML dashboard at GET /status)
|
|
19
|
+
├── session-manager.ts (long-running session spawn/poll/kill)
|
|
20
|
+
├── *-browser.ts (Playwright web-session providers: Grok, Gemini, Claude, ChatGPT)
|
|
21
|
+
└── gemini-api-runner.ts (native Gemini API via @google/genai SDK)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Key Design Decisions
|
|
25
|
+
|
|
26
|
+
- **Prompt via stdin, never CLI args** — avoids E2BIG on large sessions
|
|
27
|
+
- **Tool calls via text injection** — CLI tools don't support native tool_use, so tool schemas are injected into the prompt text and responses are parsed for JSON
|
|
28
|
+
- **JSON reminder sandwich** — tool instructions appear at start AND end of prompt; models (especially Haiku) forget format instructions after long conversations
|
|
29
|
+
- **Stale-output detection** — if a CLI subprocess produces zero stdout for 30s, SIGTERM early instead of waiting full timeout. Claude Sonnet intermittently hangs silently on large tool prompts (API-side issue, not RAM — confirmed 28GB free, zero swap)
|
|
30
|
+
- **Smart fallback** — Sonnet tries first (better tool selection), 30s stale timeout kills it fast, Haiku takes over (~10s, reliable but picks wrong tools sometimes)
|
|
31
|
+
- **Compact tool schema** — when >8 tools, only send name+params (skip descriptions/full JSON schema), cuts prompt ~60%
|
|
32
|
+
- **Exit 143 = our SIGTERM** — not OOM, not crash. The bridge's timeout/stale-output detector sends SIGTERM, Claude CLI exits 143
|
|
33
|
+
|
|
34
|
+
## Build & Test
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm run build # tsc — always has 5 pre-existing errors (openclaw/plugin-sdk not found at compile time, only at runtime). Dist output is still generated correctly.
|
|
38
|
+
npx vitest run # 278+ tests across 19 files. All must pass.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Deploy Workflow
|
|
42
|
+
|
|
43
|
+
The gateway loads plugins from `~/.openclaw/extensions/`, NOT from this workspace:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm run build
|
|
47
|
+
rsync -a --exclude node_modules --exclude .git ./ ~/.openclaw/extensions/openclaw-cli-bridge-elvatis/
|
|
48
|
+
openclaw gateway restart
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Version Bump Checklist
|
|
52
|
+
|
|
53
|
+
Bump version string in 4 files before every release:
|
|
54
|
+
1. `package.json`
|
|
55
|
+
2. `openclaw.plugin.json`
|
|
56
|
+
3. `README.md` (line 5: "Current version")
|
|
57
|
+
4. `SKILL.md` (last line: "Version:")
|
|
58
|
+
|
|
59
|
+
Then: `git commit && git push && gh release create vX.Y.Z`
|
|
60
|
+
|
|
61
|
+
## Config (src/config.ts)
|
|
62
|
+
|
|
63
|
+
All magic numbers live here. Key values:
|
|
64
|
+
|
|
65
|
+
| Constant | Value | Purpose |
|
|
66
|
+
|----------|-------|---------|
|
|
67
|
+
| `MAX_EFFECTIVE_TIMEOUT_MS` | 580s | Must stay UNDER gateway's `idleTimeoutSeconds` (600s) |
|
|
68
|
+
| `STALE_OUTPUT_TIMEOUT_MS` | 30s | Kill silent CLI processes fast |
|
|
69
|
+
| `TOOL_HEAVY_THRESHOLD` | 10 | Reduce MAX_MESSAGES from 20 to 12 when tools exceed this |
|
|
70
|
+
| `COMPACT_TOOL_THRESHOLD` | 8 | Switch to compact tool schema (name+params only) |
|
|
71
|
+
| `TOOL_ROUTING_THRESHOLD` | 8 | (in proxy-server) Was used for Haiku routing, now Sonnet-first with fast fallback |
|
|
72
|
+
|
|
73
|
+
## Tool Protocol (src/tool-protocol.ts)
|
|
74
|
+
|
|
75
|
+
Models receive tool schemas as text and must respond with:
|
|
76
|
+
- `{"tool_calls":[{"name":"...","arguments":{...}}]}` — to call a tool
|
|
77
|
+
- `{"content":"..."}` — to respond with text
|
|
78
|
+
|
|
79
|
+
Parser tries 5 strategies: Claude JSON wrapper, direct JSON, code blocks, embedded JSON, rescue from content string. Debug logging on every path.
|
|
80
|
+
|
|
81
|
+
## Known Issues
|
|
82
|
+
|
|
83
|
+
- **Sonnet intermittent hangs** — `claude -p` with Sonnet goes completely silent (~50% of the time) on large tool prompts (20KB+). First call often works, subsequent calls hang. NOT RAM-related. Likely API-side rate limiting or request dedup. Workaround: 30s stale-output detection + Haiku fallback.
|
|
84
|
+
- **Haiku empty responses** — occasionally returns zero stdout (len:0). Cause unclear. The JSON reminder at prompt end helps but doesn't fully solve it.
|
|
85
|
+
- **Pre-existing tsc errors** — 5 errors about `openclaw/plugin-sdk` module not found. These are expected — the SDK is injected at runtime by the gateway. Dist output is still generated.
|
|
86
|
+
|
|
87
|
+
## Testing
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx vitest run # full suite (278+ tests)
|
|
91
|
+
npx vitest run test/config.test.ts # just config
|
|
92
|
+
npx vitest run test/proxy-e2e.test.ts # proxy integration
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Tests that mock `config.js` must use `importOriginal` spread pattern — the mock must include all exports since `debug-log.ts` and `cli-runner.ts` import from config at module level.
|
|
96
|
+
|
|
97
|
+
## Debug
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
tail -f ~/.openclaw/cli-bridge/debug.log # real-time request lifecycle
|
|
101
|
+
curl http://127.0.0.1:31337/status # dashboard (auto-refresh 10s)
|
|
102
|
+
cat ~/.openclaw/cli-bridge/metrics.json # persisted metrics
|
|
103
|
+
cat ~/.openclaw/cli-bridge/sessions.json # provider session state
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Roadmap (v3.0)
|
|
107
|
+
|
|
108
|
+
- [ ] Dashboard v2: sidebar navigation, live log viewer, model config editor, routing visualization
|
|
109
|
+
- [ ] User-configurable routing engine via dashboard UI
|
|
110
|
+
- [ ] Multi-model fallback chains: Claude → Gemini → Codex → Haiku
|
|
111
|
+
- [ ] Per-tool routing: write/exec → fast model, search/analyze → smart model
|
|
112
|
+
- [ ] Model health scoring: track success rates, auto-demote unreliable models
|
|
113
|
+
- [ ] Session resume: use `claude --resume` for conversation continuity instead of fresh `-p` each time
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code, OpenCode, Pi) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
|
|
4
4
|
|
|
5
|
-
**Current version:** `
|
|
5
|
+
**Current version:** `3.1.0`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -406,6 +406,21 @@ npm run ci # lint + typecheck + test
|
|
|
406
406
|
|
|
407
407
|
## Changelog
|
|
408
408
|
|
|
409
|
+
### v3.1.0
|
|
410
|
+
- **feat:** cross-provider fallback chains — Sonnet → Haiku → Gemini Flash → Codex (was single-model fallback only)
|
|
411
|
+
- **feat:** fallback chain loop — tries each model in order until one succeeds, logs each attempt
|
|
412
|
+
- **fix:** live logs newest-on-top — latest entries now appear at the top of the log viewer
|
|
413
|
+
- **feat:** SSE fallback notifications for each chain attempt so user sees what's happening
|
|
414
|
+
|
|
415
|
+
### v3.0.0
|
|
416
|
+
- **feat:** dashboard v2 — sidebar navigation with 9 sections (Overview, Providers, Active, Requests, Fallbacks, Sessions, Live Logs, Timeouts, Models)
|
|
417
|
+
- **feat:** live log viewer — SSE-powered real-time log streaming with color-coded categories, auto-scroll, pause/resume, 500-line client buffer
|
|
418
|
+
- **feat:** AJAX polling — replaces full-page meta-refresh with incremental section updates every 10s (preserves scroll position and active section)
|
|
419
|
+
- **feat:** `/api/dashboard-data` endpoint — returns pre-rendered HTML sections as JSON for client-side updates
|
|
420
|
+
- **feat:** `/api/logs/stream` SSE endpoint — streams debug.log in real-time with initial tail of last 100 lines
|
|
421
|
+
- **feat:** mobile-responsive sidebar with hamburger toggle
|
|
422
|
+
- **feat:** CLAUDE.md added with full project documentation
|
|
423
|
+
|
|
409
424
|
### v2.10.1
|
|
410
425
|
- **feat:** smart tool-routing — tool-heavy requests (>8 tools) auto-route to Haiku instead of Sonnet. Haiku handles tool calls in ~11s vs Sonnet's 80-120s (with intermittent hangs). Sonnet is preserved for reasoning/text responses.
|
|
411
426
|
- **fix:** reduce stale-output timeout 120s→60s — faster fallback when Sonnet goes silent
|
package/SKILL.md
CHANGED
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": "
|
|
5
|
+
"version": "3.1.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": "
|
|
3
|
+
"version": "3.1.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": {
|
package/src/cli-runner.ts
CHANGED
|
@@ -508,9 +508,11 @@ export async function runGemini(
|
|
|
508
508
|
const args = ["-m", model, "-p", "", "--approval-mode", "yolo"];
|
|
509
509
|
const cwd = workdir ?? tmpdir();
|
|
510
510
|
|
|
511
|
-
// When tools are present,
|
|
511
|
+
// When tools are present, sandwich the conversation between tool instructions.
|
|
512
|
+
// The reminder at the end ensures models (especially Haiku) remember the JSON format
|
|
513
|
+
// after processing a long conversation history.
|
|
512
514
|
const effectivePrompt = opts?.tools?.length
|
|
513
|
-
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
|
|
515
|
+
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
|
|
514
516
|
: prompt;
|
|
515
517
|
|
|
516
518
|
const result = await runCli("gemini", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
|
|
@@ -560,9 +562,11 @@ export async function runClaude(
|
|
|
560
562
|
"--model", model,
|
|
561
563
|
];
|
|
562
564
|
|
|
563
|
-
// When tools are present,
|
|
565
|
+
// When tools are present, sandwich the conversation between tool instructions.
|
|
566
|
+
// The reminder at the end ensures models (especially Haiku) remember the JSON format
|
|
567
|
+
// after processing a long conversation history.
|
|
564
568
|
const effectivePrompt = opts?.tools?.length
|
|
565
|
-
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
|
|
569
|
+
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
|
|
566
570
|
: prompt;
|
|
567
571
|
|
|
568
572
|
const cwd = workdir ?? homedir();
|
|
@@ -641,9 +645,11 @@ export async function runCodex(
|
|
|
641
645
|
// Codex requires a git repo in the working directory
|
|
642
646
|
ensureGitRepo(cwd);
|
|
643
647
|
|
|
644
|
-
// When tools are present,
|
|
648
|
+
// When tools are present, sandwich the conversation between tool instructions.
|
|
649
|
+
// The reminder at the end ensures models (especially Haiku) remember the JSON format
|
|
650
|
+
// after processing a long conversation history.
|
|
645
651
|
const effectivePrompt = opts?.tools?.length
|
|
646
|
-
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
|
|
652
|
+
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
|
|
647
653
|
: prompt;
|
|
648
654
|
|
|
649
655
|
const result = await runCli("codex", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
|
package/src/config.ts
CHANGED
|
@@ -57,7 +57,7 @@ export const TIMEOUT_GRACE_MS = 5_000;
|
|
|
57
57
|
* assume it's stuck and SIGTERM early. 0 = disabled.
|
|
58
58
|
* Prevents waiting the full timeout when Claude CLI hangs silently.
|
|
59
59
|
*/
|
|
60
|
-
export const STALE_OUTPUT_TIMEOUT_MS =
|
|
60
|
+
export const STALE_OUTPUT_TIMEOUT_MS = 30_000; // 30s of silence → kill. Sonnet either starts producing within 30s or it's hung.
|
|
61
61
|
|
|
62
62
|
/** Max messages to include in the prompt sent to CLI subprocesses. */
|
|
63
63
|
export const MAX_MESSAGES = 20;
|
|
@@ -138,15 +138,21 @@ export const DEFAULT_MODEL_TIMEOUTS: Record<string, number> = {
|
|
|
138
138
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
|
-
* Default fallback
|
|
142
|
-
*
|
|
141
|
+
* Default fallback chains: when a primary model fails (timeout, stale, error),
|
|
142
|
+
* try each fallback in order. Cross-provider chains ensure we use all available
|
|
143
|
+
* models instead of just falling back within one provider.
|
|
144
|
+
*
|
|
145
|
+
* Strategy: same-provider fast model first, then cross-provider alternatives.
|
|
143
146
|
*/
|
|
144
|
-
export const DEFAULT_MODEL_FALLBACKS: Record<string, string> = {
|
|
145
|
-
"cli-
|
|
146
|
-
"cli-
|
|
147
|
-
"cli-claude/claude-
|
|
148
|
-
"cli-
|
|
149
|
-
"gemini
|
|
147
|
+
export const DEFAULT_MODEL_FALLBACKS: Record<string, string[]> = {
|
|
148
|
+
"cli-claude/claude-opus-4-6": ["cli-claude/claude-sonnet-4-6", "cli-gemini/gemini-2.5-pro", "cli-claude/claude-haiku-4-5"],
|
|
149
|
+
"cli-claude/claude-sonnet-4-6": ["cli-claude/claude-haiku-4-5", "cli-gemini/gemini-2.5-flash", "openai-codex/gpt-5.3-codex"],
|
|
150
|
+
"cli-claude/claude-haiku-4-5": ["cli-gemini/gemini-2.5-flash", "openai-codex/gpt-5.1-codex-mini"],
|
|
151
|
+
"cli-gemini/gemini-2.5-pro": ["cli-gemini/gemini-2.5-flash", "cli-claude/claude-haiku-4-5"],
|
|
152
|
+
"cli-gemini/gemini-3-pro-preview": ["cli-gemini/gemini-3-flash-preview", "cli-gemini/gemini-2.5-flash"],
|
|
153
|
+
"openai-codex/gpt-5.4": ["openai-codex/gpt-5.3-codex", "cli-claude/claude-haiku-4-5"],
|
|
154
|
+
"openai-codex/gpt-5.3-codex": ["openai-codex/gpt-5.1-codex-mini", "cli-gemini/gemini-2.5-flash"],
|
|
155
|
+
"gemini-api/gemini-2.5-pro": ["gemini-api/gemini-2.5-flash"],
|
|
150
156
|
};
|
|
151
157
|
|
|
152
158
|
// ──────────────────────────────────────────────────────────────────────────────
|
package/src/debug-log.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* tail -f ~/.openclaw/cli-bridge/debug.log
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { appendFileSync, statSync, renameSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { appendFileSync, readFileSync, openSync, readSync, closeSync, statSync, renameSync, mkdirSync, watchFile, unwatchFile } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
|
|
@@ -53,3 +53,49 @@ export function debugLog(category: string, message: string, data?: Record<string
|
|
|
53
53
|
|
|
54
54
|
/** Log path for display on status page / startup messages. */
|
|
55
55
|
export const DEBUG_LOG_PATH = LOG_FILE;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the last N lines from the log file.
|
|
59
|
+
* Returns null if the file doesn't exist.
|
|
60
|
+
*/
|
|
61
|
+
export function getLogTail(lines = 100): string | null {
|
|
62
|
+
try {
|
|
63
|
+
const content = readFileSync(LOG_FILE, "utf8");
|
|
64
|
+
const allLines = content.split("\n").filter(Boolean);
|
|
65
|
+
return allLines.slice(-lines).reverse().join("\n");
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Watch the log file for new content and call the callback for each new line.
|
|
73
|
+
* Returns an unwatch function to stop watching.
|
|
74
|
+
*/
|
|
75
|
+
export function watchLogFile(onLine: (line: string) => void): () => void {
|
|
76
|
+
let lastSize = 0;
|
|
77
|
+
try { lastSize = statSync(LOG_FILE).size; } catch { /* file doesn't exist yet */ }
|
|
78
|
+
|
|
79
|
+
const listener = () => {
|
|
80
|
+
try {
|
|
81
|
+
const stat = statSync(LOG_FILE);
|
|
82
|
+
if (stat.size <= lastSize) {
|
|
83
|
+
// File was rotated or truncated — reset
|
|
84
|
+
lastSize = 0;
|
|
85
|
+
}
|
|
86
|
+
if (stat.size > lastSize) {
|
|
87
|
+
const buf = Buffer.alloc(stat.size - lastSize);
|
|
88
|
+
const fd = openSync(LOG_FILE, "r");
|
|
89
|
+
readSync(fd, buf, 0, buf.length, lastSize);
|
|
90
|
+
closeSync(fd);
|
|
91
|
+
const newContent = buf.toString("utf8");
|
|
92
|
+
const lines = newContent.split("\n").filter(Boolean);
|
|
93
|
+
for (const line of lines) onLine(line);
|
|
94
|
+
lastSize = stat.size;
|
|
95
|
+
}
|
|
96
|
+
} catch { /* best effort */ }
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
watchFile(LOG_FILE, { interval: 1000 }, listener);
|
|
100
|
+
return () => { unwatchFile(LOG_FILE, listener); };
|
|
101
|
+
}
|
package/src/proxy-server.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowser
|
|
|
18
18
|
import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
|
|
19
19
|
import { geminiApiComplete, geminiApiCompleteStream, type GeminiApiResult, type ContentPart } from "./gemini-api-runner.js";
|
|
20
20
|
import type { BrowserContext } from "playwright";
|
|
21
|
-
import { renderStatusPage, type StatusProvider } from "./status-template.js";
|
|
21
|
+
import { renderStatusPage, renderDashboardData, type StatusProvider } from "./status-template.js";
|
|
22
22
|
import { sessionManager } from "./session-manager.js";
|
|
23
23
|
import { metrics, estimateTokens } from "./metrics.js";
|
|
24
24
|
import { providerSessions } from "./provider-sessions.js";
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
DEFAULT_MODEL_TIMEOUTS,
|
|
35
35
|
TOOL_ROUTING_THRESHOLD,
|
|
36
36
|
} from "./config.js";
|
|
37
|
-
import { debugLog, DEBUG_LOG_PATH } from "./debug-log.js";
|
|
37
|
+
import { debugLog, DEBUG_LOG_PATH, getLogTail, watchLogFile } from "./debug-log.js";
|
|
38
38
|
|
|
39
39
|
// ── Active request tracking ─────────────────────────────────────────────────
|
|
40
40
|
|
|
@@ -117,7 +117,7 @@ export interface ProxyServerOptions {
|
|
|
117
117
|
* When a CLI model fails (timeout, error), the request is retried once
|
|
118
118
|
* with the fallback model. Example: "cli-gemini/gemini-2.5-pro" → "cli-gemini/gemini-2.5-flash"
|
|
119
119
|
*/
|
|
120
|
-
modelFallbacks?: Record<string, string>;
|
|
120
|
+
modelFallbacks?: Record<string, string | string[]>;
|
|
121
121
|
/**
|
|
122
122
|
* Per-model timeout overrides (ms). Keys are model IDs (without "vllm/" prefix).
|
|
123
123
|
* Use this to give heavy models more time or limit fast models.
|
|
@@ -316,6 +316,58 @@ async function handleRequest(
|
|
|
316
316
|
return;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
// Dashboard data API — returns pre-rendered HTML sections for AJAX polling
|
|
320
|
+
if (url === "/api/dashboard-data" && req.method === "GET") {
|
|
321
|
+
const expiry = opts.getExpiryInfo?.() ?? { grok: null, gemini: null, claude: null, chatgpt: null };
|
|
322
|
+
const version = opts.version ?? "?";
|
|
323
|
+
const providers: StatusProvider[] = [
|
|
324
|
+
{ name: "Grok", icon: "\uD835\uDD4F", expiry: expiry.grok, loginCmd: "/grok-login", ctx: opts.getGrokContext?.() ?? null },
|
|
325
|
+
{ name: "Gemini", icon: "\u2726", expiry: expiry.gemini, loginCmd: "/gemini-login", ctx: opts.getGeminiContext?.() ?? null },
|
|
326
|
+
{ name: "Claude", icon: "\u25C6", expiry: expiry.claude, loginCmd: "/claude-login", ctx: opts.getClaudeContext?.() ?? null },
|
|
327
|
+
{ name: "ChatGPT", icon: "\u25C9", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
|
|
328
|
+
];
|
|
329
|
+
const sections = renderDashboardData({
|
|
330
|
+
version, port: opts.port, providers, models: CLI_MODELS,
|
|
331
|
+
modelCommands: opts.modelCommands,
|
|
332
|
+
metrics: metrics.getMetrics(),
|
|
333
|
+
activeRequests: getActiveRequests(),
|
|
334
|
+
providerSessionsList: providerSessions.listSessions(),
|
|
335
|
+
timeoutConfig: {
|
|
336
|
+
defaults: { ...DEFAULT_MODEL_TIMEOUTS, ...(opts.modelTimeouts ?? {}) },
|
|
337
|
+
baseDefault: opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS,
|
|
338
|
+
maxEffective: MAX_EFFECTIVE_TIMEOUT_MS,
|
|
339
|
+
perExtraMsg: TIMEOUT_PER_EXTRA_MSG_MS,
|
|
340
|
+
perTool: TIMEOUT_PER_TOOL_MS,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
344
|
+
res.end(JSON.stringify(sections));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Live log streaming via SSE
|
|
349
|
+
if (url === "/api/logs/stream" && req.method === "GET") {
|
|
350
|
+
res.writeHead(200, {
|
|
351
|
+
"Content-Type": "text/event-stream",
|
|
352
|
+
"Cache-Control": "no-cache",
|
|
353
|
+
Connection: "keep-alive",
|
|
354
|
+
...corsHeaders(),
|
|
355
|
+
});
|
|
356
|
+
// Send initial tail
|
|
357
|
+
const tail = getLogTail(100);
|
|
358
|
+
if (tail) res.write(`data: ${tail.replace(/\n/g, "\ndata: ")}\n\n`);
|
|
359
|
+
// Watch for new lines
|
|
360
|
+
const unwatch = watchLogFile((line) => {
|
|
361
|
+
try { res.write(`data: ${line}\n\n`); } catch { /* client disconnected */ }
|
|
362
|
+
});
|
|
363
|
+
// Keepalive
|
|
364
|
+
const ka = setInterval(() => {
|
|
365
|
+
try { res.write(": keepalive\n\n"); } catch { /* client disconnected */ }
|
|
366
|
+
}, 15_000);
|
|
367
|
+
req.on("close", () => { unwatch(); clearInterval(ka); });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
319
371
|
// Model list
|
|
320
372
|
if (url === "/v1/models" && req.method === "GET") {
|
|
321
373
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -792,16 +844,11 @@ async function handleRequest(
|
|
|
792
844
|
let result: CliToolResult;
|
|
793
845
|
let usedModel = model;
|
|
794
846
|
|
|
795
|
-
// ── Smart tool routing:
|
|
796
|
-
// Sonnet
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
const toolModel = "cli-claude/claude-haiku-4-5";
|
|
801
|
-
opts.log(`[cli-bridge] tool-routing: ${model} → ${toolModel} (${tools!.length} tools)`);
|
|
802
|
-
debugLog("TOOL-ROUTE", `${model} → ${toolModel}`, { tools: tools!.length, threshold: TOOL_ROUTING_THRESHOLD });
|
|
803
|
-
usedModel = toolModel;
|
|
804
|
-
}
|
|
847
|
+
// ── Smart tool routing: Sonnet first (better reasoning), fast fallback to Haiku ──
|
|
848
|
+
// Sonnet picks the right tools but intermittently hangs on large prompts.
|
|
849
|
+
// Strategy: let Sonnet try first — if it responds, great (better tool selection).
|
|
850
|
+
// The stale-output detector (60s) kills it fast if it hangs, then fallback to Haiku.
|
|
851
|
+
// This preserves Sonnet's intelligence for tool selection while keeping Haiku as safety net.
|
|
805
852
|
|
|
806
853
|
const routeOpts = { workdir, tools: hasTools ? tools : undefined, mediaFiles: mediaFiles.length ? mediaFiles : undefined, log: opts.log };
|
|
807
854
|
|
|
@@ -870,36 +917,55 @@ async function handleRequest(
|
|
|
870
917
|
debugLog("FAIL", `${model} failed after ${(primaryDuration / 1000).toFixed(1)}s`, { isTimeout, error: msg.slice(0, 200) });
|
|
871
918
|
// Record the run (with timeout flag) — session is preserved, not deleted
|
|
872
919
|
providerSessions.recordRun(session.id, isTimeout);
|
|
873
|
-
|
|
874
|
-
|
|
920
|
+
// ── Multi-model fallback chain: try each fallback in order ──────────
|
|
921
|
+
// Chains cross providers: Sonnet → Haiku → Gemini Flash → Codex
|
|
922
|
+
const rawFallbacks = opts.modelFallbacks?.[model];
|
|
923
|
+
const fallbackChain: string[] = Array.isArray(rawFallbacks) ? rawFallbacks
|
|
924
|
+
: typeof rawFallbacks === "string" ? [rawFallbacks]
|
|
925
|
+
: [];
|
|
926
|
+
|
|
927
|
+
if (fallbackChain.length > 0) {
|
|
875
928
|
metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
|
|
876
929
|
const reason = isTimeout ? `timeout by supervisor, session=${session.id} preserved` : msg;
|
|
877
|
-
opts.warn(`[cli-bridge] ${model} failed (${reason}),
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
930
|
+
opts.warn(`[cli-bridge] ${model} failed (${reason}), trying fallback chain: ${fallbackChain.join(" → ")}`);
|
|
931
|
+
|
|
932
|
+
let chainSuccess = false;
|
|
933
|
+
for (const fallbackModel of fallbackChain) {
|
|
934
|
+
debugLog("FALLBACK", `${model} → ${fallbackModel}`, { reason: isTimeout ? "timeout" : "error", primaryDuration: Math.round(primaryDuration / 1000), chain: fallbackChain });
|
|
935
|
+
if (sseHeadersSent) {
|
|
936
|
+
res.write(`: fallback — trying ${fallbackModel}\n\n`);
|
|
937
|
+
}
|
|
938
|
+
const fallbackStart = Date.now();
|
|
939
|
+
try {
|
|
940
|
+
result = await routeToCliRunner(fallbackModel, cleanMessages, effectiveTimeout, routeOpts);
|
|
941
|
+
const fbCompTokens = estimateTokens(result.content ?? "");
|
|
942
|
+
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens, promptPreview);
|
|
943
|
+
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, true);
|
|
944
|
+
usedModel = fallbackModel;
|
|
945
|
+
debugLog("FALLBACK-OK", `${fallbackModel} succeeded in ${((Date.now() - fallbackStart) / 1000).toFixed(1)}s`, { toolCalls: result.tool_calls?.length ?? 0 });
|
|
946
|
+
opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded`);
|
|
947
|
+
chainSuccess = true;
|
|
948
|
+
break;
|
|
949
|
+
} catch (fallbackErr) {
|
|
950
|
+
const fbDuration = Date.now() - fallbackStart;
|
|
951
|
+
metrics.recordRequest(fallbackModel, fbDuration, false, estPromptTokens, undefined, promptPreview);
|
|
952
|
+
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, false);
|
|
953
|
+
const fallbackMsg = (fallbackErr as Error).message;
|
|
954
|
+
debugLog("FALLBACK-FAIL", `${fallbackModel} failed after ${(fbDuration / 1000).toFixed(1)}s`, { error: fallbackMsg.slice(0, 150) });
|
|
955
|
+
opts.warn(`[cli-bridge] fallback ${fallbackModel} failed: ${fallbackMsg.slice(0, 100)}`);
|
|
956
|
+
// Continue to next fallback in chain
|
|
957
|
+
}
|
|
882
958
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
const fbCompTokens = estimateTokens(result.content ?? "");
|
|
887
|
-
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens, promptPreview);
|
|
888
|
-
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, true);
|
|
889
|
-
usedModel = fallbackModel;
|
|
890
|
-
opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded (response will report original model: ${model})`);
|
|
891
|
-
} catch (fallbackErr) {
|
|
892
|
-
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens, undefined, promptPreview);
|
|
893
|
-
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, false);
|
|
894
|
-
const fallbackMsg = (fallbackErr as Error).message;
|
|
895
|
-
opts.warn(`[cli-bridge] fallback ${fallbackModel} also failed: ${fallbackMsg}`);
|
|
959
|
+
|
|
960
|
+
if (!chainSuccess) {
|
|
961
|
+
const chainStr = fallbackChain.join(", ");
|
|
896
962
|
if (sseHeadersSent) {
|
|
897
|
-
res.write(`data: ${JSON.stringify({ error: { message: `${model}
|
|
963
|
+
res.write(`data: ${JSON.stringify({ error: { message: `${model} and all fallbacks (${chainStr}) failed`, type: "cli_error" } })}\n\n`);
|
|
898
964
|
res.write("data: [DONE]\n\n");
|
|
899
965
|
res.end();
|
|
900
966
|
} else {
|
|
901
967
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
902
|
-
res.end(JSON.stringify({ error: { message: `${model}
|
|
968
|
+
res.end(JSON.stringify({ error: { message: `${model} and all fallbacks (${chainStr}) failed`, type: "cli_error" } }));
|
|
903
969
|
}
|
|
904
970
|
return;
|
|
905
971
|
}
|
package/src/status-template.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* status-template.ts
|
|
3
3
|
*
|
|
4
4
|
* Generates the HTML dashboard for the /status endpoint.
|
|
5
|
-
*
|
|
5
|
+
* v3.0: Sidebar navigation, section-based layout, JS polling, live log viewer.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { BrowserContext } from "playwright";
|
|
@@ -39,11 +39,11 @@ export interface StatusTemplateOptions {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function statusBadge(p: StatusProvider): { label: string; color: string; dot: string } {
|
|
42
|
-
if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "
|
|
43
|
-
if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "
|
|
44
|
-
if (p.expiry.startsWith("
|
|
45
|
-
if (p.expiry.startsWith("
|
|
46
|
-
return { label: "Logged in", color: "#3b82f6", dot: "
|
|
42
|
+
if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "\u{1F7E2}" };
|
|
43
|
+
if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "\u26AA" };
|
|
44
|
+
if (p.expiry.startsWith("\u26A0\uFE0F EXPIRED")) return { label: "Expired", color: "#ef4444", dot: "\u{1F534}" };
|
|
45
|
+
if (p.expiry.startsWith("\u{1F6A8}")) return { label: "Expiring soon", color: "#f59e0b", dot: "\u{1F7E1}" };
|
|
46
|
+
return { label: "Logged in", color: "#3b82f6", dot: "\u{1F535}" };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// ── Formatting helpers ──────────────────────────────────────────────────────
|
|
@@ -91,9 +91,46 @@ function truncateId(id: string): string {
|
|
|
91
91
|
return id.slice(0, 8) + "\u2026" + id.slice(-8);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// ──
|
|
94
|
+
// ── Section renderers (each returns an HTML fragment) ──────────────────────
|
|
95
95
|
|
|
96
|
-
function
|
|
96
|
+
export function renderProviders(providers: StatusProvider[]): string {
|
|
97
|
+
const rows = providers.map(p => {
|
|
98
|
+
const badge = statusBadge(p);
|
|
99
|
+
const expiryText = p.expiry
|
|
100
|
+
? p.expiry.replace(/[\u26A0\uFE0F\u{1F6A8}\u2705\u{1F550}]/gu, "").trim()
|
|
101
|
+
: `Not logged in \u2014 run <code>${p.loginCmd}</code>`;
|
|
102
|
+
return `
|
|
103
|
+
<tr>
|
|
104
|
+
<td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
|
|
105
|
+
<td style="padding:12px 16px">
|
|
106
|
+
<span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
|
|
107
|
+
border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
|
|
108
|
+
${badge.dot} ${badge.label}
|
|
109
|
+
</span>
|
|
110
|
+
</td>
|
|
111
|
+
<td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
|
|
112
|
+
<td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
|
|
113
|
+
</tr>`;
|
|
114
|
+
}).join("");
|
|
115
|
+
|
|
116
|
+
return `
|
|
117
|
+
<div class="card">
|
|
118
|
+
<div class="card-header">Web Session Providers</div>
|
|
119
|
+
<table>
|
|
120
|
+
<thead>
|
|
121
|
+
<tr class="table-head">
|
|
122
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
|
|
123
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
|
|
124
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
|
|
125
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
|
|
126
|
+
</tr>
|
|
127
|
+
</thead>
|
|
128
|
+
<tbody>${rows}</tbody>
|
|
129
|
+
</table>
|
|
130
|
+
</div>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function renderActiveRequests(active: ActiveRequest[]): string {
|
|
97
134
|
if (active.length === 0) {
|
|
98
135
|
return `
|
|
99
136
|
<div class="card">
|
|
@@ -135,9 +172,7 @@ function renderActiveRequests(active: ActiveRequest[]): string {
|
|
|
135
172
|
</div>`;
|
|
136
173
|
}
|
|
137
174
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
function renderRecentRequestLog(entries: RequestLogEntry[]): string {
|
|
175
|
+
export function renderRecentRequestLog(entries: RequestLogEntry[]): string {
|
|
141
176
|
if (entries.length === 0) {
|
|
142
177
|
return `
|
|
143
178
|
<div class="card">
|
|
@@ -180,9 +215,7 @@ function renderRecentRequestLog(entries: RequestLogEntry[]): string {
|
|
|
180
215
|
</div>`;
|
|
181
216
|
}
|
|
182
217
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
function renderFallbackHistory(events: FallbackEvent[]): string {
|
|
218
|
+
export function renderFallbackHistory(events: FallbackEvent[]): string {
|
|
186
219
|
if (events.length === 0) {
|
|
187
220
|
return `
|
|
188
221
|
<div class="card">
|
|
@@ -228,9 +261,7 @@ function renderFallbackHistory(events: FallbackEvent[]): string {
|
|
|
228
261
|
</div>`;
|
|
229
262
|
}
|
|
230
263
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
function renderProviderSessions(sessions: ProviderSession[]): string {
|
|
264
|
+
export function renderProviderSessions(sessions: ProviderSession[]): string {
|
|
234
265
|
if (sessions.length === 0) {
|
|
235
266
|
return `
|
|
236
267
|
<div class="card">
|
|
@@ -272,9 +303,7 @@ function renderProviderSessions(sessions: ProviderSession[]): string {
|
|
|
272
303
|
</div>`;
|
|
273
304
|
}
|
|
274
305
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
function renderTimeoutConfig(config: TimeoutConfigInfo): string {
|
|
306
|
+
export function renderTimeoutConfig(config: TimeoutConfigInfo): string {
|
|
278
307
|
const entries = Object.entries(config.defaults).sort(([a], [b]) => a.localeCompare(b));
|
|
279
308
|
const rows = entries.map(([model, ms]) => {
|
|
280
309
|
return `
|
|
@@ -303,13 +332,10 @@ function renderTimeoutConfig(config: TimeoutConfigInfo): string {
|
|
|
303
332
|
</div>`;
|
|
304
333
|
}
|
|
305
334
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
function renderMetricsSection(m: MetricsSnapshot): string {
|
|
335
|
+
export function renderMetricsSection(m: MetricsSnapshot): string {
|
|
309
336
|
const errorRate = m.totalRequests > 0 ? ((m.totalErrors / m.totalRequests) * 100).toFixed(1) : "0.0";
|
|
310
337
|
const totalTokens = m.models.reduce((sum, mod) => sum + mod.promptTokens + mod.completionTokens, 0);
|
|
311
338
|
|
|
312
|
-
// Summary cards
|
|
313
339
|
const summaryCards = `
|
|
314
340
|
<div class="summary-grid">
|
|
315
341
|
<div class="summary-card">
|
|
@@ -330,7 +356,6 @@ function renderMetricsSection(m: MetricsSnapshot): string {
|
|
|
330
356
|
</div>
|
|
331
357
|
</div>`;
|
|
332
358
|
|
|
333
|
-
// Per-model stats table
|
|
334
359
|
let modelRows: string;
|
|
335
360
|
if (m.models.length === 0) {
|
|
336
361
|
modelRows = `<tr><td colspan="6" style="padding:16px;color:#6b7280;text-align:center;font-style:italic">No requests recorded yet.</td></tr>`;
|
|
@@ -371,33 +396,15 @@ function renderMetricsSection(m: MetricsSnapshot): string {
|
|
|
371
396
|
return summaryCards + modelTable;
|
|
372
397
|
}
|
|
373
398
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const badge = statusBadge(p);
|
|
379
|
-
const expiryText = p.expiry
|
|
380
|
-
? p.expiry.replace(/[⚠️🚨✅🕐]/gu, "").trim()
|
|
381
|
-
: `Not logged in \u2014 run <code>${p.loginCmd}</code>`;
|
|
382
|
-
return `
|
|
383
|
-
<tr>
|
|
384
|
-
<td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
|
|
385
|
-
<td style="padding:12px 16px">
|
|
386
|
-
<span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
|
|
387
|
-
border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
|
|
388
|
-
${badge.dot} ${badge.label}
|
|
389
|
-
</span>
|
|
390
|
-
</td>
|
|
391
|
-
<td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
|
|
392
|
-
<td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
|
|
393
|
-
</tr>`;
|
|
394
|
-
}).join("");
|
|
395
|
-
|
|
399
|
+
function renderModels(
|
|
400
|
+
models: StatusTemplateOptions["models"],
|
|
401
|
+
modelCommands?: Record<string, string>,
|
|
402
|
+
): string {
|
|
396
403
|
const cliModels = models.filter(m => m.id.startsWith("cli-"));
|
|
397
404
|
const codexModels = models.filter(m => m.id.startsWith("openai-codex/"));
|
|
398
405
|
const webModels = models.filter(m => m.id.startsWith("web-"));
|
|
399
406
|
const localModels = models.filter(m => m.id.startsWith("local-"));
|
|
400
|
-
const cmds =
|
|
407
|
+
const cmds = modelCommands ?? {};
|
|
401
408
|
const modelList = (items: typeof models) =>
|
|
402
409
|
items.map(m => {
|
|
403
410
|
const cmd = cmds[m.id];
|
|
@@ -405,27 +412,114 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
405
412
|
return `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code class="model-id">${m.id}</code>${cmdBadge}</li>`;
|
|
406
413
|
}).join("");
|
|
407
414
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
415
|
+
return `
|
|
416
|
+
<div class="models">
|
|
417
|
+
<div class="card">
|
|
418
|
+
<div class="card-header">CLI Models (${cliModels.length})</div>
|
|
419
|
+
<ul>${modelList(cliModels)}</ul>
|
|
420
|
+
<div class="card-header">Codex Models (${codexModels.length})</div>
|
|
421
|
+
<ul>${modelList(codexModels)}</ul>
|
|
422
|
+
</div>
|
|
423
|
+
<div class="card">
|
|
424
|
+
<div class="card-header">Web Session Models (${webModels.length})</div>
|
|
425
|
+
<ul>${modelList(webModels)}</ul>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="card">
|
|
428
|
+
<div class="card-header">Local Models (${localModels.length})</div>
|
|
429
|
+
<ul>${modelList(localModels)}</ul>
|
|
430
|
+
</div>
|
|
431
|
+
</div>`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function renderLogsSection(): string {
|
|
435
|
+
return `
|
|
436
|
+
<div class="card" style="height:calc(100vh - 160px);display:flex;flex-direction:column">
|
|
437
|
+
<div class="card-header" style="flex-shrink:0;display:flex;justify-content:space-between;align-items:center">
|
|
438
|
+
<span>Live Logs <span id="log-status" class="badge badge-ok" style="margin-left:8px">connecting...</span></span>
|
|
439
|
+
<span>
|
|
440
|
+
<button onclick="toggleLogPause()" id="log-pause-btn" style="background:#1e2130;color:#9ca3af;border:1px solid #2d3148;border-radius:6px;padding:3px 10px;font-size:11px;cursor:pointer;margin-right:4px">Pause</button>
|
|
441
|
+
<button onclick="clearLogs()" style="background:#1e2130;color:#9ca3af;border:1px solid #2d3148;border-radius:6px;padding:3px 10px;font-size:11px;cursor:pointer">Clear</button>
|
|
442
|
+
</span>
|
|
443
|
+
</div>
|
|
444
|
+
<pre id="log-output" style="flex:1;overflow-y:auto;padding:12px 16px;font-size:12px;line-height:1.6;color:#9ca3af;margin:0;white-space:pre-wrap;word-break:break-all"></pre>
|
|
445
|
+
</div>`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── Dashboard data (for AJAX polling) ──────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
export interface DashboardSections {
|
|
451
|
+
providers: string;
|
|
452
|
+
metrics: string;
|
|
453
|
+
active: string;
|
|
454
|
+
recent: string;
|
|
455
|
+
fallbacks: string;
|
|
456
|
+
sessions: string;
|
|
457
|
+
timeouts: string;
|
|
458
|
+
models: string;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function renderDashboardData(opts: StatusTemplateOptions): DashboardSections {
|
|
462
|
+
return {
|
|
463
|
+
providers: renderProviders(opts.providers),
|
|
464
|
+
metrics: opts.metrics ? renderMetricsSection(opts.metrics) : "",
|
|
465
|
+
active: opts.activeRequests ? renderActiveRequests(opts.activeRequests) : "",
|
|
466
|
+
recent: opts.metrics ? renderRecentRequestLog(opts.metrics.recentRequests) : "",
|
|
467
|
+
fallbacks: opts.metrics ? renderFallbackHistory(opts.metrics.fallbackHistory) : "",
|
|
468
|
+
sessions: opts.providerSessionsList ? renderProviderSessions(opts.providerSessionsList) : "",
|
|
469
|
+
timeouts: opts.timeoutConfig ? renderTimeoutConfig(opts.timeoutConfig) : "",
|
|
470
|
+
models: renderModels(opts.models, opts.modelCommands),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── Navigation definitions ────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
const NAV_ITEMS = [
|
|
477
|
+
{ id: "overview", label: "Overview", icon: "\u25C9" },
|
|
478
|
+
{ id: "providers", label: "Providers", icon: "\u26A1" },
|
|
479
|
+
{ id: "active", label: "Active", icon: "\u25CF" },
|
|
480
|
+
{ id: "recent", label: "Requests", icon: "\u2630" },
|
|
481
|
+
{ id: "fallbacks", label: "Fallbacks", icon: "\u21C4" },
|
|
482
|
+
{ id: "sessions", label: "Sessions", icon: "\u29BF" },
|
|
483
|
+
{ id: "logs", label: "Live Logs", icon: "\u276F" },
|
|
484
|
+
{ id: "timeouts", label: "Timeouts", icon: "\u23F1" },
|
|
485
|
+
{ id: "models", label: "Models", icon: "\u2726" },
|
|
486
|
+
] as const;
|
|
487
|
+
|
|
488
|
+
// ── Full page render ──────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
491
|
+
const { version, port } = opts;
|
|
492
|
+
const sections = renderDashboardData(opts);
|
|
493
|
+
|
|
494
|
+
const navHtml = NAV_ITEMS.map(n =>
|
|
495
|
+
`<a href="#${n.id}" class="nav-item" data-nav="${n.id}" onclick="showSection('${n.id}')">${n.icon} ${n.label}</a>`
|
|
496
|
+
).join("\n ");
|
|
414
497
|
|
|
415
498
|
return `<!DOCTYPE html>
|
|
416
499
|
<html lang="en">
|
|
417
500
|
<head>
|
|
418
501
|
<meta charset="UTF-8">
|
|
419
502
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
420
|
-
<title>CLI Bridge
|
|
421
|
-
<meta http-equiv="refresh" content="10">
|
|
503
|
+
<title>CLI Bridge Dashboard</title>
|
|
422
504
|
<style>
|
|
423
505
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
424
|
-
body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
.
|
|
428
|
-
.
|
|
506
|
+
body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; display: grid; grid-template-columns: 200px 1fr; }
|
|
507
|
+
|
|
508
|
+
/* ── Sidebar ── */
|
|
509
|
+
.sidebar { background: #13151f; border-right: 1px solid #2d3148; padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; width: 200px; overflow-y: auto; z-index: 10; }
|
|
510
|
+
.sidebar-title { padding: 0 16px 16px; font-size: 16px; font-weight: 700; color: #f9fafb; border-bottom: 1px solid #2d3148; margin-bottom: 8px; }
|
|
511
|
+
.sidebar-version { display: block; font-size: 11px; color: #6b7280; font-weight: 400; margin-top: 2px; }
|
|
512
|
+
.nav-item { display: flex; align-items: center; gap: 8px; padding: 8px 16px; color: #9ca3af; text-decoration: none; font-size: 13px; transition: all 0.15s; border-left: 3px solid transparent; }
|
|
513
|
+
.nav-item:hover { color: #e5e7eb; background: #1a1d27; }
|
|
514
|
+
.nav-item.active { color: #3b82f6; background: #1e2130; border-left-color: #3b82f6; font-weight: 600; }
|
|
515
|
+
|
|
516
|
+
/* ── Main content ── */
|
|
517
|
+
.main { grid-column: 2; padding: 24px; min-height: 100vh; }
|
|
518
|
+
.section { display: none; }
|
|
519
|
+
.section.active { display: block; }
|
|
520
|
+
.section-title { font-size: 18px; font-weight: 700; color: #f9fafb; margin-bottom: 16px; }
|
|
521
|
+
|
|
522
|
+
/* ── Cards & tables ── */
|
|
429
523
|
.card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
|
|
430
524
|
.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; }
|
|
431
525
|
table { width: 100%; border-collapse: collapse; }
|
|
@@ -433,7 +527,6 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
433
527
|
.table-head { background: #13151f; }
|
|
434
528
|
.models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
435
529
|
ul { list-style: none; padding: 12px 16px; }
|
|
436
|
-
.footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
437
530
|
code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
|
|
438
531
|
.model-id { color: #93c5fd; }
|
|
439
532
|
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
|
@@ -453,62 +546,211 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
453
546
|
.pulse-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse 1.5s ease-in-out infinite; }
|
|
454
547
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
455
548
|
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
549
|
+
.footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
550
|
+
|
|
551
|
+
/* ── Log colors ── */
|
|
552
|
+
.log-fail { color: #ef4444; }
|
|
553
|
+
.log-ok { color: #22c55e; }
|
|
554
|
+
.log-warn { color: #f59e0b; }
|
|
555
|
+
.log-kill { color: #f97316; }
|
|
556
|
+
.log-route { color: #8b5cf6; }
|
|
557
|
+
.log-dim { color: #6b7280; }
|
|
558
|
+
|
|
559
|
+
/* ── Mobile ── */
|
|
560
|
+
.mobile-toggle { display: none; position: fixed; top: 8px; left: 8px; z-index: 20; background: #1a1d27; border: 1px solid #2d3148; border-radius: 8px; padding: 6px 10px; color: #e5e7eb; font-size: 18px; cursor: pointer; }
|
|
456
561
|
@media (max-width: 768px) {
|
|
562
|
+
body { grid-template-columns: 1fr; }
|
|
563
|
+
.sidebar { transform: translateX(-100%); transition: transform 0.2s; }
|
|
564
|
+
.sidebar.open { transform: translateX(0); }
|
|
565
|
+
.main { grid-column: 1; }
|
|
566
|
+
.mobile-toggle { display: block; }
|
|
457
567
|
.summary-grid { grid-template-columns: repeat(2, 1fr); }
|
|
458
568
|
.models, .two-col { grid-template-columns: 1fr; }
|
|
459
569
|
}
|
|
460
570
|
</style>
|
|
461
571
|
</head>
|
|
462
572
|
<body>
|
|
463
|
-
<
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
<
|
|
469
|
-
<
|
|
470
|
-
<
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
573
|
+
<button class="mobile-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')">\u2630</button>
|
|
574
|
+
|
|
575
|
+
<nav class="sidebar">
|
|
576
|
+
<div class="sidebar-title">CLI Bridge<span class="sidebar-version">v${version} \u00b7 :${port}</span></div>
|
|
577
|
+
${navHtml}
|
|
578
|
+
<div style="padding:16px;margin-top:auto">
|
|
579
|
+
<div style="font-size:11px;color:#374151">
|
|
580
|
+
<a href="/v1/models" style="color:#4b5563;text-decoration:none">/v1/models</a> \u00b7
|
|
581
|
+
<a href="/healthz" style="color:#4b5563;text-decoration:none">/healthz</a>
|
|
582
|
+
</div>
|
|
583
|
+
</div>
|
|
584
|
+
</nav>
|
|
585
|
+
|
|
586
|
+
<main class="main">
|
|
587
|
+
<section data-section="overview" class="section active">
|
|
588
|
+
<h2 class="section-title">Overview</h2>
|
|
589
|
+
<div id="s-metrics">${sections.metrics}</div>
|
|
590
|
+
<div id="s-active">${sections.active}</div>
|
|
591
|
+
</section>
|
|
592
|
+
|
|
593
|
+
<section data-section="providers" class="section">
|
|
594
|
+
<h2 class="section-title">Providers</h2>
|
|
595
|
+
<div id="s-providers">${sections.providers}</div>
|
|
596
|
+
</section>
|
|
597
|
+
|
|
598
|
+
<section data-section="active" class="section">
|
|
599
|
+
<h2 class="section-title">Active Requests</h2>
|
|
600
|
+
<div id="s-active2">${sections.active}</div>
|
|
601
|
+
</section>
|
|
602
|
+
|
|
603
|
+
<section data-section="recent" class="section">
|
|
604
|
+
<h2 class="section-title">Recent Requests</h2>
|
|
605
|
+
<div id="s-recent">${sections.recent}</div>
|
|
606
|
+
</section>
|
|
607
|
+
|
|
608
|
+
<section data-section="fallbacks" class="section">
|
|
609
|
+
<h2 class="section-title">Fallbacks & Sessions</h2>
|
|
610
|
+
<div class="two-col">
|
|
611
|
+
<div id="s-fallbacks">${sections.fallbacks}</div>
|
|
612
|
+
<div id="s-sessions">${sections.sessions}</div>
|
|
613
|
+
</div>
|
|
614
|
+
</section>
|
|
615
|
+
|
|
616
|
+
<section data-section="sessions" class="section">
|
|
617
|
+
<h2 class="section-title">Provider Sessions</h2>
|
|
618
|
+
<div id="s-sessions2">${sections.sessions}</div>
|
|
619
|
+
</section>
|
|
620
|
+
|
|
621
|
+
<section data-section="logs" class="section">
|
|
622
|
+
<h2 class="section-title">Live Logs</h2>
|
|
623
|
+
${renderLogsSection()}
|
|
624
|
+
</section>
|
|
625
|
+
|
|
626
|
+
<section data-section="timeouts" class="section">
|
|
627
|
+
<h2 class="section-title">Timeout Configuration</h2>
|
|
628
|
+
<div id="s-timeouts">${sections.timeouts}</div>
|
|
629
|
+
</section>
|
|
630
|
+
|
|
631
|
+
<section data-section="models" class="section">
|
|
632
|
+
<h2 class="section-title">Models</h2>
|
|
633
|
+
<div id="s-models">${sections.models}</div>
|
|
634
|
+
</section>
|
|
635
|
+
|
|
636
|
+
<p class="footer">openclaw-cli-bridge-elvatis v${version}</p>
|
|
637
|
+
</main>
|
|
638
|
+
|
|
639
|
+
<script>
|
|
640
|
+
// ── Section switching ──
|
|
641
|
+
function showSection(id) {
|
|
642
|
+
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
|
643
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
644
|
+
const sec = document.querySelector('[data-section="' + id + '"]');
|
|
645
|
+
const nav = document.querySelector('[data-nav="' + id + '"]');
|
|
646
|
+
if (sec) sec.classList.add('active');
|
|
647
|
+
if (nav) nav.classList.add('active');
|
|
648
|
+
location.hash = id;
|
|
649
|
+
// Close mobile sidebar
|
|
650
|
+
document.querySelector('.sidebar').classList.remove('open');
|
|
651
|
+
}
|
|
480
652
|
|
|
481
|
-
|
|
653
|
+
// Init from hash
|
|
654
|
+
(function() {
|
|
655
|
+
var hash = location.hash.slice(1) || 'overview';
|
|
656
|
+
showSection(hash);
|
|
657
|
+
})();
|
|
658
|
+
|
|
659
|
+
// ── AJAX polling (replaces meta-refresh) ──
|
|
660
|
+
setInterval(function() {
|
|
661
|
+
fetch('/api/dashboard-data').then(function(r) { return r.json(); }).then(function(data) {
|
|
662
|
+
var map = {
|
|
663
|
+
's-metrics': 'metrics', 's-active': 'active', 's-active2': 'active',
|
|
664
|
+
's-providers': 'providers', 's-recent': 'recent',
|
|
665
|
+
's-fallbacks': 'fallbacks', 's-sessions': 'sessions', 's-sessions2': 'sessions',
|
|
666
|
+
's-timeouts': 'timeouts', 's-models': 'models'
|
|
667
|
+
};
|
|
668
|
+
for (var elId in map) {
|
|
669
|
+
var el = document.getElementById(elId);
|
|
670
|
+
if (el && data[map[elId]]) el.innerHTML = data[map[elId]];
|
|
671
|
+
}
|
|
672
|
+
}).catch(function() { /* silent fail on poll */ });
|
|
673
|
+
}, 10000);
|
|
674
|
+
|
|
675
|
+
// ── Live log viewer ──
|
|
676
|
+
var logOutput = document.getElementById('log-output');
|
|
677
|
+
var logStatus = document.getElementById('log-status');
|
|
678
|
+
var logPaused = false;
|
|
679
|
+
var logSource = null;
|
|
680
|
+
var logLineCount = 0;
|
|
681
|
+
var MAX_LOG_LINES = 500;
|
|
682
|
+
var autoScroll = true;
|
|
683
|
+
|
|
684
|
+
function colorLogLine(line) {
|
|
685
|
+
if (line.indexOf('[FAIL]') !== -1 || line.indexOf('[ERROR]') !== -1) return '<span class="log-fail">' + line + '</span>';
|
|
686
|
+
if (line.indexOf('[OK]') !== -1) return '<span class="log-ok">' + line + '</span>';
|
|
687
|
+
if (line.indexOf('[FALLBACK]') !== -1 || line.indexOf('[WARN]') !== -1) return '<span class="log-warn">' + line + '</span>';
|
|
688
|
+
if (line.indexOf('[KILL]') !== -1) return '<span class="log-kill">' + line + '</span>';
|
|
689
|
+
if (line.indexOf('[TOOL-ROUTE]') !== -1 || line.indexOf('[TASK-ROUTE]') !== -1) return '<span class="log-route">' + line + '</span>';
|
|
690
|
+
if (line.indexOf('[TIMEOUT]') !== -1 || line.indexOf('[CLAUDE]') !== -1) return '<span class="log-dim">' + line + '</span>';
|
|
691
|
+
return line;
|
|
692
|
+
}
|
|
482
693
|
|
|
483
|
-
|
|
694
|
+
function appendLog(text) {
|
|
695
|
+
if (!logOutput) return;
|
|
696
|
+
var lines = text.split('\\n').filter(function(l) { return l.trim(); });
|
|
697
|
+
// Newest on top — prepend lines in reverse order
|
|
698
|
+
var html = '';
|
|
699
|
+
lines.forEach(function(line) {
|
|
700
|
+
html = colorLogLine(line.replace(/</g, '<').replace(/>/g, '>')) + '\\n' + html;
|
|
701
|
+
logLineCount++;
|
|
702
|
+
});
|
|
703
|
+
logOutput.innerHTML = html + logOutput.innerHTML;
|
|
704
|
+
// Trim old lines from bottom
|
|
705
|
+
while (logLineCount > MAX_LOG_LINES) {
|
|
706
|
+
var idx = logOutput.innerHTML.lastIndexOf('\\n');
|
|
707
|
+
if (idx === -1) break;
|
|
708
|
+
logOutput.innerHTML = logOutput.innerHTML.slice(0, idx);
|
|
709
|
+
logLineCount--;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
484
712
|
|
|
485
|
-
|
|
713
|
+
function connectLog() {
|
|
714
|
+
if (logSource) logSource.close();
|
|
715
|
+
logSource = new EventSource('/api/logs/stream');
|
|
716
|
+
logSource.onopen = function() {
|
|
717
|
+
if (logStatus) { logStatus.textContent = 'connected'; logStatus.className = 'badge badge-ok'; }
|
|
718
|
+
};
|
|
719
|
+
logSource.onmessage = function(e) { appendLog(e.data); };
|
|
720
|
+
logSource.onerror = function() {
|
|
721
|
+
if (logStatus) { logStatus.textContent = 'disconnected'; logStatus.className = 'badge badge-error'; }
|
|
722
|
+
// Reconnect after 3s
|
|
723
|
+
setTimeout(function() { if (!logPaused) connectLog(); }, 3000);
|
|
724
|
+
};
|
|
725
|
+
}
|
|
486
726
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
727
|
+
function toggleLogPause() {
|
|
728
|
+
logPaused = !logPaused;
|
|
729
|
+
var btn = document.getElementById('log-pause-btn');
|
|
730
|
+
if (logPaused) {
|
|
731
|
+
if (logSource) logSource.close();
|
|
732
|
+
if (btn) btn.textContent = 'Resume';
|
|
733
|
+
if (logStatus) { logStatus.textContent = 'paused'; logStatus.className = 'badge badge-warn'; }
|
|
734
|
+
} else {
|
|
735
|
+
connectLog();
|
|
736
|
+
if (btn) btn.textContent = 'Pause';
|
|
737
|
+
}
|
|
738
|
+
}
|
|
491
739
|
|
|
492
|
-
|
|
740
|
+
function clearLogs() {
|
|
741
|
+
if (logOutput) { logOutput.innerHTML = ''; logLineCount = 0; }
|
|
742
|
+
}
|
|
493
743
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
</div>
|
|
501
|
-
<div class="card">
|
|
502
|
-
<div class="card-header">Web Session Models (${webModels.length})</div>
|
|
503
|
-
<ul>${modelList(webModels)}</ul>
|
|
504
|
-
</div>
|
|
505
|
-
<div class="card">
|
|
506
|
-
<div class="card-header">Local Models (${localModels.length})</div>
|
|
507
|
-
<ul>${modelList(localModels)}</ul>
|
|
508
|
-
</div>
|
|
509
|
-
</div>
|
|
744
|
+
// Auto-scroll detection
|
|
745
|
+
if (logOutput) {
|
|
746
|
+
logOutput.addEventListener('scroll', function() {
|
|
747
|
+
autoScroll = (logOutput.scrollTop + logOutput.clientHeight >= logOutput.scrollHeight - 50);
|
|
748
|
+
});
|
|
749
|
+
}
|
|
510
750
|
|
|
511
|
-
|
|
751
|
+
// Start log connection
|
|
752
|
+
connectLog();
|
|
753
|
+
</script>
|
|
512
754
|
</body>
|
|
513
755
|
</html>`;
|
|
514
756
|
}
|
package/test/config.test.ts
CHANGED
|
@@ -71,11 +71,15 @@ describe("config.ts exports", () => {
|
|
|
71
71
|
expect(DEFAULT_MODEL_TIMEOUTS["gemini-api/gemini-2.5-flash"]).toBe(180_000);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("exports model fallback chains", () => {
|
|
75
|
-
expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-sonnet-4-6"]).
|
|
76
|
-
expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-opus-4-6"]).
|
|
77
|
-
expect(DEFAULT_MODEL_FALLBACKS["cli-gemini/gemini-2.5-pro"]).
|
|
78
|
-
expect(DEFAULT_MODEL_FALLBACKS["gemini-api/gemini-2.5-pro"]).
|
|
74
|
+
it("exports model fallback chains as arrays", () => {
|
|
75
|
+
expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-sonnet-4-6"]).toEqual(["cli-claude/claude-haiku-4-5", "cli-gemini/gemini-2.5-flash", "openai-codex/gpt-5.3-codex"]);
|
|
76
|
+
expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-opus-4-6"]).toContain("cli-claude/claude-sonnet-4-6");
|
|
77
|
+
expect(DEFAULT_MODEL_FALLBACKS["cli-gemini/gemini-2.5-pro"]).toContain("cli-gemini/gemini-2.5-flash");
|
|
78
|
+
expect(DEFAULT_MODEL_FALLBACKS["gemini-api/gemini-2.5-pro"]).toContain("gemini-api/gemini-2.5-flash");
|
|
79
|
+
// All values must be arrays
|
|
80
|
+
for (const chain of Object.values(DEFAULT_MODEL_FALLBACKS)) {
|
|
81
|
+
expect(Array.isArray(chain)).toBe(true);
|
|
82
|
+
}
|
|
79
83
|
});
|
|
80
84
|
|
|
81
85
|
it("exports path constants rooted in ~/.openclaw", () => {
|