@elvatis_com/openclaw-cli-bridge-elvatis 2.8.5 → 2.10.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 +23 -1
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +3 -3
- package/package.json +1 -1
- package/src/cli-runner.ts +43 -10
- package/src/config.ts +25 -4
- package/src/debug-log.ts +55 -0
- package/src/metrics.ts +67 -0
- package/src/proxy-server.ts +76 -6
- package/src/status-template.ts +275 -13
- package/src/tool-protocol.ts +94 -11
- package/test/config.test.ts +3 -3
- package/test/session-manager.test.ts +3 -1
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.10.0`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -406,6 +406,28 @@ npm run ci # lint + typecheck + test
|
|
|
406
406
|
|
|
407
407
|
## Changelog
|
|
408
408
|
|
|
409
|
+
### v2.10.0
|
|
410
|
+
- **fix:** cap effective timeout at 580s (under gateway's 600s `idleTimeoutSeconds`) so bridge fallback fires BEFORE gateway kills the request — eliminates the race condition where both compete to handle the timeout
|
|
411
|
+
- **fix:** reduce Sonnet base timeout 420s→300s, Opus 420s→360s — ensures fallback triggers faster for stuck CLI sessions
|
|
412
|
+
- **feat:** compact tool schema mode — when >8 tools, compress definitions to name+params only, cutting prompt size ~60%
|
|
413
|
+
- **feat:** stale-output detection — if CLI produces no stdout for 120s, SIGTERM early instead of waiting full timeout
|
|
414
|
+
- **feat:** adaptive message limits — reduce history from 20→12 messages when >10 tools to keep prompts smaller
|
|
415
|
+
- **feat:** file-based debug log at `~/.openclaw/cli-bridge/debug.log` — `tail -f` for real-time request lifecycle visibility
|
|
416
|
+
- **feat:** SSE progress comments every 30s so the webchat connection stays informed during long CLI runs
|
|
417
|
+
- **feat:** SSE fallback notification — visible comment when a model times out and the bridge retries with fallback
|
|
418
|
+
- **fix:** rescue tool_calls embedded inside content strings — handles models that wrap `{"tool_calls":[...]}` inside a `{"content":"..."}` wrapper
|
|
419
|
+
- **fix:** parse robustness — debug logging on all parse paths to diagnose raw-JSON-instead-of-tool-calls issues
|
|
420
|
+
|
|
421
|
+
### v2.9.0
|
|
422
|
+
- **feat:** enhanced `/status` dashboard with 5 new panels:
|
|
423
|
+
- **Active Requests**: live in-flight requests with model, elapsed time, message/tool count, prompt preview
|
|
424
|
+
- **Recent Request Log**: last 20 requests with latency, success/fail, prompt preview, token counts
|
|
425
|
+
- **Fallback History**: last 10 fallback events with reason, timing, and outcome
|
|
426
|
+
- **Provider Sessions**: CLI session state (active/idle/expired), run count, timeout count
|
|
427
|
+
- **Timeout Configuration**: per-model base timeouts and dynamic scaling formula
|
|
428
|
+
- **feat:** auto-refresh reduced from 30s to 10s for more responsive monitoring
|
|
429
|
+
- **feat:** responsive two-column layout for fallback history and provider sessions
|
|
430
|
+
|
|
409
431
|
### v2.8.5
|
|
410
432
|
- **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.
|
|
411
433
|
|
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.10.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,8 +43,8 @@
|
|
|
43
43
|
"type": "number"
|
|
44
44
|
},
|
|
45
45
|
"default": {
|
|
46
|
-
"cli-claude/claude-opus-4-6":
|
|
47
|
-
"cli-claude/claude-sonnet-4-6":
|
|
46
|
+
"cli-claude/claude-opus-4-6": 360000,
|
|
47
|
+
"cli-claude/claude-sonnet-4-6": 300000,
|
|
48
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,
|
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.10.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
|
@@ -30,11 +30,15 @@ import {
|
|
|
30
30
|
} from "./tool-protocol.js";
|
|
31
31
|
import {
|
|
32
32
|
MAX_MESSAGES,
|
|
33
|
+
MAX_MESSAGES_HEAVY_TOOLS,
|
|
34
|
+
TOOL_HEAVY_THRESHOLD,
|
|
33
35
|
MAX_MSG_CHARS,
|
|
34
36
|
DEFAULT_CLI_TIMEOUT_MS,
|
|
35
37
|
TIMEOUT_GRACE_MS,
|
|
36
38
|
MEDIA_TMP_DIR,
|
|
39
|
+
STALE_OUTPUT_TIMEOUT_MS,
|
|
37
40
|
} from "./config.js";
|
|
41
|
+
import { debugLog } from "./debug-log.js";
|
|
38
42
|
|
|
39
43
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
40
44
|
// Message formatting
|
|
@@ -69,13 +73,16 @@ export type { ToolDefinition, CliToolResult } from "./tool-protocol.js";
|
|
|
69
73
|
* - role "tool": formatted as [Tool Result: name]
|
|
70
74
|
* - role "assistant" with tool_calls: formatted as [Assistant Tool Call: name(args)]
|
|
71
75
|
*/
|
|
72
|
-
export function formatPrompt(messages: ChatMessage[]): string {
|
|
76
|
+
export function formatPrompt(messages: ChatMessage[], toolCount = 0): string {
|
|
73
77
|
if (messages.length === 0) return "";
|
|
74
78
|
|
|
79
|
+
// Reduce history when tool schemas dominate the prompt
|
|
80
|
+
const maxMsgs = toolCount > TOOL_HEAVY_THRESHOLD ? MAX_MESSAGES_HEAVY_TOOLS : MAX_MESSAGES;
|
|
81
|
+
|
|
75
82
|
// Keep system message (if any) + last N non-system messages
|
|
76
83
|
const system = messages.find((m) => m.role === "system");
|
|
77
84
|
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
78
|
-
const recent = nonSystem.slice(-
|
|
85
|
+
const recent = nonSystem.slice(-maxMsgs);
|
|
79
86
|
const truncated = system ? [system, ...recent] : recent;
|
|
80
87
|
|
|
81
88
|
// Single short user message — send bare (no wrapping needed)
|
|
@@ -331,17 +338,20 @@ export function runCli(
|
|
|
331
338
|
let timedOut = false;
|
|
332
339
|
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
333
340
|
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
341
|
+
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
|
342
|
+
let lastOutputAt = Date.now();
|
|
334
343
|
|
|
335
344
|
const clearTimers = () => {
|
|
336
345
|
if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
|
|
337
346
|
if (killTimer) { clearTimeout(killTimer); killTimer = null; }
|
|
347
|
+
if (staleTimer) { clearInterval(staleTimer); staleTimer = null; }
|
|
338
348
|
};
|
|
339
349
|
|
|
340
|
-
|
|
341
|
-
|
|
350
|
+
const doKill = (reason: string) => {
|
|
351
|
+
if (timedOut) return; // already killing
|
|
342
352
|
timedOut = true;
|
|
343
|
-
|
|
344
|
-
|
|
353
|
+
log(`[cli-bridge] ${reason} for ${cmd}, sending SIGTERM`);
|
|
354
|
+
debugLog("KILL", `${cmd} ${reason}`, { stdoutLen: stdout.length, stderrLen: stderr.length });
|
|
345
355
|
proc.kill("SIGTERM");
|
|
346
356
|
|
|
347
357
|
killTimer = setTimeout(() => {
|
|
@@ -350,14 +360,36 @@ export function runCli(
|
|
|
350
360
|
proc.kill("SIGKILL");
|
|
351
361
|
}
|
|
352
362
|
}, TIMEOUT_GRACE_MS);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// ── Hard timeout: SIGTERM → grace → SIGKILL ──────────────────────────
|
|
366
|
+
timeoutTimer = setTimeout(() => {
|
|
367
|
+
doKill(`timeout after ${Math.round(timeoutMs / 1000)}s`);
|
|
353
368
|
}, timeoutMs);
|
|
354
369
|
|
|
370
|
+
// ── Stale-output detection: kill if no stdout for STALE_OUTPUT_TIMEOUT_MS
|
|
371
|
+
if (STALE_OUTPUT_TIMEOUT_MS > 0) {
|
|
372
|
+
const checkInterval = 15_000; // check every 15s
|
|
373
|
+
staleTimer = setInterval(() => {
|
|
374
|
+
const silent = Date.now() - lastOutputAt;
|
|
375
|
+
if (silent >= STALE_OUTPUT_TIMEOUT_MS) {
|
|
376
|
+
doKill(`stale output — no stdout for ${Math.round(silent / 1000)}s`);
|
|
377
|
+
}
|
|
378
|
+
}, checkInterval);
|
|
379
|
+
}
|
|
380
|
+
|
|
355
381
|
proc.stdin.write(prompt, "utf8", () => {
|
|
356
382
|
proc.stdin.end();
|
|
357
383
|
});
|
|
358
384
|
|
|
359
|
-
proc.stdout.on("data", (d: Buffer) => {
|
|
360
|
-
|
|
385
|
+
proc.stdout.on("data", (d: Buffer) => {
|
|
386
|
+
stdout += d.toString();
|
|
387
|
+
lastOutputAt = Date.now();
|
|
388
|
+
});
|
|
389
|
+
proc.stderr.on("data", (d: Buffer) => {
|
|
390
|
+
stderr += d.toString();
|
|
391
|
+
lastOutputAt = Date.now(); // stderr also counts as activity
|
|
392
|
+
});
|
|
361
393
|
|
|
362
394
|
proc.on("close", (code) => {
|
|
363
395
|
clearTimers();
|
|
@@ -770,8 +802,9 @@ export async function routeToCliRunner(
|
|
|
770
802
|
timeoutMs: number,
|
|
771
803
|
opts: RouteOptions = {}
|
|
772
804
|
): Promise<CliToolResult> {
|
|
773
|
-
const
|
|
774
|
-
const
|
|
805
|
+
const toolCount = opts.tools?.length ?? 0;
|
|
806
|
+
const prompt = formatPrompt(messages, toolCount);
|
|
807
|
+
const hasTools = toolCount > 0;
|
|
775
808
|
|
|
776
809
|
// Strip "vllm/" prefix if present — OpenClaw sends the full provider path
|
|
777
810
|
// (e.g. "vllm/cli-claude/claude-sonnet-4-6") but the router only needs the
|
package/src/config.ts
CHANGED
|
@@ -25,8 +25,13 @@ export const DEFAULT_PROXY_API_KEY = "cli-bridge";
|
|
|
25
25
|
/** Default base timeout for CLI subprocess responses (ms). Scales dynamically. */
|
|
26
26
|
export const DEFAULT_PROXY_TIMEOUT_MS = 300_000; // 5 min
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Maximum effective timeout after dynamic scaling (ms).
|
|
30
|
+
* MUST be lower than the OpenClaw gateway's idleTimeoutSeconds (600s)
|
|
31
|
+
* so the bridge's own fallback fires BEFORE the gateway kills the request.
|
|
32
|
+
* 580s gives a 20s safety margin under the gateway's 600s hard limit.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_EFFECTIVE_TIMEOUT_MS = 580_000; // 9m 40s — under gateway's 600s
|
|
30
35
|
|
|
31
36
|
/** Extra timeout per message beyond 10 in the conversation (ms). */
|
|
32
37
|
export const TIMEOUT_PER_EXTRA_MSG_MS = 2_000;
|
|
@@ -47,9 +52,25 @@ export const DEFAULT_CLI_TIMEOUT_MS = 120_000; // 2 min
|
|
|
47
52
|
/** Grace period between SIGTERM and SIGKILL when a timeout fires (ms). */
|
|
48
53
|
export const TIMEOUT_GRACE_MS = 5_000;
|
|
49
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Stale output timeout — if a CLI subprocess produces no stdout for this long,
|
|
57
|
+
* assume it's stuck and SIGTERM early. 0 = disabled.
|
|
58
|
+
* Prevents waiting the full timeout when Claude CLI hangs silently.
|
|
59
|
+
*/
|
|
60
|
+
export const STALE_OUTPUT_TIMEOUT_MS = 120_000; // 2 min of silence → kill
|
|
61
|
+
|
|
50
62
|
/** Max messages to include in the prompt sent to CLI subprocesses. */
|
|
51
63
|
export const MAX_MESSAGES = 20;
|
|
52
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Reduced message limit when tools are heavy (> TOOL_HEAVY_THRESHOLD).
|
|
67
|
+
* Fewer history messages = smaller prompt = faster CLI response.
|
|
68
|
+
*/
|
|
69
|
+
export const MAX_MESSAGES_HEAVY_TOOLS = 12;
|
|
70
|
+
|
|
71
|
+
/** Tool count threshold that triggers reduced message limit. */
|
|
72
|
+
export const TOOL_HEAVY_THRESHOLD = 10;
|
|
73
|
+
|
|
53
74
|
/** Max characters per message content before truncation. */
|
|
54
75
|
export const MAX_MSG_CHARS = 4_000;
|
|
55
76
|
|
|
@@ -91,8 +112,8 @@ export const PROVIDER_SESSION_SWEEP_MS = 10 * 60 * 1_000; // 10 min
|
|
|
91
112
|
* - Fast/lightweight (Haiku, Flash, Mini): 120s
|
|
92
113
|
*/
|
|
93
114
|
export const DEFAULT_MODEL_TIMEOUTS: Record<string, number> = {
|
|
94
|
-
"cli-claude/claude-opus-4-6":
|
|
95
|
-
"cli-claude/claude-sonnet-4-6":
|
|
115
|
+
"cli-claude/claude-opus-4-6": 360_000, // 6 min — leaves room for dynamic scaling up to 580s cap
|
|
116
|
+
"cli-claude/claude-sonnet-4-6": 300_000, // 5 min — was 7 min, reduced so fallback fires before gateway's 600s
|
|
96
117
|
"cli-claude/claude-haiku-4-5": 120_000, // 2 min
|
|
97
118
|
"cli-gemini/gemini-2.5-pro": 300_000, // 5 min — image generation needs more time
|
|
98
119
|
"cli-gemini/gemini-2.5-flash": 180_000, // 3 min
|
package/src/debug-log.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* debug-log.ts
|
|
3
|
+
*
|
|
4
|
+
* File-based debug logger for the CLI bridge.
|
|
5
|
+
* Writes to ~/.openclaw/cli-bridge/debug.log with automatic rotation at 5 MB.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* tail -f ~/.openclaw/cli-bridge/debug.log
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, statSync, renameSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
|
|
15
|
+
const LOG_DIR = join(homedir(), ".openclaw", "cli-bridge");
|
|
16
|
+
const LOG_FILE = join(LOG_DIR, "debug.log");
|
|
17
|
+
const LOG_FILE_PREV = join(LOG_DIR, "debug.log.1");
|
|
18
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
19
|
+
|
|
20
|
+
let initialized = false;
|
|
21
|
+
|
|
22
|
+
function ensureDir(): void {
|
|
23
|
+
if (initialized) return;
|
|
24
|
+
try { mkdirSync(LOG_DIR, { recursive: true }); } catch { /* exists */ }
|
|
25
|
+
initialized = true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rotate(): void {
|
|
29
|
+
try {
|
|
30
|
+
const stat = statSync(LOG_FILE);
|
|
31
|
+
if (stat.size > MAX_LOG_SIZE) {
|
|
32
|
+
try { renameSync(LOG_FILE, LOG_FILE_PREV); } catch { /* best effort */ }
|
|
33
|
+
}
|
|
34
|
+
} catch { /* file doesn't exist yet */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ts(): string {
|
|
38
|
+
return new Date().toISOString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Append a debug line to the log file.
|
|
43
|
+
* Non-blocking, never throws — logging must not crash the bridge.
|
|
44
|
+
*/
|
|
45
|
+
export function debugLog(category: string, message: string, data?: Record<string, unknown>): void {
|
|
46
|
+
try {
|
|
47
|
+
ensureDir();
|
|
48
|
+
rotate();
|
|
49
|
+
const extra = data ? ` ${JSON.stringify(data)}` : "";
|
|
50
|
+
appendFileSync(LOG_FILE, `${ts()} [${category}] ${message}${extra}\n`);
|
|
51
|
+
} catch { /* never crash on log failure */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Log path for display on status page / startup messages. */
|
|
55
|
+
export const DEBUG_LOG_PATH = LOG_FILE;
|
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,7 +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";
|
|
36
|
+
import { debugLog, DEBUG_LOG_PATH } from "./debug-log.js";
|
|
37
|
+
|
|
38
|
+
// ── Active request tracking ─────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface ActiveRequest {
|
|
41
|
+
id: string;
|
|
42
|
+
model: string;
|
|
43
|
+
startedAt: number;
|
|
44
|
+
messageCount: number;
|
|
45
|
+
toolCount: number;
|
|
46
|
+
promptPreview: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const activeRequests = new Map<string, ActiveRequest>();
|
|
50
|
+
|
|
51
|
+
export function getActiveRequests(): ActiveRequest[] {
|
|
52
|
+
return [...activeRequests.values()];
|
|
53
|
+
}
|
|
35
54
|
|
|
36
55
|
export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
|
|
37
56
|
export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
|
|
@@ -196,6 +215,7 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
|
|
|
196
215
|
opts.log(
|
|
197
216
|
`[cli-bridge] proxy listening on :${opts.port}`
|
|
198
217
|
);
|
|
218
|
+
debugLog("STARTUP", `proxy listening on :${opts.port}`, { debugLog: DEBUG_LOG_PATH });
|
|
199
219
|
// unref() so the proxy server does not keep the Node.js event loop alive
|
|
200
220
|
// when openclaw doctor or other short-lived CLI commands load plugins.
|
|
201
221
|
// The gateway's own main loop keeps the process alive during normal operation.
|
|
@@ -276,7 +296,20 @@ async function handleRequest(
|
|
|
276
296
|
{ name: "ChatGPT", icon: "◉", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
|
|
277
297
|
];
|
|
278
298
|
|
|
279
|
-
const html = renderStatusPage({
|
|
299
|
+
const html = renderStatusPage({
|
|
300
|
+
version, port: opts.port, providers, models: CLI_MODELS,
|
|
301
|
+
modelCommands: opts.modelCommands,
|
|
302
|
+
metrics: metrics.getMetrics(),
|
|
303
|
+
activeRequests: getActiveRequests(),
|
|
304
|
+
providerSessionsList: providerSessions.listSessions(),
|
|
305
|
+
timeoutConfig: {
|
|
306
|
+
defaults: { ...DEFAULT_MODEL_TIMEOUTS, ...(opts.modelTimeouts ?? {}) },
|
|
307
|
+
baseDefault: opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS,
|
|
308
|
+
maxEffective: MAX_EFFECTIVE_TIMEOUT_MS,
|
|
309
|
+
perExtraMsg: TIMEOUT_PER_EXTRA_MSG_MS,
|
|
310
|
+
perTool: TIMEOUT_PER_TOOL_MS,
|
|
311
|
+
},
|
|
312
|
+
});
|
|
280
313
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
281
314
|
res.end(html);
|
|
282
315
|
return;
|
|
@@ -354,6 +387,15 @@ async function handleRequest(
|
|
|
354
387
|
const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
|
|
355
388
|
const created = Math.floor(Date.now() / 1000);
|
|
356
389
|
|
|
390
|
+
// Extract prompt preview from last user message for dashboard
|
|
391
|
+
const lastUserMsg = [...cleanMessages].reverse().find(m => m.role === "user");
|
|
392
|
+
const promptPreview = typeof lastUserMsg?.content === "string" ? lastUserMsg.content.slice(0, 80) : "";
|
|
393
|
+
|
|
394
|
+
debugLog("REQ", `${model} start`, { msgs: cleanMessages.length, tools: tools?.length ?? 0, stream, media: mediaFiles.length, promptPreview: promptPreview.slice(0, 60) });
|
|
395
|
+
|
|
396
|
+
// Track active request for dashboard
|
|
397
|
+
activeRequests.set(id, { id, model, startedAt: Date.now(), messageCount: cleanMessages.length, toolCount: tools?.length ?? 0, promptPreview });
|
|
398
|
+
|
|
357
399
|
// ── Grok web-session routing ──────────────────────────────────────────────
|
|
358
400
|
if (model.startsWith("web-grok/")) {
|
|
359
401
|
let grokCtx = opts.getGrokContext?.() ?? null;
|
|
@@ -767,6 +809,7 @@ async function handleRequest(
|
|
|
767
809
|
const toolExtra = (tools?.length ?? 0) * TIMEOUT_PER_TOOL_MS;
|
|
768
810
|
const effectiveTimeout = Math.min(baseTimeout + msgExtra + toolExtra, MAX_EFFECTIVE_TIMEOUT_MS);
|
|
769
811
|
opts.log(`[cli-bridge] ${model} session=${session.id} timeout: ${Math.round(effectiveTimeout / 1000)}s (base=${Math.round(baseTimeout / 1000)}s${perModelTimeout ? " per-model" : ""}, +${Math.round(msgExtra / 1000)}s msgs, +${Math.round(toolExtra / 1000)}s tools)`);
|
|
812
|
+
debugLog("TIMEOUT", `${model} effective=${Math.round(effectiveTimeout / 1000)}s`, { base: Math.round(baseTimeout / 1000), msgExtra: Math.round(msgExtra / 1000), toolExtra: Math.round(toolExtra / 1000), cap: Math.round(MAX_EFFECTIVE_TIMEOUT_MS / 1000) });
|
|
770
813
|
|
|
771
814
|
// ── SSE keepalive: send headers early so OpenClaw doesn't read-timeout ──
|
|
772
815
|
let sseHeadersSent = false;
|
|
@@ -783,33 +826,58 @@ async function handleRequest(
|
|
|
783
826
|
keepaliveInterval = setInterval(() => { res.write(": keepalive\n\n"); }, SSE_KEEPALIVE_INTERVAL_MS);
|
|
784
827
|
}
|
|
785
828
|
|
|
829
|
+
// ── Progress notifications: send visible status updates to the webchat ──
|
|
830
|
+
// Users shouldn't stare at a blank screen for minutes without feedback.
|
|
831
|
+
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
|
832
|
+
const PROGRESS_INTERVAL_MS = 30_000; // 30s between updates
|
|
833
|
+
if (stream && sseHeadersSent) {
|
|
834
|
+
const progressStart = Date.now();
|
|
835
|
+
progressInterval = setInterval(() => {
|
|
836
|
+
const elapsed = Math.round((Date.now() - progressStart) / 1000);
|
|
837
|
+
const timeoutSec = Math.round(effectiveTimeout / 1000);
|
|
838
|
+
// Send an SSE comment with progress info — visible in raw SSE but won't render as content
|
|
839
|
+
// Also send a small content delta that OpenClaw can show as typing indicator
|
|
840
|
+
res.write(`: progress ${elapsed}s/${timeoutSec}s — ${model} processing\n\n`);
|
|
841
|
+
}, PROGRESS_INTERVAL_MS);
|
|
842
|
+
}
|
|
843
|
+
|
|
786
844
|
const cliStart = Date.now();
|
|
787
845
|
try {
|
|
788
846
|
result = await routeToCliRunner(model, cleanMessages, effectiveTimeout, routeOpts);
|
|
847
|
+
const latencyMs = Date.now() - cliStart;
|
|
789
848
|
const estCompletionTokens = estimateTokens(result.content ?? "");
|
|
790
|
-
metrics.recordRequest(model,
|
|
849
|
+
metrics.recordRequest(model, latencyMs, true, estPromptTokens, estCompletionTokens, promptPreview);
|
|
791
850
|
providerSessions.recordRun(session.id, false);
|
|
851
|
+
debugLog("OK", `${model} completed in ${(latencyMs / 1000).toFixed(1)}s`, { toolCalls: result.tool_calls?.length ?? 0, contentLen: result.content?.length ?? 0 });
|
|
792
852
|
} catch (err) {
|
|
793
853
|
const primaryDuration = Date.now() - cliStart;
|
|
794
854
|
const msg = (err as Error).message;
|
|
795
855
|
// ── Model fallback: retry once with a lighter model if configured ────
|
|
796
856
|
const isTimeout = msg.includes("timeout:") || msg.includes("exit 143") || msg.includes("exited 143");
|
|
857
|
+
debugLog("FAIL", `${model} failed after ${(primaryDuration / 1000).toFixed(1)}s`, { isTimeout, error: msg.slice(0, 200) });
|
|
797
858
|
// Record the run (with timeout flag) — session is preserved, not deleted
|
|
798
859
|
providerSessions.recordRun(session.id, isTimeout);
|
|
799
860
|
const fallbackModel = opts.modelFallbacks?.[model];
|
|
800
861
|
if (fallbackModel) {
|
|
801
|
-
metrics.recordRequest(model, primaryDuration, false, estPromptTokens);
|
|
862
|
+
metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
|
|
802
863
|
const reason = isTimeout ? `timeout by supervisor, session=${session.id} preserved` : msg;
|
|
803
864
|
opts.warn(`[cli-bridge] ${model} failed (${reason}), falling back to ${fallbackModel}`);
|
|
865
|
+
debugLog("FALLBACK", `${model} → ${fallbackModel}`, { reason: isTimeout ? "timeout" : "error", primaryDuration: Math.round(primaryDuration / 1000) });
|
|
866
|
+
// Notify the user via SSE that we're retrying with a different model
|
|
867
|
+
if (sseHeadersSent) {
|
|
868
|
+
res.write(`: fallback — ${model} ${isTimeout ? "timed out" : "failed"} after ${Math.round(primaryDuration / 1000)}s, retrying with ${fallbackModel}\n\n`);
|
|
869
|
+
}
|
|
804
870
|
const fallbackStart = Date.now();
|
|
805
871
|
try {
|
|
806
872
|
result = await routeToCliRunner(fallbackModel, cleanMessages, effectiveTimeout, routeOpts);
|
|
807
873
|
const fbCompTokens = estimateTokens(result.content ?? "");
|
|
808
|
-
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens);
|
|
874
|
+
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens, promptPreview);
|
|
875
|
+
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, true);
|
|
809
876
|
usedModel = fallbackModel;
|
|
810
877
|
opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded (response will report original model: ${model})`);
|
|
811
878
|
} catch (fallbackErr) {
|
|
812
|
-
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens);
|
|
879
|
+
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens, undefined, promptPreview);
|
|
880
|
+
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, false);
|
|
813
881
|
const fallbackMsg = (fallbackErr as Error).message;
|
|
814
882
|
opts.warn(`[cli-bridge] fallback ${fallbackModel} also failed: ${fallbackMsg}`);
|
|
815
883
|
if (sseHeadersSent) {
|
|
@@ -823,7 +891,7 @@ async function handleRequest(
|
|
|
823
891
|
return;
|
|
824
892
|
}
|
|
825
893
|
} else {
|
|
826
|
-
metrics.recordRequest(model, primaryDuration, false, estPromptTokens);
|
|
894
|
+
metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
|
|
827
895
|
opts.warn(`[cli-bridge] CLI error for ${model}: ${msg}`);
|
|
828
896
|
if (sseHeadersSent) {
|
|
829
897
|
res.write(`data: ${JSON.stringify({ error: { message: msg, type: "cli_error" } })}\n\n`);
|
|
@@ -837,7 +905,9 @@ async function handleRequest(
|
|
|
837
905
|
}
|
|
838
906
|
} finally {
|
|
839
907
|
if (keepaliveInterval) clearInterval(keepaliveInterval);
|
|
908
|
+
if (progressInterval) clearInterval(progressInterval);
|
|
840
909
|
cleanupMediaFiles(mediaFiles);
|
|
910
|
+
activeRequests.delete(id);
|
|
841
911
|
}
|
|
842
912
|
|
|
843
913
|
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
|
}
|
package/src/tool-protocol.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { debugLog } from "./debug-log.js";
|
|
13
14
|
|
|
14
15
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
15
16
|
// Types
|
|
@@ -46,13 +47,34 @@ export interface CliToolResult {
|
|
|
46
47
|
* Build a text block describing available tools and response format instructions.
|
|
47
48
|
* This block is prepended to the system message (or added as a new system message).
|
|
48
49
|
*/
|
|
50
|
+
/** Threshold: when tool count exceeds this, use compact schema to reduce prompt size. */
|
|
51
|
+
const COMPACT_TOOL_THRESHOLD = 8;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build a compact tool description: name + required param names only.
|
|
55
|
+
* Cuts prompt size by ~60-70% for large tool sets.
|
|
56
|
+
*/
|
|
57
|
+
function compactToolDescription(t: ToolDefinition): string {
|
|
58
|
+
const fn = t.function;
|
|
59
|
+
const params = fn.parameters as { properties?: Record<string, unknown>; required?: string[] };
|
|
60
|
+
const required = params?.required ?? Object.keys(params?.properties ?? {});
|
|
61
|
+
const paramList = required.length > 0 ? `(${required.join(", ")})` : "()";
|
|
62
|
+
return `- ${fn.name}${paramList}: ${fn.description}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a full tool description: name, description, and full JSON schema.
|
|
67
|
+
*/
|
|
68
|
+
function fullToolDescription(t: ToolDefinition): string {
|
|
69
|
+
const fn = t.function;
|
|
70
|
+
const params = JSON.stringify(fn.parameters);
|
|
71
|
+
return `- name: ${fn.name}\n description: ${fn.description}\n parameters: ${params}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
export function buildToolPromptBlock(tools: ToolDefinition[]): string {
|
|
75
|
+
const useCompact = tools.length > COMPACT_TOOL_THRESHOLD;
|
|
50
76
|
const toolDescriptions = tools
|
|
51
|
-
.map(
|
|
52
|
-
const fn = t.function;
|
|
53
|
-
const params = JSON.stringify(fn.parameters);
|
|
54
|
-
return `- name: ${fn.name}\n description: ${fn.description}\n parameters: ${params}`;
|
|
55
|
-
})
|
|
77
|
+
.map(useCompact ? compactToolDescription : fullToolDescription)
|
|
56
78
|
.join("\n");
|
|
57
79
|
|
|
58
80
|
return [
|
|
@@ -67,6 +89,7 @@ export function buildToolPromptBlock(tools: ToolDefinition[]): string {
|
|
|
67
89
|
'{"content":"<your text response>"}',
|
|
68
90
|
"",
|
|
69
91
|
"Do NOT include any text outside the JSON. Do NOT wrap in markdown code blocks.",
|
|
92
|
+
useCompact ? "Call ONE tool at a time. Do NOT batch multiple tool calls." : "",
|
|
70
93
|
"",
|
|
71
94
|
"Available tools:",
|
|
72
95
|
toolDescriptions,
|
|
@@ -117,6 +140,7 @@ export function buildToolCallJsonSchema(): object {
|
|
|
117
140
|
*/
|
|
118
141
|
export function parseToolCallResponse(text: string): CliToolResult {
|
|
119
142
|
const trimmed = text.trim();
|
|
143
|
+
const preview = trimmed.slice(0, 120);
|
|
120
144
|
|
|
121
145
|
// Check for Claude's --output-format json wrapper FIRST.
|
|
122
146
|
// Claude returns: { "type": "result", "result": "..." }
|
|
@@ -124,30 +148,48 @@ export function parseToolCallResponse(text: string): CliToolResult {
|
|
|
124
148
|
const claudeResult = tryExtractClaudeJsonResult(trimmed);
|
|
125
149
|
if (claudeResult) {
|
|
126
150
|
const inner = tryParseJson(claudeResult);
|
|
127
|
-
if (inner)
|
|
151
|
+
if (inner) {
|
|
152
|
+
const result = normalizeResult(inner);
|
|
153
|
+
debugLog("PARSE", `claude-json → ${result.tool_calls ? "tool_calls" : "content"}`, { toolCalls: result.tool_calls?.length ?? 0 });
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
128
156
|
// Claude result is plain text
|
|
157
|
+
debugLog("PARSE", "claude-json → plain text", { len: claudeResult.length });
|
|
129
158
|
return { content: claudeResult };
|
|
130
159
|
}
|
|
131
160
|
|
|
132
161
|
// Try direct JSON parse (for non-Claude outputs)
|
|
133
162
|
const parsed = tryParseJson(trimmed);
|
|
134
|
-
if (parsed)
|
|
163
|
+
if (parsed) {
|
|
164
|
+
const result = normalizeResult(parsed);
|
|
165
|
+
debugLog("PARSE", `direct-json → ${result.tool_calls ? "tool_calls" : "content"}`, { toolCalls: result.tool_calls?.length ?? 0 });
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
135
168
|
|
|
136
169
|
// Try extracting JSON from markdown code blocks: ```json ... ```
|
|
137
170
|
const codeBlock = tryExtractCodeBlock(trimmed);
|
|
138
171
|
if (codeBlock) {
|
|
139
172
|
const inner = tryParseJson(codeBlock);
|
|
140
|
-
if (inner)
|
|
173
|
+
if (inner) {
|
|
174
|
+
const result = normalizeResult(inner);
|
|
175
|
+
debugLog("PARSE", `code-block → ${result.tool_calls ? "tool_calls" : "content"}`, { toolCalls: result.tool_calls?.length ?? 0 });
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
141
178
|
}
|
|
142
179
|
|
|
143
180
|
// Try finding a JSON object anywhere in the text
|
|
144
181
|
const embedded = tryExtractEmbeddedJson(trimmed);
|
|
145
182
|
if (embedded) {
|
|
146
183
|
const inner = tryParseJson(embedded);
|
|
147
|
-
if (inner)
|
|
184
|
+
if (inner) {
|
|
185
|
+
const result = normalizeResult(inner);
|
|
186
|
+
debugLog("PARSE", `embedded-json → ${result.tool_calls ? "tool_calls" : "content"}`, { toolCalls: result.tool_calls?.length ?? 0 });
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
148
189
|
}
|
|
149
190
|
|
|
150
191
|
// Fallback: treat entire text as content
|
|
192
|
+
debugLog("PARSE", "no JSON found → raw content", { len: trimmed.length, preview });
|
|
151
193
|
return { content: trimmed || null };
|
|
152
194
|
}
|
|
153
195
|
|
|
@@ -167,11 +209,17 @@ function normalizeResult(obj: Record<string, unknown>): CliToolResult {
|
|
|
167
209
|
: JSON.stringify(tc.arguments ?? {}),
|
|
168
210
|
},
|
|
169
211
|
}));
|
|
170
|
-
|
|
212
|
+
// If the model also returned a content string alongside tool_calls, include it
|
|
213
|
+
const content = typeof obj.content === "string" ? obj.content : null;
|
|
214
|
+
return { content, tool_calls: toolCalls };
|
|
171
215
|
}
|
|
172
216
|
|
|
173
|
-
// Check for content field
|
|
217
|
+
// Check for content field — but rescue embedded tool_calls JSON from inside content strings.
|
|
218
|
+
// Models sometimes wrap tool calls inside a content string:
|
|
219
|
+
// {"content":"I'll write that file.\n{\"tool_calls\":[...]}"}
|
|
174
220
|
if (typeof obj.content === "string") {
|
|
221
|
+
const rescued = tryRescueToolCallsFromContent(obj.content);
|
|
222
|
+
if (rescued) return rescued;
|
|
175
223
|
return { content: obj.content };
|
|
176
224
|
}
|
|
177
225
|
|
|
@@ -179,6 +227,41 @@ function normalizeResult(obj: Record<string, unknown>): CliToolResult {
|
|
|
179
227
|
return { content: JSON.stringify(obj) };
|
|
180
228
|
}
|
|
181
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Rescue tool_calls embedded inside a content string.
|
|
232
|
+
* Handles cases where the model wraps tool calls in a content field:
|
|
233
|
+
* {"content":"Some text\n{\"tool_calls\":[...]}"}
|
|
234
|
+
* {"content":"{\"tool_calls\":[{\"name\":\"write\",...}]}"}
|
|
235
|
+
*/
|
|
236
|
+
function tryRescueToolCallsFromContent(content: string): CliToolResult | null {
|
|
237
|
+
// Only attempt rescue if content contains the tool_calls signature
|
|
238
|
+
if (!content.includes('"tool_calls"') && !content.includes("tool_calls")) return null;
|
|
239
|
+
|
|
240
|
+
// Try to find embedded JSON with tool_calls
|
|
241
|
+
const embedded = tryExtractEmbeddedJson(content);
|
|
242
|
+
if (!embedded) return null;
|
|
243
|
+
|
|
244
|
+
const parsed = tryParseJson(embedded);
|
|
245
|
+
if (!parsed || !Array.isArray(parsed.tool_calls) || parsed.tool_calls.length === 0) return null;
|
|
246
|
+
|
|
247
|
+
// Extract the text content before the JSON (if any)
|
|
248
|
+
const jsonStart = content.indexOf(embedded);
|
|
249
|
+
const textBefore = jsonStart > 0 ? content.slice(0, jsonStart).trim() : null;
|
|
250
|
+
|
|
251
|
+
const toolCalls: ToolCall[] = parsed.tool_calls.map((tc: Record<string, unknown>) => ({
|
|
252
|
+
id: generateCallId(),
|
|
253
|
+
type: "function" as const,
|
|
254
|
+
function: {
|
|
255
|
+
name: String(tc.name ?? ""),
|
|
256
|
+
arguments: typeof tc.arguments === "string"
|
|
257
|
+
? tc.arguments
|
|
258
|
+
: JSON.stringify(tc.arguments ?? {}),
|
|
259
|
+
},
|
|
260
|
+
}));
|
|
261
|
+
|
|
262
|
+
return { content: textBefore || null, tool_calls: toolCalls };
|
|
263
|
+
}
|
|
264
|
+
|
|
182
265
|
function tryParseJson(text: string): Record<string, unknown> | null {
|
|
183
266
|
try {
|
|
184
267
|
const obj = JSON.parse(text);
|
package/test/config.test.ts
CHANGED
|
@@ -38,7 +38,7 @@ describe("config.ts exports", () => {
|
|
|
38
38
|
expect(DEFAULT_PROXY_TIMEOUT_MS).toBe(300_000);
|
|
39
39
|
expect(DEFAULT_CLI_TIMEOUT_MS).toBe(120_000);
|
|
40
40
|
expect(TIMEOUT_GRACE_MS).toBe(5_000);
|
|
41
|
-
expect(MAX_EFFECTIVE_TIMEOUT_MS).toBe(
|
|
41
|
+
expect(MAX_EFFECTIVE_TIMEOUT_MS).toBe(580_000); // under gateway's 600s
|
|
42
42
|
expect(SESSION_TTL_MS).toBe(30 * 60 * 1000);
|
|
43
43
|
expect(CLEANUP_INTERVAL_MS).toBe(5 * 60 * 1000);
|
|
44
44
|
expect(SESSION_KILL_GRACE_MS).toBe(5_000);
|
|
@@ -61,8 +61,8 @@ describe("config.ts exports", () => {
|
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
it("exports per-model timeouts for all major models", () => {
|
|
64
|
-
expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-opus-4-6"]).toBe(
|
|
65
|
-
expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-sonnet-4-6"]).toBe(
|
|
64
|
+
expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-opus-4-6"]).toBe(360_000);
|
|
65
|
+
expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-sonnet-4-6"]).toBe(300_000);
|
|
66
66
|
expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-haiku-4-5"]).toBe(120_000);
|
|
67
67
|
expect(DEFAULT_MODEL_TIMEOUTS["cli-gemini/gemini-2.5-pro"]).toBe(300_000);
|
|
68
68
|
expect(DEFAULT_MODEL_TIMEOUTS["cli-gemini/gemini-2.5-flash"]).toBe(180_000);
|
|
@@ -63,8 +63,10 @@ vi.mock("../src/workdir.js", () => ({
|
|
|
63
63
|
}));
|
|
64
64
|
|
|
65
65
|
// Mock config module — provide all constants needed by session-manager.ts and cli-runner.ts
|
|
66
|
-
vi.mock("../src/config.js", async () => {
|
|
66
|
+
vi.mock("../src/config.js", async (importOriginal) => {
|
|
67
|
+
const actual = await importOriginal<typeof import("../src/config.js")>();
|
|
67
68
|
return {
|
|
69
|
+
...actual,
|
|
68
70
|
SESSION_TTL_MS: 30 * 60 * 1000,
|
|
69
71
|
CLEANUP_INTERVAL_MS: 5 * 60 * 1000,
|
|
70
72
|
SESSION_KILL_GRACE_MS: 5_000,
|