oc-inspector 1.3.0 → 1.4.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 +25 -2
- package/bin/cli.mjs +321 -56
- package/package.json +1 -1
- package/src/config.mjs +108 -15
- package/src/proxy.mjs +19 -0
package/README.md
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
# oc-inspector
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A debugging and monitoring tool for [OpenClaw](https://openclaw.ai) that helps you understand **where your tokens and money are going**.
|
|
4
|
+
|
|
5
|
+
When working with LLM agents you often have no visibility into what's actually happening under the hood — how many tokens each request burns, which models cost the most, what system prompts look like, how tool calls are structured. `oc-inspector` sits between OpenClaw and your LLM providers as a transparent proxy, capturing every request and response in real time so you can see the full picture: token usage, costs, message flow, thinking blocks, tool calls — everything in a clear, human-readable format.
|
|
6
|
+
|
|
7
|
+
Use it to:
|
|
8
|
+
- **Debug** agent behavior by inspecting actual API requests and responses
|
|
9
|
+
- **Find token leaks** — see which conversations, models, or providers consume the most
|
|
10
|
+
- **Track costs** — per-request, per-model, per-day breakdowns with persistent history
|
|
11
|
+
- **Understand** what OpenClaw sends to each provider (system prompts, tool definitions, context)
|
|
12
|
+
|
|
13
|
+
> **⚠️ USE AT YOUR OWN RISK**
|
|
14
|
+
>
|
|
15
|
+
> This tool works by patching `openclaw.json` to route API traffic through a local proxy. While it includes safeguards (automatic config restore on stop/shutdown, backup files, double-enable protection), **always keep a manual backup of your `openclaw.json` before using the inspector**:
|
|
16
|
+
>
|
|
17
|
+
> ```bash
|
|
18
|
+
> cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.manual-backup
|
|
19
|
+
> ```
|
|
20
|
+
>
|
|
21
|
+
> If something goes wrong, restore it:
|
|
22
|
+
>
|
|
23
|
+
> ```bash
|
|
24
|
+
> cp ~/.openclaw/openclaw.json.manual-backup ~/.openclaw/openclaw.json
|
|
25
|
+
> openclaw gateway restart
|
|
26
|
+
> ```
|
|
4
27
|
|
|
5
28
|
## Quick Start
|
|
6
29
|
|
|
@@ -8,7 +31,7 @@ Real-time API traffic inspector for [OpenClaw](https://openclaw.ai). Intercepts
|
|
|
8
31
|
npx oc-inspector
|
|
9
32
|
```
|
|
10
33
|
|
|
11
|
-
|
|
34
|
+
Starts the inspector daemon in background on `localhost:18800` with a live web dashboard.
|
|
12
35
|
|
|
13
36
|
---
|
|
14
37
|
|
package/bin/cli.mjs
CHANGED
|
@@ -38,6 +38,17 @@ import { fileURLToPath } from "node:url";
|
|
|
38
38
|
const __filename = fileURLToPath(import.meta.url);
|
|
39
39
|
const args = process.argv.slice(2);
|
|
40
40
|
|
|
41
|
+
// ── ANSI escapes & column widths (must be before command routing) ──
|
|
42
|
+
const E = {
|
|
43
|
+
R: "\x1b[0m", B: "\x1b[1m", D: "\x1b[90m",
|
|
44
|
+
RED: "\x1b[31m", GRN: "\x1b[32m", YEL: "\x1b[33m",
|
|
45
|
+
CYN: "\x1b[36m", ORG: "\x1b[38;5;208m",
|
|
46
|
+
BG: "\x1b[48;5;236m",
|
|
47
|
+
CLR: "\x1b[2J\x1b[H",
|
|
48
|
+
HIDE: "\x1b[?25l", SHOW: "\x1b[?25h",
|
|
49
|
+
};
|
|
50
|
+
const COL = { name: 24, bar: 20, tokens: 8, reqs: 7, cost: 10 };
|
|
51
|
+
|
|
41
52
|
/** Inspector state directory for PID/log files. */
|
|
42
53
|
const INSPECTOR_DIR = join(homedir(), ".openclaw", ".inspector-runtime");
|
|
43
54
|
|
|
@@ -59,6 +70,7 @@ function parseArgs(argv) {
|
|
|
59
70
|
open: false,
|
|
60
71
|
config: undefined,
|
|
61
72
|
json: false,
|
|
73
|
+
live: false,
|
|
62
74
|
days: 7,
|
|
63
75
|
lines: 50,
|
|
64
76
|
help: false,
|
|
@@ -67,7 +79,7 @@ function parseArgs(argv) {
|
|
|
67
79
|
"start", "stop", "run", "restart",
|
|
68
80
|
"enable", "disable", "status",
|
|
69
81
|
"stats", "providers", "history", "pricing", "config",
|
|
70
|
-
"logs", "_serve",
|
|
82
|
+
"logs", "help", "_serve",
|
|
71
83
|
]);
|
|
72
84
|
for (let i = 0; i < argv.length; i++) {
|
|
73
85
|
const arg = argv[i];
|
|
@@ -76,6 +88,7 @@ function parseArgs(argv) {
|
|
|
76
88
|
if (arg === "--open") { opts.open = true; continue; }
|
|
77
89
|
if (arg === "--config" && argv[i + 1]) { opts.config = argv[++i]; continue; }
|
|
78
90
|
if (arg === "--json") { opts.json = true; continue; }
|
|
91
|
+
if (arg === "--live" || arg === "-w") { opts.live = true; continue; }
|
|
79
92
|
if (arg === "--days" && argv[i + 1]) { opts.days = parseInt(argv[++i], 10); continue; }
|
|
80
93
|
if (arg === "--lines" && argv[i + 1]) { opts.lines = parseInt(argv[++i], 10); continue; }
|
|
81
94
|
if (arg === "--help" || arg === "-h") { opts.help = true; continue; }
|
|
@@ -117,6 +130,7 @@ if (opts.help) {
|
|
|
117
130
|
--open Auto-open the dashboard in a browser
|
|
118
131
|
--config <path> Custom path to openclaw.json
|
|
119
132
|
--json Output as JSON (for stats, status, providers, history)
|
|
133
|
+
--live, -w Live auto-refreshing stats (for stats command)
|
|
120
134
|
--days <number> Number of days to show in history (default: 7)
|
|
121
135
|
--lines <number> Number of log lines to show (default: 50)
|
|
122
136
|
--help, -h Show this help message
|
|
@@ -128,7 +142,8 @@ if (opts.help) {
|
|
|
128
142
|
npx oc-inspector restart # Restart daemon
|
|
129
143
|
npx oc-inspector enable # Enable interception
|
|
130
144
|
npx oc-inspector disable # Disable interception
|
|
131
|
-
npx oc-inspector stats # Show
|
|
145
|
+
npx oc-inspector stats # Show token stats
|
|
146
|
+
npx oc-inspector stats --live # Live auto-refreshing dashboard
|
|
132
147
|
npx oc-inspector stats --json # Stats as JSON
|
|
133
148
|
npx oc-inspector history --days 30 # Last 30 days
|
|
134
149
|
npx oc-inspector pricing # Show pricing table
|
|
@@ -141,6 +156,15 @@ if (opts.help) {
|
|
|
141
156
|
// Command routing
|
|
142
157
|
// ═══════════════════════════════════════════════════════════════
|
|
143
158
|
|
|
159
|
+
// help: show commands
|
|
160
|
+
if (opts.command === "help") {
|
|
161
|
+
console.log("");
|
|
162
|
+
console.log(" \x1b[38;5;208m🦞 oc-inspector\x1b[0m — Real-time API traffic inspector for OpenClaw");
|
|
163
|
+
console.log("");
|
|
164
|
+
printCommandsHelp();
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
144
168
|
// Hidden: _serve is the actual foreground server (spawned by start)
|
|
145
169
|
if (opts.command === "_serve") {
|
|
146
170
|
await runServe(opts);
|
|
@@ -152,15 +176,16 @@ if (opts.command === "start") {
|
|
|
152
176
|
process.exit(0);
|
|
153
177
|
}
|
|
154
178
|
|
|
155
|
-
// stop: kill daemon
|
|
179
|
+
// stop: kill daemon + restore config
|
|
156
180
|
if (opts.command === "stop") {
|
|
157
|
-
|
|
181
|
+
console.log("");
|
|
182
|
+
await runDaemonStop(false, opts);
|
|
158
183
|
process.exit(0);
|
|
159
184
|
}
|
|
160
185
|
|
|
161
186
|
// restart: stop + start
|
|
162
187
|
if (opts.command === "restart") {
|
|
163
|
-
runDaemonStop(/* silent */ true);
|
|
188
|
+
await runDaemonStop(/* silent */ true, opts);
|
|
164
189
|
await new Promise((r) => setTimeout(r, 800));
|
|
165
190
|
await runDaemonStart(opts);
|
|
166
191
|
process.exit(0);
|
|
@@ -212,8 +237,7 @@ async function runDaemonStart(opts) {
|
|
|
212
237
|
console.log(` \x1b[33m⚠\x1b[0m Already running (PID ${running.pid})`);
|
|
213
238
|
console.log(` \x1b[32m✓\x1b[0m Dashboard: http://127.0.0.1:${opts.port}`);
|
|
214
239
|
console.log("");
|
|
215
|
-
|
|
216
|
-
console.log("");
|
|
240
|
+
printCommandsHelp();
|
|
217
241
|
return;
|
|
218
242
|
}
|
|
219
243
|
|
|
@@ -254,9 +278,7 @@ async function runDaemonStart(opts) {
|
|
|
254
278
|
console.log(` \x1b[32m✓\x1b[0m Dashboard: http://127.0.0.1:${opts.port}`);
|
|
255
279
|
console.log(` \x1b[32m✓\x1b[0m Log file: ${LOG_FILE}`);
|
|
256
280
|
console.log("");
|
|
257
|
-
|
|
258
|
-
console.log(` \x1b[90mView logs:\x1b[0m oc-inspector logs`);
|
|
259
|
-
console.log("");
|
|
281
|
+
printCommandsHelp();
|
|
260
282
|
} else {
|
|
261
283
|
console.log(` \x1b[31m✗ Failed to start — check logs:\x1b[0m`);
|
|
262
284
|
console.log(` ${LOG_FILE}`);
|
|
@@ -272,11 +294,38 @@ async function runDaemonStart(opts) {
|
|
|
272
294
|
}
|
|
273
295
|
|
|
274
296
|
/**
|
|
275
|
-
* Stop the running daemon
|
|
297
|
+
* Stop the running daemon and restore original OpenClaw config.
|
|
298
|
+
*
|
|
299
|
+
* Automatically calls `disable` to restore the original provider URLs
|
|
300
|
+
* and restart the gateway, so OpenClaw doesn't keep routing through
|
|
301
|
+
* the dead proxy.
|
|
276
302
|
*
|
|
277
303
|
* @param {boolean} [silent=false] - Suppress output (used by restart).
|
|
304
|
+
* @param {object} [cmdOpts] - CLI opts (for config path).
|
|
278
305
|
*/
|
|
279
|
-
function runDaemonStop(silent = false) {
|
|
306
|
+
async function runDaemonStop(silent = false, cmdOpts = {}) {
|
|
307
|
+
// 1. Restore original config before killing the proxy
|
|
308
|
+
try {
|
|
309
|
+
const { detect, disable, status } = await import("../src/config.mjs");
|
|
310
|
+
const oc = detect(cmdOpts.config);
|
|
311
|
+
if (oc.exists) {
|
|
312
|
+
const st = status(oc.dir);
|
|
313
|
+
if (st.enabled) {
|
|
314
|
+
const result = disable({ configPath: oc.configPath, openclawDir: oc.dir });
|
|
315
|
+
if (!silent) {
|
|
316
|
+
if (result.ok) {
|
|
317
|
+
console.log(` \x1b[32m✓\x1b[0m Config restored — ${result.message}`);
|
|
318
|
+
} else {
|
|
319
|
+
console.log(` \x1b[33m⚠\x1b[0m Config restore: ${result.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
// Non-critical — still kill the process
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 2. Kill the daemon process
|
|
280
329
|
const st = getDaemonStatus();
|
|
281
330
|
|
|
282
331
|
if (!st.pid) {
|
|
@@ -292,13 +341,11 @@ function runDaemonStop(silent = false) {
|
|
|
292
341
|
try {
|
|
293
342
|
process.kill(st.pid, "SIGTERM");
|
|
294
343
|
if (!silent) {
|
|
295
|
-
console.log("");
|
|
296
344
|
console.log(` \x1b[32m✓\x1b[0m Stopped inspector (PID ${st.pid})`);
|
|
297
345
|
console.log("");
|
|
298
346
|
}
|
|
299
347
|
} catch (err) {
|
|
300
348
|
if (!silent) {
|
|
301
|
-
console.log("");
|
|
302
349
|
console.log(` \x1b[31m✗ Failed to stop PID ${st.pid}: ${err.message}\x1b[0m`);
|
|
303
350
|
console.log("");
|
|
304
351
|
}
|
|
@@ -404,8 +451,14 @@ async function runServe(opts) {
|
|
|
404
451
|
process.exit(1);
|
|
405
452
|
}
|
|
406
453
|
|
|
407
|
-
|
|
408
|
-
|
|
454
|
+
const gracefulShutdown = async (signal) => {
|
|
455
|
+
console.log(`\n Shutting down (${signal})...`);
|
|
456
|
+
await restoreConfigOnExit(opts);
|
|
457
|
+
process.exit(0);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
461
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
409
462
|
}
|
|
410
463
|
|
|
411
464
|
/**
|
|
@@ -438,8 +491,36 @@ async function runForeground(opts) {
|
|
|
438
491
|
process.exit(1);
|
|
439
492
|
}
|
|
440
493
|
|
|
441
|
-
|
|
442
|
-
|
|
494
|
+
const gracefulShutdown = async (signal) => {
|
|
495
|
+
console.log(`\n Shutting down (${signal})...`);
|
|
496
|
+
await restoreConfigOnExit(opts);
|
|
497
|
+
process.exit(0);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
501
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Restore original OpenClaw config on process exit.
|
|
506
|
+
* Used by graceful shutdown handlers in _serve and run modes.
|
|
507
|
+
*
|
|
508
|
+
* @param {object} cmdOpts - CLI opts (for config path).
|
|
509
|
+
*/
|
|
510
|
+
async function restoreConfigOnExit(cmdOpts = {}) {
|
|
511
|
+
try {
|
|
512
|
+
const { detect, disable, status } = await import("../src/config.mjs");
|
|
513
|
+
const oc = detect(cmdOpts.config);
|
|
514
|
+
if (oc.exists) {
|
|
515
|
+
const st = status(oc.dir);
|
|
516
|
+
if (st.enabled) {
|
|
517
|
+
const result = disable({ configPath: oc.configPath, openclawDir: oc.dir });
|
|
518
|
+
console.log(` \x1b[32m✓\x1b[0m Config restored — ${result.message}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
} catch {
|
|
522
|
+
// Best-effort — don't block exit
|
|
523
|
+
}
|
|
443
524
|
}
|
|
444
525
|
|
|
445
526
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -516,6 +597,10 @@ async function runRemoteCommand(opts) {
|
|
|
516
597
|
const base = `http://127.0.0.1:${opts.port}`;
|
|
517
598
|
|
|
518
599
|
if (opts.command === "stats") {
|
|
600
|
+
if (opts.live) {
|
|
601
|
+
await runLiveStats(base);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
519
604
|
const data = await fetchApi(`${base}/api/stats`);
|
|
520
605
|
if (!data) return;
|
|
521
606
|
if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
@@ -564,6 +649,36 @@ async function runRemoteCommand(opts) {
|
|
|
564
649
|
// Helpers / Printers
|
|
565
650
|
// ═══════════════════════════════════════════════════════════════
|
|
566
651
|
|
|
652
|
+
/**
|
|
653
|
+
* Print a compact command reference table.
|
|
654
|
+
* Shown after successful daemon start and via `help` command.
|
|
655
|
+
*/
|
|
656
|
+
function printCommandsHelp() {
|
|
657
|
+
const C = "\x1b[36m"; // cyan
|
|
658
|
+
const D = "\x1b[90m"; // dim
|
|
659
|
+
const R = "\x1b[0m"; // reset
|
|
660
|
+
const B = "\x1b[1m"; // bold
|
|
661
|
+
|
|
662
|
+
console.log(` ${B}Commands:${R}`);
|
|
663
|
+
console.log(` ${C}oc-inspector${R} Start daemon ${D}(default)${R}`);
|
|
664
|
+
console.log(` ${C}oc-inspector stop${R} Stop daemon`);
|
|
665
|
+
console.log(` ${C}oc-inspector restart${R} Restart daemon`);
|
|
666
|
+
console.log(` ${C}oc-inspector run${R} Foreground mode ${D}(interactive)${R}`);
|
|
667
|
+
console.log(` ${C}oc-inspector status${R} Daemon + interception status`);
|
|
668
|
+
console.log(` ${C}oc-inspector enable${R} Enable interception`);
|
|
669
|
+
console.log(` ${C}oc-inspector disable${R} Disable interception`);
|
|
670
|
+
console.log(` ${C}oc-inspector stats${R} Token/cost statistics ${D}(--live for auto-refresh)${R}`);
|
|
671
|
+
console.log(` ${C}oc-inspector history${R} Daily usage history`);
|
|
672
|
+
console.log(` ${C}oc-inspector pricing${R} Model pricing table`);
|
|
673
|
+
console.log(` ${C}oc-inspector providers${R} Active providers list`);
|
|
674
|
+
console.log(` ${C}oc-inspector config${R} Inspector config info`);
|
|
675
|
+
console.log(` ${C}oc-inspector logs${R} Daemon log output`);
|
|
676
|
+
console.log(` ${C}oc-inspector help${R} Show this help`);
|
|
677
|
+
console.log("");
|
|
678
|
+
console.log(` ${D}Use --json for machine-readable output. See oc-inspector --help for all options.${R}`);
|
|
679
|
+
console.log("");
|
|
680
|
+
}
|
|
681
|
+
|
|
567
682
|
async function fetchApi(url) {
|
|
568
683
|
try {
|
|
569
684
|
const res = await fetch(url);
|
|
@@ -575,64 +690,214 @@ async function fetchApi(url) {
|
|
|
575
690
|
}
|
|
576
691
|
}
|
|
577
692
|
|
|
693
|
+
// ── Formatting helpers ──
|
|
694
|
+
|
|
578
695
|
function fmtNum(n) {
|
|
579
696
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
|
|
580
697
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
|
581
698
|
return String(n);
|
|
582
699
|
}
|
|
583
700
|
|
|
584
|
-
function
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
return `${label} ${"█".repeat(filled)}${"░".repeat(empty)} ${fmtNum(val)}`;
|
|
590
|
-
};
|
|
701
|
+
function fmtCost(n) {
|
|
702
|
+
if (!n || n === 0) return "$0.00";
|
|
703
|
+
if (n < 0.01) return "$" + n.toFixed(4);
|
|
704
|
+
return "$" + n.toFixed(2);
|
|
705
|
+
}
|
|
591
706
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
707
|
+
function fmtMs(ms) {
|
|
708
|
+
if (ms >= 60_000) return (ms / 60_000).toFixed(1) + "m";
|
|
709
|
+
if (ms >= 1_000) return (ms / 1_000).toFixed(1) + "s";
|
|
710
|
+
return ms + "ms";
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Build a horizontal bar string.
|
|
715
|
+
*
|
|
716
|
+
* @param {number} val - Current value.
|
|
717
|
+
* @param {number} max - Maximum value (100%).
|
|
718
|
+
* @param {number} [width=20] - Bar width in characters.
|
|
719
|
+
* @returns {string} Bar characters.
|
|
720
|
+
*/
|
|
721
|
+
function bar(val, max, width = 20) {
|
|
722
|
+
const pct = max > 0 ? Math.min(val / max, 1) : 0;
|
|
723
|
+
const filled = Math.round(pct * width);
|
|
724
|
+
const empty = width - filled;
|
|
725
|
+
return "█".repeat(filled) + "░".repeat(empty);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Render stats data to an array of strings (no ANSI clears).
|
|
730
|
+
*
|
|
731
|
+
* @param {object} s - Stats from /api/stats.
|
|
732
|
+
* @param {object} [opts] - Render options.
|
|
733
|
+
* @param {boolean} [opts.compact=false] - Skip header/footer.
|
|
734
|
+
* @returns {string[]} Lines to print.
|
|
735
|
+
*/
|
|
736
|
+
function renderStats(s, { compact = false } = {}) {
|
|
737
|
+
const lines = [];
|
|
738
|
+
const w = COL;
|
|
739
|
+
|
|
740
|
+
if (!compact) {
|
|
741
|
+
lines.push("");
|
|
742
|
+
lines.push(` ${E.ORG}🦞 OpenClaw Inspector — Stats${E.R}`);
|
|
743
|
+
lines.push(" " + "─".repeat(68));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── Summary box ──
|
|
747
|
+
lines.push("");
|
|
748
|
+
const errStr = s.errors > 0 ? ` ${E.RED}(${s.errors} errors)${E.R}` : "";
|
|
749
|
+
lines.push(` ${E.B}Requests${E.R} ${String(s.totalRequests).padStart(6)}${errStr}`);
|
|
750
|
+
|
|
751
|
+
const inTok = fmtNum(s.totalInputTokens);
|
|
752
|
+
const outTok = fmtNum(s.totalOutputTokens);
|
|
753
|
+
lines.push(` ${E.B}Tokens${E.R} ${fmtNum(s.totalTokens).padStart(6)} ${E.D}in ${inTok} out ${outTok}${E.R}`);
|
|
596
754
|
|
|
597
|
-
console.log(` \x1b[1mRequests:\x1b[0m ${s.totalRequests}${s.errors > 0 ? ` (\x1b[31m${s.errors} errors\x1b[0m)` : ""}`);
|
|
598
|
-
console.log(` \x1b[1mTokens:\x1b[0m ${fmtNum(s.totalTokens)} total (in: ${fmtNum(s.totalInputTokens)} out: ${fmtNum(s.totalOutputTokens)})`);
|
|
599
755
|
if (s.totalCachedTokens > 0) {
|
|
600
|
-
|
|
756
|
+
lines.push(` ${E.B}Cached${E.R} ${fmtNum(s.totalCachedTokens).padStart(6)}`);
|
|
601
757
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
758
|
+
|
|
759
|
+
lines.push(` ${E.B}Cost${E.R} ${E.GRN}${fmtCost(s.totalCost || 0).padStart(8)}${E.R}`);
|
|
760
|
+
|
|
761
|
+
if (s.totalDuration > 0 && s.totalRequests > 0) {
|
|
762
|
+
const avg = Math.round(s.totalDuration / s.totalRequests);
|
|
763
|
+
lines.push(` ${E.B}Avg latency${E.R} ${fmtMs(avg).padStart(6)}`);
|
|
606
764
|
}
|
|
607
765
|
|
|
608
|
-
|
|
766
|
+
// ── By Provider ──
|
|
767
|
+
const providers = Object.entries(s.byProvider || {});
|
|
609
768
|
if (providers.length > 0) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
769
|
+
lines.push("");
|
|
770
|
+
lines.push(` ${E.B}${"Provider".padEnd(w.name)} ${"Tokens".padStart(w.tokens)} ${"Bar".padEnd(w.bar)} ${"Reqs".padStart(w.reqs)} ${"Cost".padStart(w.cost)}${E.R}`);
|
|
771
|
+
lines.push(" " + "─".repeat(68));
|
|
772
|
+
|
|
773
|
+
const maxT = Math.max(...providers.map(([, v]) => (v.inputTokens || 0) + (v.outputTokens || 0)));
|
|
774
|
+
const sorted = providers.sort((a, b) =>
|
|
775
|
+
(b[1].cost || 0) - (a[1].cost || 0) ||
|
|
776
|
+
((b[1].inputTokens || 0) + (b[1].outputTokens || 0)) - ((a[1].inputTokens || 0) + (a[1].outputTokens || 0))
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
for (const [name, v] of sorted) {
|
|
780
|
+
const total = (v.inputTokens || 0) + (v.outputTokens || 0);
|
|
781
|
+
const n = name.length > w.name - 2 ? name.slice(0, w.name - 3) + "…" : name;
|
|
782
|
+
const costStr = v.cost > 0 ? `${E.GRN}${fmtCost(v.cost).padStart(w.cost)}${E.R}` : `${E.D}${fmtCost(0).padStart(w.cost)}${E.R}`;
|
|
783
|
+
lines.push(` ${E.CYN}${n.padEnd(w.name)}${E.R} ${fmtNum(total).padStart(w.tokens)} ${bar(total, maxT, w.bar)} ${String(v.requests).padStart(w.reqs)} ${costStr}`);
|
|
618
784
|
}
|
|
619
785
|
}
|
|
620
786
|
|
|
621
|
-
|
|
787
|
+
// ── By Model ──
|
|
788
|
+
const models = Object.entries(s.byModel || {});
|
|
622
789
|
if (models.length > 0) {
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
790
|
+
lines.push("");
|
|
791
|
+
lines.push(` ${E.B}${"Model".padEnd(w.name)} ${"Tokens".padStart(w.tokens)} ${"Bar".padEnd(w.bar)} ${"Reqs".padStart(w.reqs)} ${"Cost".padStart(w.cost)}${E.R}`);
|
|
792
|
+
lines.push(" " + "─".repeat(68));
|
|
793
|
+
|
|
794
|
+
const maxT = Math.max(...models.map(([, v]) => (v.inputTokens || 0) + (v.outputTokens || 0)));
|
|
795
|
+
const sorted = models.sort((a, b) =>
|
|
796
|
+
(b[1].cost || 0) - (a[1].cost || 0) ||
|
|
797
|
+
((b[1].inputTokens || 0) + (b[1].outputTokens || 0)) - ((a[1].inputTokens || 0) + (a[1].outputTokens || 0))
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
for (const [name, v] of sorted) {
|
|
801
|
+
const total = (v.inputTokens || 0) + (v.outputTokens || 0);
|
|
802
|
+
const n = name.length > w.name - 2 ? name.slice(0, w.name - 3) + "…" : name;
|
|
803
|
+
const costStr = v.cost > 0 ? `${E.GRN}${fmtCost(v.cost).padStart(w.cost)}${E.R}` : `${E.D}${fmtCost(0).padStart(w.cost)}${E.R}`;
|
|
804
|
+
lines.push(` ${E.YEL}${n.padEnd(w.name)}${E.R} ${fmtNum(total).padStart(w.tokens)} ${bar(total, maxT, w.bar)} ${String(v.requests).padStart(w.reqs)} ${costStr}`);
|
|
632
805
|
}
|
|
633
806
|
}
|
|
634
807
|
|
|
635
|
-
|
|
808
|
+
lines.push("");
|
|
809
|
+
return lines;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function printStats(s) {
|
|
813
|
+
for (const line of renderStats(s)) process.stdout.write(line + "\n");
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Live auto-refreshing stats dashboard.
|
|
818
|
+
* Clears the terminal and redraws every 2 seconds.
|
|
819
|
+
* Press q or Ctrl+C to exit.
|
|
820
|
+
*
|
|
821
|
+
* @param {string} base - Inspector API base URL.
|
|
822
|
+
*/
|
|
823
|
+
async function runLiveStats(base) {
|
|
824
|
+
const url = `${base}/api/stats`;
|
|
825
|
+
|
|
826
|
+
// Enable raw mode for keypress detection
|
|
827
|
+
if (process.stdin.isTTY) {
|
|
828
|
+
process.stdin.setRawMode(true);
|
|
829
|
+
process.stdin.resume();
|
|
830
|
+
process.stdin.setEncoding("utf8");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
process.stdout.write(E.HIDE); // hide cursor
|
|
834
|
+
|
|
835
|
+
let running = true;
|
|
836
|
+
let tick = 0;
|
|
837
|
+
|
|
838
|
+
const cleanup = () => {
|
|
839
|
+
running = false;
|
|
840
|
+
process.stdout.write(E.SHOW); // show cursor
|
|
841
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
842
|
+
console.log("");
|
|
843
|
+
process.exit(0);
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
process.on("SIGINT", cleanup);
|
|
847
|
+
process.on("SIGTERM", cleanup);
|
|
848
|
+
|
|
849
|
+
if (process.stdin.isTTY) {
|
|
850
|
+
process.stdin.on("data", (key) => {
|
|
851
|
+
if (key === "q" || key === "Q" || key === "\x03") cleanup(); // q or Ctrl+C
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
while (running) {
|
|
856
|
+
try {
|
|
857
|
+
const res = await fetch(url);
|
|
858
|
+
const data = await res.json();
|
|
859
|
+
|
|
860
|
+
const lines = [];
|
|
861
|
+
lines.push(E.CLR); // clear screen
|
|
862
|
+
lines.push(` ${E.ORG}🦞 OpenClaw Inspector${E.R} ${E.D}— Live Stats${E.R}`);
|
|
863
|
+
lines.push(" " + "═".repeat(68));
|
|
864
|
+
|
|
865
|
+
// Uptime spinner
|
|
866
|
+
const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
867
|
+
const sp = spinner[tick % spinner.length];
|
|
868
|
+
const now = new Date().toLocaleTimeString();
|
|
869
|
+
lines.push(` ${E.GRN}${sp}${E.R} ${E.D}Updated ${now} Press ${E.B}q${E.R}${E.D} to exit${E.R}`);
|
|
870
|
+
|
|
871
|
+
// Render stats body (compact, no header)
|
|
872
|
+
const body = renderStats(data, { compact: true });
|
|
873
|
+
lines.push(...body);
|
|
874
|
+
|
|
875
|
+
// Recently seen models (last 5 entries by model)
|
|
876
|
+
const recent = data.recentEntries || [];
|
|
877
|
+
if (recent.length > 0) {
|
|
878
|
+
lines.push(` ${E.B}Recent Requests${E.R}`);
|
|
879
|
+
lines.push(" " + "─".repeat(68));
|
|
880
|
+
for (const e of recent.slice(0, 8)) {
|
|
881
|
+
const model = (e.model || "?").length > 22 ? (e.model || "?").slice(0, 21) + "…" : (e.model || "?");
|
|
882
|
+
const st = e.state === "done" ? `${E.GRN}✓${E.R}` : e.state === "error" ? `${E.RED}✗${E.R}` : `${E.YEL}…${E.R}`;
|
|
883
|
+
const dur = e.duration ? fmtMs(e.duration) : "—";
|
|
884
|
+
const tok = e.totalTokens ? fmtNum(e.totalTokens) : "—";
|
|
885
|
+
const cost = e.cost > 0 ? `${E.GRN}${fmtCost(e.cost)}${E.R}` : `${E.D}—${E.R}`;
|
|
886
|
+
lines.push(` ${st} ${E.YEL}${model.padEnd(24)}${E.R} ${E.CYN}${(e.provider || "?").padEnd(12)}${E.R} ${tok.padStart(8)} tok ${dur.padStart(6)} ${cost}`);
|
|
887
|
+
}
|
|
888
|
+
lines.push("");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
892
|
+
} catch {
|
|
893
|
+
process.stdout.write(E.CLR);
|
|
894
|
+
process.stdout.write(`\n ${E.RED}✗ Cannot connect to inspector at ${base}${E.R}\n`);
|
|
895
|
+
process.stdout.write(` ${E.D}Retrying...${E.R}\n`);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
tick++;
|
|
899
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
900
|
+
}
|
|
636
901
|
}
|
|
637
902
|
|
|
638
903
|
function printPricing(models) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oc-inspector",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Real-time API traffic inspector for OpenClaw — intercepts LLM provider requests, shows token usage, costs, and message flow in a live web dashboard.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/config.mjs
CHANGED
|
@@ -151,9 +151,22 @@ export function restartGateway() {
|
|
|
151
151
|
export function enable({ configPath, openclawDir, port }) {
|
|
152
152
|
const proxyBase = `http://127.0.0.1:${port}`;
|
|
153
153
|
|
|
154
|
-
//
|
|
154
|
+
// 0. Guard: if already enabled, refuse to double-enable
|
|
155
|
+
// This prevents overwriting originals with proxy URLs.
|
|
156
|
+
const existingState = readState(openclawDir);
|
|
157
|
+
if (existingState?.enabled && Object.keys(existingState.originals || {}).length > 0) {
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
message: "Already enabled",
|
|
161
|
+
providers: Object.keys(existingState.originals),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 1. Backup — only if no existing backup (prevents overwriting clean backup)
|
|
155
166
|
const backupPath = configPath + ".inspector-backup";
|
|
156
|
-
|
|
167
|
+
if (!existsSync(backupPath)) {
|
|
168
|
+
copyFileSync(configPath, backupPath);
|
|
169
|
+
}
|
|
157
170
|
|
|
158
171
|
// 2. Read config
|
|
159
172
|
const config = readConfig(configPath);
|
|
@@ -170,6 +183,13 @@ export function enable({ configPath, openclawDir, port }) {
|
|
|
170
183
|
originals[name] = { baseUrl: cfg.baseUrl, wasCustom: true };
|
|
171
184
|
cfg.baseUrl = `${proxyBase}/${name}`;
|
|
172
185
|
enabled.push(name);
|
|
186
|
+
} else if (cfg.baseUrl && cfg.baseUrl.startsWith(proxyBase)) {
|
|
187
|
+
// Already proxied — skip but don't lose the original
|
|
188
|
+
// Try to resolve from BUILTIN_URLS
|
|
189
|
+
if (BUILTIN_URLS[name]) {
|
|
190
|
+
originals[name] = { baseUrl: BUILTIN_URLS[name], wasCustom: true };
|
|
191
|
+
}
|
|
192
|
+
enabled.push(name);
|
|
173
193
|
}
|
|
174
194
|
}
|
|
175
195
|
|
|
@@ -187,7 +207,16 @@ export function enable({ configPath, openclawDir, port }) {
|
|
|
187
207
|
enabled.push(name);
|
|
188
208
|
}
|
|
189
209
|
|
|
190
|
-
// 5.
|
|
210
|
+
// 5. Validate: refuse to save if originals is empty (means nothing to restore)
|
|
211
|
+
if (Object.keys(originals).length === 0 && enabled.length === 0) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
message: "No providers found to intercept",
|
|
215
|
+
providers: [],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 6. Save state for restore
|
|
191
220
|
writeState(openclawDir, {
|
|
192
221
|
enabled: true,
|
|
193
222
|
port,
|
|
@@ -196,10 +225,10 @@ export function enable({ configPath, openclawDir, port }) {
|
|
|
196
225
|
backupPath,
|
|
197
226
|
});
|
|
198
227
|
|
|
199
|
-
//
|
|
228
|
+
// 7. Write patched config
|
|
200
229
|
writeConfig(configPath, config);
|
|
201
230
|
|
|
202
|
-
//
|
|
231
|
+
// 8. Restart gateway
|
|
203
232
|
const restart = restartGateway();
|
|
204
233
|
|
|
205
234
|
return {
|
|
@@ -222,27 +251,49 @@ export function enable({ configPath, openclawDir, port }) {
|
|
|
222
251
|
export function disable({ configPath, openclawDir }) {
|
|
223
252
|
const state = readState(openclawDir);
|
|
224
253
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
254
|
+
const hasOriginals = state?.originals && Object.keys(state.originals).length > 0;
|
|
255
|
+
|
|
256
|
+
if (!hasOriginals) {
|
|
257
|
+
// No valid originals — try restoring from backup file
|
|
258
|
+
const backupPath = state?.backupPath || configPath + ".inspector-backup";
|
|
228
259
|
if (existsSync(backupPath)) {
|
|
229
|
-
|
|
260
|
+
// Verify backup is clean (doesn't contain proxy URLs)
|
|
261
|
+
try {
|
|
262
|
+
const backupContent = readFileSync(backupPath, "utf-8");
|
|
263
|
+
if (!backupContent.includes("127.0.0.1:18800")) {
|
|
264
|
+
copyFileSync(backupPath, configPath);
|
|
265
|
+
removeState(openclawDir);
|
|
266
|
+
const restart = restartGateway();
|
|
267
|
+
return {
|
|
268
|
+
ok: restart.ok,
|
|
269
|
+
message: restart.ok ? "Restored from clean backup" : "Restored config but gateway restart failed",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
// Backup is also corrupted — fall through to manual cleanup
|
|
273
|
+
} catch { /* ignore */ }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Last resort: scan config for proxy URLs and replace with BUILTIN_URLS
|
|
277
|
+
const cleaned = cleanProxyUrls(configPath);
|
|
278
|
+
if (cleaned) {
|
|
279
|
+
removeState(openclawDir);
|
|
230
280
|
const restart = restartGateway();
|
|
231
281
|
return {
|
|
232
282
|
ok: restart.ok,
|
|
233
|
-
message: restart.ok ? "
|
|
283
|
+
message: restart.ok ? "Cleaned proxy URLs from config" : "Config cleaned but gateway restart failed",
|
|
234
284
|
};
|
|
235
285
|
}
|
|
236
|
-
|
|
286
|
+
|
|
287
|
+
removeState(openclawDir);
|
|
288
|
+
return { ok: false, message: "No inspector state or backup found — nothing to restore" };
|
|
237
289
|
}
|
|
238
290
|
|
|
239
|
-
//
|
|
291
|
+
// Happy path: restore from originals
|
|
240
292
|
const config = readConfig(configPath);
|
|
241
293
|
const providers = config.models?.providers || {};
|
|
242
294
|
|
|
243
295
|
for (const [name, orig] of Object.entries(state.originals)) {
|
|
244
296
|
if (orig.wasCustom) {
|
|
245
|
-
// Restore original baseUrl
|
|
246
297
|
if (providers[name]) {
|
|
247
298
|
providers[name].baseUrl = orig.baseUrl;
|
|
248
299
|
}
|
|
@@ -253,10 +304,12 @@ export function disable({ configPath, openclawDir }) {
|
|
|
253
304
|
}
|
|
254
305
|
|
|
255
306
|
writeConfig(configPath, config);
|
|
256
|
-
|
|
257
|
-
// Clean up state file
|
|
258
307
|
removeState(openclawDir);
|
|
259
308
|
|
|
309
|
+
// Clean up backup file
|
|
310
|
+
const backupPath = state?.backupPath || configPath + ".inspector-backup";
|
|
311
|
+
try { if (existsSync(backupPath)) unlinkSync(backupPath); } catch { /* ignore */ }
|
|
312
|
+
|
|
260
313
|
const restart = restartGateway();
|
|
261
314
|
|
|
262
315
|
return {
|
|
@@ -265,6 +318,46 @@ export function disable({ configPath, openclawDir }) {
|
|
|
265
318
|
};
|
|
266
319
|
}
|
|
267
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Emergency cleanup: scan config for proxy URLs and replace with known originals.
|
|
323
|
+
*
|
|
324
|
+
* @param {string} configPath - Path to openclaw.json.
|
|
325
|
+
* @returns {boolean} True if any cleanup was performed.
|
|
326
|
+
*/
|
|
327
|
+
function cleanProxyUrls(configPath) {
|
|
328
|
+
try {
|
|
329
|
+
const config = readConfig(configPath);
|
|
330
|
+
const providers = config.models?.providers || {};
|
|
331
|
+
let cleaned = false;
|
|
332
|
+
|
|
333
|
+
for (const [name, cfg] of Object.entries(providers)) {
|
|
334
|
+
if (cfg.baseUrl && cfg.baseUrl.includes("127.0.0.1:18800")) {
|
|
335
|
+
if (BUILTIN_URLS[name]) {
|
|
336
|
+
// Known provider — restore builtin URL
|
|
337
|
+
cfg.baseUrl = BUILTIN_URLS[name];
|
|
338
|
+
cleaned = true;
|
|
339
|
+
} else if (cfg.models && cfg.models.length > 0) {
|
|
340
|
+
// Custom provider with models — remove the broken baseUrl
|
|
341
|
+
// Can't know original URL, but removing it is better than dead proxy
|
|
342
|
+
delete cfg.baseUrl;
|
|
343
|
+
cleaned = true;
|
|
344
|
+
} else {
|
|
345
|
+
// Empty provider entry added by inspector — remove entirely
|
|
346
|
+
delete providers[name];
|
|
347
|
+
cleaned = true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (cleaned) {
|
|
353
|
+
writeConfig(configPath, config);
|
|
354
|
+
}
|
|
355
|
+
return cleaned;
|
|
356
|
+
} catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
268
361
|
/**
|
|
269
362
|
* Check current interception status.
|
|
270
363
|
*
|
package/src/proxy.mjs
CHANGED
|
@@ -112,6 +112,24 @@ export function getStats() {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// Recent entries (last 10) for live view
|
|
116
|
+
const recentEntries = [];
|
|
117
|
+
const recentIds = entryOrder.slice(-10).reverse();
|
|
118
|
+
for (const id of recentIds) {
|
|
119
|
+
const e = entries.get(id);
|
|
120
|
+
if (!e) continue;
|
|
121
|
+
const u = e.usage || {};
|
|
122
|
+
recentEntries.push({
|
|
123
|
+
id: e.id,
|
|
124
|
+
provider: e.provider || "?",
|
|
125
|
+
model: u.model || e.reqModel || "?",
|
|
126
|
+
state: e.state,
|
|
127
|
+
duration: e.duration || 0,
|
|
128
|
+
totalTokens: (u.inputTokens || 0) + (u.outputTokens || 0),
|
|
129
|
+
cost: e.cost || 0,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
115
133
|
return {
|
|
116
134
|
totalRequests,
|
|
117
135
|
totalInputTokens,
|
|
@@ -123,6 +141,7 @@ export function getStats() {
|
|
|
123
141
|
errors,
|
|
124
142
|
byProvider,
|
|
125
143
|
byModel,
|
|
144
|
+
recentEntries,
|
|
126
145
|
};
|
|
127
146
|
}
|
|
128
147
|
|