@elvatis_com/openclaw-cli-bridge-elvatis 2.8.4 → 2.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/README.md +14 -1
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +4 -4
- package/package.json +1 -1
- package/src/metrics.ts +67 -0
- package/src/proxy-server.ts +47 -6
- package/src/status-template.ts +275 -13
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:** `2.
|
|
5
|
+
**Current version:** `2.9.0`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -406,6 +406,19 @@ npm run ci # lint + typecheck + test
|
|
|
406
406
|
|
|
407
407
|
## Changelog
|
|
408
408
|
|
|
409
|
+
### v2.9.0
|
|
410
|
+
- **feat:** enhanced `/status` dashboard with 5 new panels:
|
|
411
|
+
- **Active Requests**: live in-flight requests with model, elapsed time, message/tool count, prompt preview
|
|
412
|
+
- **Recent Request Log**: last 20 requests with latency, success/fail, prompt preview, token counts
|
|
413
|
+
- **Fallback History**: last 10 fallback events with reason, timing, and outcome
|
|
414
|
+
- **Provider Sessions**: CLI session state (active/idle/expired), run count, timeout count
|
|
415
|
+
- **Timeout Configuration**: per-model base timeouts and dynamic scaling formula
|
|
416
|
+
- **feat:** auto-refresh reduced from 30s to 10s for more responsive monitoring
|
|
417
|
+
- **feat:** responsive two-column layout for fallback history and provider sessions
|
|
418
|
+
|
|
419
|
+
### v2.8.5
|
|
420
|
+
- **fix:** sync `openclaw.plugin.json` configSchema defaults with code: Sonnet/Opus 300s to 420s, Haiku 90s to 120s. The schema `default` block was overriding `DEFAULT_MODEL_TIMEOUTS` via `cfg.modelTimeouts`, making all code-level timeout bumps ineffective.
|
|
421
|
+
|
|
409
422
|
### v2.8.4
|
|
410
423
|
- **fix:** rebuild dist with correct per-model timeouts (v2.8.2 bumped source to 420s but dist was never recompiled, so gateway kept using 300s)
|
|
411
424
|
- **fix:** recommend `agents.defaults.llm.idleTimeoutSeconds` bump 360s to 600s to prevent gateway-level timeout from killing Sonnet before plugin timeout fires
|
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": "2.
|
|
5
|
+
"version": "2.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": [
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"type": "number"
|
|
44
44
|
},
|
|
45
45
|
"default": {
|
|
46
|
-
"cli-claude/claude-opus-4-6":
|
|
47
|
-
"cli-claude/claude-sonnet-4-6":
|
|
48
|
-
"cli-claude/claude-haiku-4-5":
|
|
46
|
+
"cli-claude/claude-opus-4-6": 420000,
|
|
47
|
+
"cli-claude/claude-sonnet-4-6": 420000,
|
|
48
|
+
"cli-claude/claude-haiku-4-5": 120000,
|
|
49
49
|
"cli-gemini/gemini-2.5-pro": 300000,
|
|
50
50
|
"cli-gemini/gemini-2.5-flash": 180000,
|
|
51
51
|
"cli-gemini/gemini-3-pro-preview": 300000,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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": {
|
package/src/metrics.ts
CHANGED
|
@@ -23,11 +23,32 @@ export interface ModelMetrics {
|
|
|
23
23
|
lastRequestAt: number | null;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export interface RequestLogEntry {
|
|
27
|
+
timestamp: number;
|
|
28
|
+
model: string;
|
|
29
|
+
latencyMs: number;
|
|
30
|
+
success: boolean;
|
|
31
|
+
promptPreview: string;
|
|
32
|
+
promptTokens: number;
|
|
33
|
+
completionTokens: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FallbackEvent {
|
|
37
|
+
timestamp: number;
|
|
38
|
+
originalModel: string;
|
|
39
|
+
fallbackModel: string;
|
|
40
|
+
reason: "timeout" | "error";
|
|
41
|
+
failedDurationMs: number;
|
|
42
|
+
fallbackSuccess: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
export interface MetricsSnapshot {
|
|
27
46
|
startedAt: number;
|
|
28
47
|
totalRequests: number;
|
|
29
48
|
totalErrors: number;
|
|
30
49
|
models: ModelMetrics[]; // sorted by requests desc
|
|
50
|
+
recentRequests: RequestLogEntry[];
|
|
51
|
+
fallbackHistory: FallbackEvent[];
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
// ── Token estimation ────────────────────────────────────────────────────────
|
|
@@ -42,6 +63,19 @@ export function estimateTokens(text: string): number {
|
|
|
42
63
|
return Math.ceil(text.length / 4);
|
|
43
64
|
}
|
|
44
65
|
|
|
66
|
+
// ── Circular buffer ─────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
class CircularBuffer<T> {
|
|
69
|
+
private items: T[] = [];
|
|
70
|
+
constructor(private capacity: number) {}
|
|
71
|
+
push(item: T): void {
|
|
72
|
+
if (this.items.length >= this.capacity) this.items.shift();
|
|
73
|
+
this.items.push(item);
|
|
74
|
+
}
|
|
75
|
+
toArray(): T[] { return [...this.items]; }
|
|
76
|
+
clear(): void { this.items.length = 0; }
|
|
77
|
+
}
|
|
78
|
+
|
|
45
79
|
// ── Persistence format ──────────────────────────────────────────────────────
|
|
46
80
|
|
|
47
81
|
interface PersistedMetrics {
|
|
@@ -57,6 +91,8 @@ class MetricsCollector {
|
|
|
57
91
|
private data = new Map<string, ModelMetrics>();
|
|
58
92
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
59
93
|
private dirty = false;
|
|
94
|
+
private recentRequests = new CircularBuffer<RequestLogEntry>(20);
|
|
95
|
+
private fallbackEvents = new CircularBuffer<FallbackEvent>(10);
|
|
60
96
|
|
|
61
97
|
constructor() {
|
|
62
98
|
this.load();
|
|
@@ -68,6 +104,7 @@ class MetricsCollector {
|
|
|
68
104
|
success: boolean,
|
|
69
105
|
promptTokens?: number,
|
|
70
106
|
completionTokens?: number,
|
|
107
|
+
promptPreview?: string,
|
|
71
108
|
): void {
|
|
72
109
|
let entry = this.data.get(model);
|
|
73
110
|
if (!entry) {
|
|
@@ -88,6 +125,15 @@ class MetricsCollector {
|
|
|
88
125
|
if (promptTokens) entry.promptTokens += promptTokens;
|
|
89
126
|
if (completionTokens) entry.completionTokens += completionTokens;
|
|
90
127
|
entry.lastRequestAt = Date.now();
|
|
128
|
+
this.recentRequests.push({
|
|
129
|
+
timestamp: Date.now(),
|
|
130
|
+
model,
|
|
131
|
+
latencyMs: durationMs,
|
|
132
|
+
success,
|
|
133
|
+
promptPreview: promptPreview ?? "",
|
|
134
|
+
promptTokens: promptTokens ?? 0,
|
|
135
|
+
completionTokens: completionTokens ?? 0,
|
|
136
|
+
});
|
|
91
137
|
this.scheduleSave();
|
|
92
138
|
}
|
|
93
139
|
|
|
@@ -109,12 +155,33 @@ class MetricsCollector {
|
|
|
109
155
|
totalRequests,
|
|
110
156
|
totalErrors,
|
|
111
157
|
models,
|
|
158
|
+
recentRequests: this.recentRequests.toArray(),
|
|
159
|
+
fallbackHistory: this.fallbackEvents.toArray(),
|
|
112
160
|
};
|
|
113
161
|
}
|
|
114
162
|
|
|
163
|
+
recordFallback(
|
|
164
|
+
originalModel: string,
|
|
165
|
+
fallbackModel: string,
|
|
166
|
+
reason: "timeout" | "error",
|
|
167
|
+
failedDurationMs: number,
|
|
168
|
+
fallbackSuccess: boolean,
|
|
169
|
+
): void {
|
|
170
|
+
this.fallbackEvents.push({
|
|
171
|
+
timestamp: Date.now(),
|
|
172
|
+
originalModel,
|
|
173
|
+
fallbackModel,
|
|
174
|
+
reason,
|
|
175
|
+
failedDurationMs,
|
|
176
|
+
fallbackSuccess,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
115
180
|
reset(): void {
|
|
116
181
|
this.startedAt = Date.now();
|
|
117
182
|
this.data.clear();
|
|
183
|
+
this.recentRequests.clear();
|
|
184
|
+
this.fallbackEvents.clear();
|
|
118
185
|
this.saveNow();
|
|
119
186
|
}
|
|
120
187
|
|
package/src/proxy-server.ts
CHANGED
|
@@ -31,8 +31,26 @@ import {
|
|
|
31
31
|
DEFAULT_BITNET_SERVER_URL,
|
|
32
32
|
BITNET_MAX_MESSAGES,
|
|
33
33
|
BITNET_SYSTEM_PROMPT,
|
|
34
|
+
DEFAULT_MODEL_TIMEOUTS,
|
|
34
35
|
} from "./config.js";
|
|
35
36
|
|
|
37
|
+
// ── Active request tracking ─────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface ActiveRequest {
|
|
40
|
+
id: string;
|
|
41
|
+
model: string;
|
|
42
|
+
startedAt: number;
|
|
43
|
+
messageCount: number;
|
|
44
|
+
toolCount: number;
|
|
45
|
+
promptPreview: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const activeRequests = new Map<string, ActiveRequest>();
|
|
49
|
+
|
|
50
|
+
export function getActiveRequests(): ActiveRequest[] {
|
|
51
|
+
return [...activeRequests.values()];
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
|
|
37
55
|
export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
|
|
38
56
|
export type GrokCompleteResult = Awaited<ReturnType<typeof grokComplete>>;
|
|
@@ -276,7 +294,20 @@ async function handleRequest(
|
|
|
276
294
|
{ name: "ChatGPT", icon: "◉", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
|
|
277
295
|
];
|
|
278
296
|
|
|
279
|
-
const html = renderStatusPage({
|
|
297
|
+
const html = renderStatusPage({
|
|
298
|
+
version, port: opts.port, providers, models: CLI_MODELS,
|
|
299
|
+
modelCommands: opts.modelCommands,
|
|
300
|
+
metrics: metrics.getMetrics(),
|
|
301
|
+
activeRequests: getActiveRequests(),
|
|
302
|
+
providerSessionsList: providerSessions.listSessions(),
|
|
303
|
+
timeoutConfig: {
|
|
304
|
+
defaults: { ...DEFAULT_MODEL_TIMEOUTS, ...(opts.modelTimeouts ?? {}) },
|
|
305
|
+
baseDefault: opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS,
|
|
306
|
+
maxEffective: MAX_EFFECTIVE_TIMEOUT_MS,
|
|
307
|
+
perExtraMsg: TIMEOUT_PER_EXTRA_MSG_MS,
|
|
308
|
+
perTool: TIMEOUT_PER_TOOL_MS,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
280
311
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
281
312
|
res.end(html);
|
|
282
313
|
return;
|
|
@@ -354,6 +385,13 @@ async function handleRequest(
|
|
|
354
385
|
const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
|
|
355
386
|
const created = Math.floor(Date.now() / 1000);
|
|
356
387
|
|
|
388
|
+
// Extract prompt preview from last user message for dashboard
|
|
389
|
+
const lastUserMsg = [...cleanMessages].reverse().find(m => m.role === "user");
|
|
390
|
+
const promptPreview = typeof lastUserMsg?.content === "string" ? lastUserMsg.content.slice(0, 80) : "";
|
|
391
|
+
|
|
392
|
+
// Track active request for dashboard
|
|
393
|
+
activeRequests.set(id, { id, model, startedAt: Date.now(), messageCount: cleanMessages.length, toolCount: tools?.length ?? 0, promptPreview });
|
|
394
|
+
|
|
357
395
|
// ── Grok web-session routing ──────────────────────────────────────────────
|
|
358
396
|
if (model.startsWith("web-grok/")) {
|
|
359
397
|
let grokCtx = opts.getGrokContext?.() ?? null;
|
|
@@ -787,7 +825,7 @@ async function handleRequest(
|
|
|
787
825
|
try {
|
|
788
826
|
result = await routeToCliRunner(model, cleanMessages, effectiveTimeout, routeOpts);
|
|
789
827
|
const estCompletionTokens = estimateTokens(result.content ?? "");
|
|
790
|
-
metrics.recordRequest(model, Date.now() - cliStart, true, estPromptTokens, estCompletionTokens);
|
|
828
|
+
metrics.recordRequest(model, Date.now() - cliStart, true, estPromptTokens, estCompletionTokens, promptPreview);
|
|
791
829
|
providerSessions.recordRun(session.id, false);
|
|
792
830
|
} catch (err) {
|
|
793
831
|
const primaryDuration = Date.now() - cliStart;
|
|
@@ -798,18 +836,20 @@ async function handleRequest(
|
|
|
798
836
|
providerSessions.recordRun(session.id, isTimeout);
|
|
799
837
|
const fallbackModel = opts.modelFallbacks?.[model];
|
|
800
838
|
if (fallbackModel) {
|
|
801
|
-
metrics.recordRequest(model, primaryDuration, false, estPromptTokens);
|
|
839
|
+
metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
|
|
802
840
|
const reason = isTimeout ? `timeout by supervisor, session=${session.id} preserved` : msg;
|
|
803
841
|
opts.warn(`[cli-bridge] ${model} failed (${reason}), falling back to ${fallbackModel}`);
|
|
804
842
|
const fallbackStart = Date.now();
|
|
805
843
|
try {
|
|
806
844
|
result = await routeToCliRunner(fallbackModel, cleanMessages, effectiveTimeout, routeOpts);
|
|
807
845
|
const fbCompTokens = estimateTokens(result.content ?? "");
|
|
808
|
-
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens);
|
|
846
|
+
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens, promptPreview);
|
|
847
|
+
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, true);
|
|
809
848
|
usedModel = fallbackModel;
|
|
810
849
|
opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded (response will report original model: ${model})`);
|
|
811
850
|
} catch (fallbackErr) {
|
|
812
|
-
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens);
|
|
851
|
+
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens, undefined, promptPreview);
|
|
852
|
+
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, false);
|
|
813
853
|
const fallbackMsg = (fallbackErr as Error).message;
|
|
814
854
|
opts.warn(`[cli-bridge] fallback ${fallbackModel} also failed: ${fallbackMsg}`);
|
|
815
855
|
if (sseHeadersSent) {
|
|
@@ -823,7 +863,7 @@ async function handleRequest(
|
|
|
823
863
|
return;
|
|
824
864
|
}
|
|
825
865
|
} else {
|
|
826
|
-
metrics.recordRequest(model, primaryDuration, false, estPromptTokens);
|
|
866
|
+
metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
|
|
827
867
|
opts.warn(`[cli-bridge] CLI error for ${model}: ${msg}`);
|
|
828
868
|
if (sseHeadersSent) {
|
|
829
869
|
res.write(`data: ${JSON.stringify({ error: { message: msg, type: "cli_error" } })}\n\n`);
|
|
@@ -838,6 +878,7 @@ async function handleRequest(
|
|
|
838
878
|
} finally {
|
|
839
879
|
if (keepaliveInterval) clearInterval(keepaliveInterval);
|
|
840
880
|
cleanupMediaFiles(mediaFiles);
|
|
881
|
+
activeRequests.delete(id);
|
|
841
882
|
}
|
|
842
883
|
|
|
843
884
|
const hasToolCalls = !!(result.tool_calls?.length);
|
package/src/status-template.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { BrowserContext } from "playwright";
|
|
9
|
-
import type { MetricsSnapshot } from "./metrics.js";
|
|
9
|
+
import type { MetricsSnapshot, RequestLogEntry, FallbackEvent } from "./metrics.js";
|
|
10
|
+
import type { ProviderSession } from "./provider-sessions.js";
|
|
11
|
+
import type { ActiveRequest } from "./proxy-server.js";
|
|
10
12
|
|
|
11
13
|
export interface StatusProvider {
|
|
12
14
|
name: string;
|
|
@@ -16,15 +18,24 @@ export interface StatusProvider {
|
|
|
16
18
|
ctx: BrowserContext | null;
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
export interface TimeoutConfigInfo {
|
|
22
|
+
defaults: Record<string, number>;
|
|
23
|
+
baseDefault: number;
|
|
24
|
+
maxEffective: number;
|
|
25
|
+
perExtraMsg: number;
|
|
26
|
+
perTool: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export interface StatusTemplateOptions {
|
|
20
30
|
version: string;
|
|
21
31
|
port: number;
|
|
22
32
|
providers: StatusProvider[];
|
|
23
33
|
models: Array<{ id: string; name: string; contextWindow: number; maxTokens: number }>;
|
|
24
|
-
/** Maps model ID → slash command name (e.g. "openai-codex/gpt-5.3-codex" → "/cli-codex") */
|
|
25
34
|
modelCommands?: Record<string, string>;
|
|
26
|
-
/** In-memory metrics snapshot — optional for backward compat */
|
|
27
35
|
metrics?: MetricsSnapshot;
|
|
36
|
+
activeRequests?: ActiveRequest[];
|
|
37
|
+
providerSessionsList?: ProviderSession[];
|
|
38
|
+
timeoutConfig?: TimeoutConfigInfo;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
function statusBadge(p: StatusProvider): { label: string; color: string; dot: string } {
|
|
@@ -44,14 +55,14 @@ function formatDuration(ms: number): string {
|
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
function formatTokens(n: number): string {
|
|
47
|
-
if (n === 0) return "
|
|
58
|
+
if (n === 0) return "\u2014";
|
|
48
59
|
if (n < 1000) return String(n);
|
|
49
60
|
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
50
61
|
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
function timeAgo(epochMs: number | null): string {
|
|
54
|
-
if (!epochMs) return "
|
|
65
|
+
if (!epochMs) return "\u2014";
|
|
55
66
|
const diff = Date.now() - epochMs;
|
|
56
67
|
if (diff < 60_000) return "just now";
|
|
57
68
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
@@ -75,6 +86,223 @@ function escapeHtml(s: string): string {
|
|
|
75
86
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
function truncateId(id: string): string {
|
|
90
|
+
if (id.length <= 20) return id;
|
|
91
|
+
return id.slice(0, 8) + "\u2026" + id.slice(-8);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Active Requests ────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function renderActiveRequests(active: ActiveRequest[]): string {
|
|
97
|
+
if (active.length === 0) {
|
|
98
|
+
return `
|
|
99
|
+
<div class="card">
|
|
100
|
+
<div class="card-header">Active Requests <span class="badge badge-ok">0</span></div>
|
|
101
|
+
<div class="empty-state">No active requests</div>
|
|
102
|
+
</div>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const rows = active.map(r => {
|
|
106
|
+
const elapsed = Date.now() - r.startedAt;
|
|
107
|
+
const elapsedClass = elapsed > 300_000 ? ' style="color:#ef4444;font-weight:600"' : elapsed > 120_000 ? ' style="color:#f59e0b"' : "";
|
|
108
|
+
return `
|
|
109
|
+
<tr>
|
|
110
|
+
<td class="metrics-cell"><span class="pulse-dot"></span></td>
|
|
111
|
+
<td class="metrics-cell"><code class="model-id">${escapeHtml(r.model)}</code></td>
|
|
112
|
+
<td class="metrics-cell" style="text-align:right"${elapsedClass}>${formatDuration(elapsed)}</td>
|
|
113
|
+
<td class="metrics-cell" style="text-align:right">${r.messageCount}</td>
|
|
114
|
+
<td class="metrics-cell" style="text-align:right">${r.toolCount}</td>
|
|
115
|
+
<td class="metrics-cell prompt-preview">${escapeHtml(r.promptPreview || "\u2014")}</td>
|
|
116
|
+
</tr>`;
|
|
117
|
+
}).join("");
|
|
118
|
+
|
|
119
|
+
return `
|
|
120
|
+
<div class="card">
|
|
121
|
+
<div class="card-header">Active Requests <span class="badge badge-active">${active.length}</span></div>
|
|
122
|
+
<table class="metrics-table">
|
|
123
|
+
<thead>
|
|
124
|
+
<tr class="table-head">
|
|
125
|
+
<th class="metrics-th" style="width:24px"></th>
|
|
126
|
+
<th class="metrics-th" style="text-align:left">Model</th>
|
|
127
|
+
<th class="metrics-th" style="text-align:right">Elapsed</th>
|
|
128
|
+
<th class="metrics-th" style="text-align:right">Msgs</th>
|
|
129
|
+
<th class="metrics-th" style="text-align:right">Tools</th>
|
|
130
|
+
<th class="metrics-th" style="text-align:left">Prompt</th>
|
|
131
|
+
</tr>
|
|
132
|
+
</thead>
|
|
133
|
+
<tbody>${rows}</tbody>
|
|
134
|
+
</table>
|
|
135
|
+
</div>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Recent Request Log ────────────────────────────────────────────────────���
|
|
139
|
+
|
|
140
|
+
function renderRecentRequestLog(entries: RequestLogEntry[]): string {
|
|
141
|
+
if (entries.length === 0) {
|
|
142
|
+
return `
|
|
143
|
+
<div class="card">
|
|
144
|
+
<div class="card-header">Recent Requests</div>
|
|
145
|
+
<div class="empty-state">No requests recorded yet</div>
|
|
146
|
+
</div>`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rows = [...entries].reverse().map(r => {
|
|
150
|
+
const statusIcon = r.success
|
|
151
|
+
? '<span style="color:#22c55e">✓</span>'
|
|
152
|
+
: '<span style="color:#ef4444">✗</span>';
|
|
153
|
+
return `
|
|
154
|
+
<tr>
|
|
155
|
+
<td class="metrics-cell" style="color:#6b7280;font-size:12px;white-space:nowrap">${timeAgo(r.timestamp)}</td>
|
|
156
|
+
<td class="metrics-cell"><code class="model-id">${escapeHtml(r.model)}</code></td>
|
|
157
|
+
<td class="metrics-cell" style="text-align:right">${formatDuration(r.latencyMs)}</td>
|
|
158
|
+
<td class="metrics-cell" style="text-align:center">${statusIcon}</td>
|
|
159
|
+
<td class="metrics-cell prompt-preview">${escapeHtml(r.promptPreview || "\u2014")}</td>
|
|
160
|
+
<td class="metrics-cell" style="text-align:right;color:#6b7280;font-size:12px">${formatTokens(r.promptTokens)} / ${formatTokens(r.completionTokens)}</td>
|
|
161
|
+
</tr>`;
|
|
162
|
+
}).join("");
|
|
163
|
+
|
|
164
|
+
return `
|
|
165
|
+
<div class="card">
|
|
166
|
+
<div class="card-header">Recent Requests <span style="color:#4b5563;font-weight:400">(last ${entries.length})</span></div>
|
|
167
|
+
<table class="metrics-table">
|
|
168
|
+
<thead>
|
|
169
|
+
<tr class="table-head">
|
|
170
|
+
<th class="metrics-th" style="text-align:left">Time</th>
|
|
171
|
+
<th class="metrics-th" style="text-align:left">Model</th>
|
|
172
|
+
<th class="metrics-th" style="text-align:right">Latency</th>
|
|
173
|
+
<th class="metrics-th" style="text-align:center">OK</th>
|
|
174
|
+
<th class="metrics-th" style="text-align:left">Prompt</th>
|
|
175
|
+
<th class="metrics-th" style="text-align:right">Tokens (in/out)</th>
|
|
176
|
+
</tr>
|
|
177
|
+
</thead>
|
|
178
|
+
<tbody>${rows}</tbody>
|
|
179
|
+
</table>
|
|
180
|
+
</div>`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Fallback History ───────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function renderFallbackHistory(events: FallbackEvent[]): string {
|
|
186
|
+
if (events.length === 0) {
|
|
187
|
+
return `
|
|
188
|
+
<div class="card">
|
|
189
|
+
<div class="card-header">Fallback History</div>
|
|
190
|
+
<div class="empty-state">No fallback events</div>
|
|
191
|
+
</div>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const rows = [...events].reverse().map(e => {
|
|
195
|
+
const reasonBadge = e.reason === "timeout"
|
|
196
|
+
? '<span class="badge badge-warn">timeout</span>'
|
|
197
|
+
: '<span class="badge badge-error">error</span>';
|
|
198
|
+
const outcomeBadge = e.fallbackSuccess
|
|
199
|
+
? '<span class="badge badge-ok">success</span>'
|
|
200
|
+
: '<span class="badge badge-error">failed</span>';
|
|
201
|
+
return `
|
|
202
|
+
<tr>
|
|
203
|
+
<td class="metrics-cell" style="color:#6b7280;font-size:12px;white-space:nowrap">${timeAgo(e.timestamp)}</td>
|
|
204
|
+
<td class="metrics-cell"><code class="model-id">${escapeHtml(e.originalModel)}</code></td>
|
|
205
|
+
<td class="metrics-cell"><code class="model-id">${escapeHtml(e.fallbackModel)}</code></td>
|
|
206
|
+
<td class="metrics-cell">${reasonBadge}</td>
|
|
207
|
+
<td class="metrics-cell" style="text-align:right">${formatDuration(e.failedDurationMs)}</td>
|
|
208
|
+
<td class="metrics-cell">${outcomeBadge}</td>
|
|
209
|
+
</tr>`;
|
|
210
|
+
}).join("");
|
|
211
|
+
|
|
212
|
+
return `
|
|
213
|
+
<div class="card">
|
|
214
|
+
<div class="card-header">Fallback History <span style="color:#4b5563;font-weight:400">(last ${events.length})</span></div>
|
|
215
|
+
<table class="metrics-table">
|
|
216
|
+
<thead>
|
|
217
|
+
<tr class="table-head">
|
|
218
|
+
<th class="metrics-th" style="text-align:left">Time</th>
|
|
219
|
+
<th class="metrics-th" style="text-align:left">Original Model</th>
|
|
220
|
+
<th class="metrics-th" style="text-align:left">Fallback Model</th>
|
|
221
|
+
<th class="metrics-th" style="text-align:left">Reason</th>
|
|
222
|
+
<th class="metrics-th" style="text-align:right">Failed After</th>
|
|
223
|
+
<th class="metrics-th" style="text-align:left">Outcome</th>
|
|
224
|
+
</tr>
|
|
225
|
+
</thead>
|
|
226
|
+
<tbody>${rows}</tbody>
|
|
227
|
+
</table>
|
|
228
|
+
</div>`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Provider Sessions ──────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function renderProviderSessions(sessions: ProviderSession[]): string {
|
|
234
|
+
if (sessions.length === 0) {
|
|
235
|
+
return `
|
|
236
|
+
<div class="card">
|
|
237
|
+
<div class="card-header">Provider Sessions</div>
|
|
238
|
+
<div class="empty-state">No active sessions</div>
|
|
239
|
+
</div>`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
243
|
+
const rows = sorted.map(s => {
|
|
244
|
+
const stateColor = s.state === "active" ? "#22c55e" : s.state === "idle" ? "#3b82f6" : "#6b7280";
|
|
245
|
+
const stateBadge = `<span class="badge" style="background:${stateColor}22;color:${stateColor};border-color:${stateColor}44">${s.state}</span>`;
|
|
246
|
+
const timeoutWarn = s.timeoutCount > 0 ? ` <span style="color:#ef4444;font-size:11px">(${s.timeoutCount} timeouts)</span>` : "";
|
|
247
|
+
return `
|
|
248
|
+
<tr>
|
|
249
|
+
<td class="metrics-cell" style="font-family:monospace;font-size:12px;color:#9ca3af">${truncateId(s.id)}</td>
|
|
250
|
+
<td class="metrics-cell"><code class="model-id">${escapeHtml(s.modelAlias)}</code></td>
|
|
251
|
+
<td class="metrics-cell">${stateBadge}</td>
|
|
252
|
+
<td class="metrics-cell" style="text-align:right">${s.runCount}${timeoutWarn}</td>
|
|
253
|
+
<td class="metrics-cell" style="text-align:right;color:#6b7280;font-size:12px">${timeAgo(s.updatedAt)}</td>
|
|
254
|
+
</tr>`;
|
|
255
|
+
}).join("");
|
|
256
|
+
|
|
257
|
+
return `
|
|
258
|
+
<div class="card">
|
|
259
|
+
<div class="card-header">Provider Sessions <span style="color:#4b5563;font-weight:400">(${sessions.length})</span></div>
|
|
260
|
+
<table class="metrics-table">
|
|
261
|
+
<thead>
|
|
262
|
+
<tr class="table-head">
|
|
263
|
+
<th class="metrics-th" style="text-align:left">Session ID</th>
|
|
264
|
+
<th class="metrics-th" style="text-align:left">Model</th>
|
|
265
|
+
<th class="metrics-th" style="text-align:left">State</th>
|
|
266
|
+
<th class="metrics-th" style="text-align:right">Runs</th>
|
|
267
|
+
<th class="metrics-th" style="text-align:right">Last Activity</th>
|
|
268
|
+
</tr>
|
|
269
|
+
</thead>
|
|
270
|
+
<tbody>${rows}</tbody>
|
|
271
|
+
</table>
|
|
272
|
+
</div>`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Timeout Configuration ──────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
function renderTimeoutConfig(config: TimeoutConfigInfo): string {
|
|
278
|
+
const entries = Object.entries(config.defaults).sort(([a], [b]) => a.localeCompare(b));
|
|
279
|
+
const rows = entries.map(([model, ms]) => {
|
|
280
|
+
return `
|
|
281
|
+
<tr>
|
|
282
|
+
<td class="metrics-cell"><code class="model-id">${escapeHtml(model)}</code></td>
|
|
283
|
+
<td class="metrics-cell" style="text-align:right">${Math.round(ms / 1000)}s</td>
|
|
284
|
+
</tr>`;
|
|
285
|
+
}).join("");
|
|
286
|
+
|
|
287
|
+
return `
|
|
288
|
+
<div class="card">
|
|
289
|
+
<div class="card-header">Timeout Configuration</div>
|
|
290
|
+
<div style="padding:12px 16px;color:#9ca3af;font-size:13px;border-bottom:1px solid #1f2335">
|
|
291
|
+
<strong style="color:#d1d5db">Formula:</strong> base timeout + (msgs beyond 10 × ${config.perExtraMsg / 1000}s) + (tools × ${config.perTool / 1000}s), capped at ${Math.round(config.maxEffective / 1000)}s
|
|
292
|
+
<br><span style="color:#6b7280">Default base: ${Math.round(config.baseDefault / 1000)}s</span>
|
|
293
|
+
</div>
|
|
294
|
+
<table class="metrics-table">
|
|
295
|
+
<thead>
|
|
296
|
+
<tr class="table-head">
|
|
297
|
+
<th class="metrics-th" style="text-align:left">Model</th>
|
|
298
|
+
<th class="metrics-th" style="text-align:right">Base Timeout</th>
|
|
299
|
+
</tr>
|
|
300
|
+
</thead>
|
|
301
|
+
<tbody>${rows}</tbody>
|
|
302
|
+
</table>
|
|
303
|
+
</div>`;
|
|
304
|
+
}
|
|
305
|
+
|
|
78
306
|
// ── Metrics sections ────────────────────────────────────────────────────────
|
|
79
307
|
|
|
80
308
|
function renderMetricsSection(m: MetricsSnapshot): string {
|
|
@@ -112,7 +340,7 @@ function renderMetricsSection(m: MetricsSnapshot): string {
|
|
|
112
340
|
const modErrorRate = mod.requests > 0 ? ((mod.errors / mod.requests) * 100).toFixed(1) : "0.0";
|
|
113
341
|
return `
|
|
114
342
|
<tr>
|
|
115
|
-
<td class="metrics-cell"><code
|
|
343
|
+
<td class="metrics-cell"><code class="model-id">${escapeHtml(mod.model)}</code></td>
|
|
116
344
|
<td class="metrics-cell" style="text-align:right">${mod.requests}</td>
|
|
117
345
|
<td class="metrics-cell" style="text-align:right;color:${mod.errors > 0 ? '#ef4444' : '#6b7280'}">${mod.errors} <span style="color:#6b7280;font-size:11px">(${modErrorRate}%)</span></td>
|
|
118
346
|
<td class="metrics-cell" style="text-align:right">${formatDuration(avgLatency)}</td>
|
|
@@ -127,7 +355,7 @@ function renderMetricsSection(m: MetricsSnapshot): string {
|
|
|
127
355
|
<div class="card-header">Per-Model Stats</div>
|
|
128
356
|
<table class="metrics-table">
|
|
129
357
|
<thead>
|
|
130
|
-
<tr
|
|
358
|
+
<tr class="table-head">
|
|
131
359
|
<th class="metrics-th" style="text-align:left">Model</th>
|
|
132
360
|
<th class="metrics-th" style="text-align:right">Requests</th>
|
|
133
361
|
<th class="metrics-th" style="text-align:right">Errors</th>
|
|
@@ -150,7 +378,7 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
150
378
|
const badge = statusBadge(p);
|
|
151
379
|
const expiryText = p.expiry
|
|
152
380
|
? p.expiry.replace(/[⚠️🚨✅🕐]/gu, "").trim()
|
|
153
|
-
: `Not logged in
|
|
381
|
+
: `Not logged in \u2014 run <code>${p.loginCmd}</code>`;
|
|
154
382
|
return `
|
|
155
383
|
<tr>
|
|
156
384
|
<td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
|
|
@@ -174,10 +402,15 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
174
402
|
items.map(m => {
|
|
175
403
|
const cmd = cmds[m.id];
|
|
176
404
|
const cmdBadge = cmd ? `<span style="color:#6b7280;font-size:11px;margin-left:8px">${cmd}</span>` : "";
|
|
177
|
-
return `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code
|
|
405
|
+
return `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code class="model-id">${m.id}</code>${cmdBadge}</li>`;
|
|
178
406
|
}).join("");
|
|
179
407
|
|
|
180
408
|
const metricsHtml = opts.metrics ? renderMetricsSection(opts.metrics) : "";
|
|
409
|
+
const activeHtml = opts.activeRequests ? renderActiveRequests(opts.activeRequests) : "";
|
|
410
|
+
const recentHtml = opts.metrics ? renderRecentRequestLog(opts.metrics.recentRequests) : "";
|
|
411
|
+
const fallbackHtml = opts.metrics ? renderFallbackHistory(opts.metrics.fallbackHistory) : "";
|
|
412
|
+
const sessionsHtml = opts.providerSessionsList ? renderProviderSessions(opts.providerSessionsList) : "";
|
|
413
|
+
const timeoutHtml = opts.timeoutConfig ? renderTimeoutConfig(opts.timeoutConfig) : "";
|
|
181
414
|
|
|
182
415
|
return `<!DOCTYPE html>
|
|
183
416
|
<html lang="en">
|
|
@@ -185,20 +418,24 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
185
418
|
<meta charset="UTF-8">
|
|
186
419
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
187
420
|
<title>CLI Bridge Status</title>
|
|
188
|
-
<meta http-equiv="refresh" content="
|
|
421
|
+
<meta http-equiv="refresh" content="10">
|
|
189
422
|
<style>
|
|
190
423
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
191
424
|
body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; padding: 32px 24px; }
|
|
192
425
|
h1 { font-size: 22px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
|
|
193
426
|
.subtitle { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
|
|
427
|
+
.subtitle a { color: #3b82f6; text-decoration: none; }
|
|
428
|
+
.subtitle a:hover { text-decoration: underline; }
|
|
194
429
|
.card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
|
|
195
430
|
.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; }
|
|
196
431
|
table { width: 100%; border-collapse: collapse; }
|
|
197
432
|
tr:not(:last-child) td { border-bottom: 1px solid #1f2335; }
|
|
433
|
+
.table-head { background: #13151f; }
|
|
198
434
|
.models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
199
435
|
ul { list-style: none; padding: 12px 16px; }
|
|
200
436
|
.footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
201
437
|
code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
|
|
438
|
+
.model-id { color: #93c5fd; }
|
|
202
439
|
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
|
203
440
|
.summary-card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; padding: 20px 16px; text-align: center; }
|
|
204
441
|
.summary-value { font-size: 28px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
|
|
@@ -206,17 +443,31 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
206
443
|
.metrics-table { width: 100%; border-collapse: collapse; }
|
|
207
444
|
.metrics-th { padding: 10px 16px; font-size: 12px; color: #4b5563; font-weight: 600; }
|
|
208
445
|
.metrics-cell { padding: 10px 16px; font-size: 13px; }
|
|
446
|
+
.empty-state { padding: 24px 16px; color: #4b5563; text-align: center; font-style: italic; font-size: 13px; }
|
|
447
|
+
.prompt-preview { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9ca3af; font-family: monospace; font-size: 12px; }
|
|
448
|
+
.badge { display: inline-block; border-radius: 6px; padding: 2px 8px; font-size: 11px; font-weight: 600; border: 1px solid transparent; }
|
|
449
|
+
.badge-ok { background: #22c55e22; color: #22c55e; border-color: #22c55e44; }
|
|
450
|
+
.badge-warn { background: #f59e0b22; color: #f59e0b; border-color: #f59e0b44; }
|
|
451
|
+
.badge-error { background: #ef444422; color: #ef4444; border-color: #ef444444; }
|
|
452
|
+
.badge-active { background: #3b82f622; color: #3b82f6; border-color: #3b82f644; }
|
|
453
|
+
.pulse-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse 1.5s ease-in-out infinite; }
|
|
454
|
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
455
|
+
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
456
|
+
@media (max-width: 768px) {
|
|
457
|
+
.summary-grid { grid-template-columns: repeat(2, 1fr); }
|
|
458
|
+
.models, .two-col { grid-template-columns: 1fr; }
|
|
459
|
+
}
|
|
209
460
|
</style>
|
|
210
461
|
</head>
|
|
211
462
|
<body>
|
|
212
463
|
<h1>CLI Bridge</h1>
|
|
213
|
-
<p class="subtitle">v${version} &
|
|
464
|
+
<p class="subtitle">v${version} · Port ${port} · Auto-refreshes every 10s · <a href="/status">\u21bb Refresh</a></p>
|
|
214
465
|
|
|
215
466
|
<div class="card">
|
|
216
467
|
<div class="card-header">Web Session Providers</div>
|
|
217
468
|
<table>
|
|
218
469
|
<thead>
|
|
219
|
-
<tr
|
|
470
|
+
<tr class="table-head">
|
|
220
471
|
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
|
|
221
472
|
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
|
|
222
473
|
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
|
|
@@ -229,6 +480,17 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
229
480
|
|
|
230
481
|
${metricsHtml}
|
|
231
482
|
|
|
483
|
+
${activeHtml}
|
|
484
|
+
|
|
485
|
+
${recentHtml}
|
|
486
|
+
|
|
487
|
+
<div class="two-col">
|
|
488
|
+
<div>${fallbackHtml}</div>
|
|
489
|
+
<div>${sessionsHtml}</div>
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
${timeoutHtml}
|
|
493
|
+
|
|
232
494
|
<div class="models">
|
|
233
495
|
<div class="card">
|
|
234
496
|
<div class="card-header">CLI Models (${cliModels.length})</div>
|
|
@@ -246,7 +508,7 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
246
508
|
</div>
|
|
247
509
|
</div>
|
|
248
510
|
|
|
249
|
-
<p class="footer">openclaw-cli-bridge-elvatis v${version} &
|
|
511
|
+
<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>
|
|
250
512
|
</body>
|
|
251
513
|
</html>`;
|
|
252
514
|
}
|