mobygate 0.8.0 → 0.8.1
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/CHANGELOG.md +105 -0
- package/bin/mobygate.js +74 -0
- package/index.html +1 -0
- package/inspector.html +422 -0
- package/lib/anthropic.js +23 -0
- package/lib/connectors/hermes.js +3 -1
- package/lib/connectors/openclaw.js +39 -2
- package/lib/connectors/safety.js +18 -1
- package/lib/request-capture.js +394 -0
- package/package.json +2 -1
- package/server.js +248 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,111 @@ All notable changes to mobygate are documented here. Format loosely follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version numbers are
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.8.1] — 2026-04-27
|
|
8
|
+
|
|
9
|
+
Diagnostic visibility release. Adds a request/response capture system,
|
|
10
|
+
a session inspector UI, a `mobygate tail` CLI, and fixes the v0.8.0
|
|
11
|
+
connector idempotency bug. Born out of an OpenClaw debugging session
|
|
12
|
+
where it took an hour of forensics to discover OpenClaw was using
|
|
13
|
+
OpenAI shape (no cache_control) instead of `/v1/messages` — a one-line
|
|
14
|
+
config flip — because mobygate had no visibility into what its
|
|
15
|
+
upstream clients were actually sending.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Request/response capture system** (`lib/request-capture.js`).
|
|
20
|
+
- Off by default. Three ways to enable:
|
|
21
|
+
1. Env var: `MOBY_CAPTURE=1 mobygate start`
|
|
22
|
+
2. Touch file: `touch ~/.mobygate/.capture-enabled` (survives
|
|
23
|
+
restarts, dashboard-toggleable)
|
|
24
|
+
3. Dashboard inspector toggle button
|
|
25
|
+
- Writes paired files per request to `~/.mobygate/captures/`:
|
|
26
|
+
- `{ts}_{path}_{requestId}.json` — raw inbound body
|
|
27
|
+
- `{ts}_{path}_{requestId}.summary.txt` — human-readable analysis
|
|
28
|
+
(system blocks, cache_control markers, message timeline, tool
|
|
29
|
+
definitions, token breakdown)
|
|
30
|
+
- On response: appends actual usage from the SDK including
|
|
31
|
+
`cache_read_input_tokens`, `cache_creation_input_tokens`,
|
|
32
|
+
`input_tokens`, `output_tokens`, plus computed cache hit % and
|
|
33
|
+
"savings vs uncached" estimate.
|
|
34
|
+
- Auto-rotation: keeps last `MOBY_CAPTURE_KEEP=100` captures (200
|
|
35
|
+
files), prunes oldest in the background.
|
|
36
|
+
|
|
37
|
+
- **Session inspector UI** at `/inspector`. New focused dashboard for
|
|
38
|
+
browsing captures:
|
|
39
|
+
- List view: all recent captures sorted newest-first, one-line stats
|
|
40
|
+
(timestamp, path, model, msgs, tokens, cache hit %), live polling
|
|
41
|
+
every 3s.
|
|
42
|
+
- Detail view: full request body decoded — system blocks (with
|
|
43
|
+
cache_control highlighted in green), messages timeline (color-coded
|
|
44
|
+
by role, tool_use/tool_result expanded with previews), tool
|
|
45
|
+
definitions, response usage stats.
|
|
46
|
+
- Capture toggle in header — flips the touch file live without
|
|
47
|
+
restarting mobygate.
|
|
48
|
+
- Linked from the main dashboard's endpoints row.
|
|
49
|
+
|
|
50
|
+
- **`mobygate tail` CLI** — `tail -f` style live view of captures as
|
|
51
|
+
they land. Shows last 10 historical entries, then watches the
|
|
52
|
+
captures dir for new files. Color-codes `/v1/messages` (green,
|
|
53
|
+
native shape) vs `/v1/chat/completions` (yellow, OpenAI shape) so
|
|
54
|
+
shape mismatches jump off the screen.
|
|
55
|
+
|
|
56
|
+
- **`extractSdkUsage()` helper** in `lib/anthropic.js` — defensive
|
|
57
|
+
extraction of cache + token fields from SDK result messages,
|
|
58
|
+
handles both flat (`message.input_tokens`) and nested
|
|
59
|
+
(`message.usage.input_tokens`) shapes from the Claude Agent SDK.
|
|
60
|
+
Used by all 4 mobygate handlers (the OpenAI streaming handler also
|
|
61
|
+
now tracks usage where it didn't before).
|
|
62
|
+
|
|
63
|
+
- **Connector migration detection** — `openclawConnector.inspect()`
|
|
64
|
+
now reports "shadow providers": entries in OpenClaw's config that
|
|
65
|
+
point at mobygate's base URL but aren't registered under our
|
|
66
|
+
canonical `moby` / `moby-native` names. Catches pre-v0.8.0
|
|
67
|
+
hand-rolled `claude-max-proxy`-style configs (which silently bypass
|
|
68
|
+
the native surface). Returned as `shadowProviders: [{ name, api,
|
|
69
|
+
baseUrl, recommendation }]` in inspect output.
|
|
70
|
+
|
|
71
|
+
- **Cache endpoints for the dashboard:**
|
|
72
|
+
- `GET /dashboard/captures` — list captures with summary stats
|
|
73
|
+
- `GET /dashboard/captures/:filename` — full body + summary for one
|
|
74
|
+
capture
|
|
75
|
+
- `GET /dashboard/captures-state` — current toggle state
|
|
76
|
+
- `POST /dashboard/captures-toggle` — flip toggle live
|
|
77
|
+
- All require local-origin (DNS-rebinding protection).
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- **Connector idempotency** (v0.8.0 OPEN bug). `mobygate connect <id>`
|
|
82
|
+
run twice with no real change was producing spurious "(changed)"
|
|
83
|
+
diffs and rewriting byte-identical files. Root cause: `diffSummary`
|
|
84
|
+
walks objects structurally (sensitive to key order); the actual
|
|
85
|
+
serialized JSON output was identical. Fix: `writeConfigSafe` now
|
|
86
|
+
byte-compares the rendered output against the existing file content
|
|
87
|
+
before deciding to write. Returns `unchanged: true` if no real
|
|
88
|
+
change. Both `hermesConnector.apply()` and
|
|
89
|
+
`openclawConnector.apply()` updated to surface this flag.
|
|
90
|
+
|
|
91
|
+
- **Tool name display in capture analyzer**. OpenAI-shape tools nest
|
|
92
|
+
the name under `tool.function.name`; the analyzer was only checking
|
|
93
|
+
`tool.name` and showing `(unnamed)` for everything. Now handles
|
|
94
|
+
both shapes via the new `toolName(t)` helper.
|
|
95
|
+
|
|
96
|
+
### Notes
|
|
97
|
+
|
|
98
|
+
The capture system was the lever that made the entire OpenClaw
|
|
99
|
+
debugging session productive. Without it we had no visibility into
|
|
100
|
+
which wire format clients were using, whether `cache_control` markers
|
|
101
|
+
were being set, where in the request the breakpoints landed, or how
|
|
102
|
+
the conversation was growing turn-over-turn. With it, the diagnosis
|
|
103
|
+
took 60 seconds: "this is `/v1/chat/completions`, no `system` array,
|
|
104
|
+
no cache markers, 33k tokens per turn."
|
|
105
|
+
|
|
106
|
+
The migration detection in `openclawConnector.inspect()` is a small
|
|
107
|
+
prevent-the-next-occurrence add. If `inspect()` had reported
|
|
108
|
+
`claude-max-proxy` as a shadow provider with a "flip api to
|
|
109
|
+
anthropic-messages" recommendation, today's hour-long forensics would
|
|
110
|
+
have been a single CLI command.
|
|
111
|
+
|
|
7
112
|
## [0.8.0] — 2026-04-25
|
|
8
113
|
|
|
9
114
|
Auto-wire third-party clients to use mobygate. The "find your client's
|
package/bin/mobygate.js
CHANGED
|
@@ -480,6 +480,78 @@ function cmdLogs() {
|
|
|
480
480
|
spawnSync(cmd, args, { stdio: 'inherit' });
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
+
// `mobygate tail` — watch captures live, print a one-line summary as
|
|
484
|
+
// each new request lands. Reads the .summary.txt file paired with each
|
|
485
|
+
// new .json file in ~/.mobygate/captures/.
|
|
486
|
+
async function cmdTail() {
|
|
487
|
+
const { homedir } = await import('os');
|
|
488
|
+
const { watch, existsSync, readFileSync, mkdirSync, statSync } = await import('fs');
|
|
489
|
+
const { readdir } = await import('fs/promises');
|
|
490
|
+
const { join } = await import('path');
|
|
491
|
+
|
|
492
|
+
const dir = process.env.MOBYGATE_CAPTURE_DIR
|
|
493
|
+
|| join(process.env.MOBYGATE_HOME || join(homedir(), '.mobygate'), 'captures');
|
|
494
|
+
|
|
495
|
+
if (!existsSync(dir)) {
|
|
496
|
+
print(`Captures directory does not exist yet: ${dir}`);
|
|
497
|
+
print(`Enable capture (touch ~/.mobygate/.capture-enabled OR set MOBY_CAPTURE=1) and send a request.`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Print recent history first (last 10), then watch.
|
|
502
|
+
const initial = (await readdir(dir))
|
|
503
|
+
.filter(n => n.endsWith('.summary.txt'))
|
|
504
|
+
.map(n => ({ name: n, mtime: statSync(join(dir, n)).mtimeMs }))
|
|
505
|
+
.sort((a, b) => a.mtime - b.mtime)
|
|
506
|
+
.slice(-10);
|
|
507
|
+
|
|
508
|
+
for (const { name } of initial) {
|
|
509
|
+
printSummaryLine(join(dir, name));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
print(''); // blank line separator before live tail
|
|
513
|
+
print('--- watching for new captures (Ctrl-C to stop) ---');
|
|
514
|
+
|
|
515
|
+
const seen = new Set(initial.map(x => x.name));
|
|
516
|
+
watch(dir, (_event, name) => {
|
|
517
|
+
if (!name || !name.endsWith('.summary.txt')) return;
|
|
518
|
+
if (seen.has(name)) return;
|
|
519
|
+
seen.add(name);
|
|
520
|
+
// Wait briefly so the file is flushed (fs.watch can fire on the
|
|
521
|
+
// creation rename before content is fully written).
|
|
522
|
+
setTimeout(() => printSummaryLine(join(dir, name)), 50);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function printSummaryLine(summaryPath) {
|
|
527
|
+
try {
|
|
528
|
+
const text = readFileSync(summaryPath, 'utf8');
|
|
529
|
+
const grab = (re) => { const m = text.match(re); return m ? m[1].trim() : '?'; };
|
|
530
|
+
const ts = grab(/timestamp:\s+(\S+)/).slice(11, 19);
|
|
531
|
+
const path = grab(/^path:\s+(\S+)/m);
|
|
532
|
+
const model = grab(/^model:\s+(\S+)/m);
|
|
533
|
+
const msgs = grab(/^messages:\s+(\d+)/m);
|
|
534
|
+
const tokens = grab(/grand total:\s+(\d+)/);
|
|
535
|
+
const hit = grab(/cache hit rate:\s+([\d.]+)%/);
|
|
536
|
+
const status = grab(/^status:\s+(\S+)/m);
|
|
537
|
+
const dur = grab(/^duration:\s+(\d+) ms/m);
|
|
538
|
+
|
|
539
|
+
const hitDisplay = hit === '?' ? '─' : `${hit}% hit`;
|
|
540
|
+
const statusDisplay = status === '?' ? '(in flight)' : `${status} ${dur}ms`;
|
|
541
|
+
|
|
542
|
+
// Color-code the path: green for /v1/messages (native), yellow for /v1/chat/completions
|
|
543
|
+
const isNative = path === '/v1/messages';
|
|
544
|
+
const pathColored = isNative ? `\x1b[32m${path}\x1b[0m` : `\x1b[33m${path}\x1b[0m`;
|
|
545
|
+
const hitColored = hit === '?' ? hitDisplay
|
|
546
|
+
: parseFloat(hit) > 50 ? `\x1b[32m${hitDisplay}\x1b[0m`
|
|
547
|
+
: `\x1b[33m${hitDisplay}\x1b[0m`;
|
|
548
|
+
|
|
549
|
+
print(`${ts} ${pathColored.padEnd(35)} ${model.padEnd(22)} msgs=${msgs.padEnd(4)} tok=${tokens.padEnd(6)} ${hitColored.padEnd(20)} ${statusDisplay}`);
|
|
550
|
+
} catch (e) {
|
|
551
|
+
print(`(failed to read ${summaryPath}: ${e.message})`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
483
555
|
async function cmdAuth() {
|
|
484
556
|
section('mobygate auth');
|
|
485
557
|
const status = await getAuthStatus();
|
|
@@ -869,6 +941,7 @@ Usage:
|
|
|
869
941
|
mobygate restart Stop + start
|
|
870
942
|
mobygate status Show service + auth + /health state
|
|
871
943
|
mobygate logs Tail the server log
|
|
944
|
+
mobygate tail Watch captures live (request/response inspector)
|
|
872
945
|
mobygate auth Show auth status + run a refresh probe
|
|
873
946
|
mobygate connect Auto-wire detected clients (Hermes, OpenClaw) to
|
|
874
947
|
use mobygate. Add: <id> for one client, --all for
|
|
@@ -900,6 +973,7 @@ const COMMANDS = {
|
|
|
900
973
|
restart: cmdRestart,
|
|
901
974
|
status: cmdStatus,
|
|
902
975
|
logs: cmdLogs,
|
|
976
|
+
tail: cmdTail,
|
|
903
977
|
auth: cmdAuth,
|
|
904
978
|
uninstall: cmdUninstall,
|
|
905
979
|
version: cmdVersion,
|
package/index.html
CHANGED
|
@@ -361,6 +361,7 @@
|
|
|
361
361
|
<a href="/auth/status?quick=1" class="py-[3px] px-2.5 border border-[#2A2A1F] text-[#C9D9A8] text-[11px] leading-[14px] hover:border-[#5A5F54]">/auth/status</a>
|
|
362
362
|
<a href="/v1/models" class="py-[3px] px-2.5 border border-[#2A2A1F] text-[#C9D9A8] text-[11px] leading-[14px] hover:border-[#5A5F54]">/v1/models</a>
|
|
363
363
|
<a href="/events" class="py-[3px] px-2.5 border border-[#2A2A1F] text-[#C9D9A8] text-[11px] leading-[14px] hover:border-[#5A5F54]">/events</a>
|
|
364
|
+
<a href="/inspector" class="py-[3px] px-2.5 border border-[#B7E56D] text-[#B7E56D] text-[11px] leading-[14px] hover:bg-[#1A1F12]">/inspector</a>
|
|
364
365
|
</div>
|
|
365
366
|
<div class="flex items-center gap-3">
|
|
366
367
|
<span class="uppercase text-[#5A5F54] text-[10px] leading-3 tracking-[0.12em]" id="f-stream">stream · connecting</span>
|
package/inspector.html
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>mobygate — inspector</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=VT323&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #0B0B09;
|
|
13
|
+
--bg-2: #14140F;
|
|
14
|
+
--bg-3: #1F1F17;
|
|
15
|
+
--fg: #F3EFE4;
|
|
16
|
+
--muted: #5A5F54;
|
|
17
|
+
--dim: #8A8F84;
|
|
18
|
+
--accent: #B7E56D;
|
|
19
|
+
--warn: #F1B05A;
|
|
20
|
+
--crit: #E66B5C;
|
|
21
|
+
--hit: #6CC994;
|
|
22
|
+
--border: #2A2A1F;
|
|
23
|
+
}
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; font-size: 13px; height: 100vh; overflow: hidden; }
|
|
26
|
+
.display { font-family: 'VT323', monospace; letter-spacing: 0.02em; }
|
|
27
|
+
a { color: var(--accent); text-decoration: none; }
|
|
28
|
+
a:hover { text-decoration: underline; }
|
|
29
|
+
button { font-family: inherit; font-size: inherit; }
|
|
30
|
+
pre, code { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
|
31
|
+
|
|
32
|
+
header {
|
|
33
|
+
display: flex; align-items: center; gap: 16px;
|
|
34
|
+
padding: 12px 20px; border-bottom: 1px solid var(--border);
|
|
35
|
+
background: var(--bg-2);
|
|
36
|
+
}
|
|
37
|
+
header .title { font-size: 28px; line-height: 1; color: var(--accent); }
|
|
38
|
+
header .breadcrumb { color: var(--dim); font-size: 12px; }
|
|
39
|
+
header .spacer { flex: 1; }
|
|
40
|
+
header .toggle {
|
|
41
|
+
display: flex; align-items: center; gap: 8px;
|
|
42
|
+
padding: 6px 12px; border: 1px solid var(--border);
|
|
43
|
+
background: var(--bg-3); cursor: pointer; color: var(--fg);
|
|
44
|
+
}
|
|
45
|
+
header .toggle.on { border-color: var(--accent); color: var(--accent); }
|
|
46
|
+
header .toggle .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
|
47
|
+
header .toggle.on .dot { background: var(--accent); animation: pulse 1.8s infinite; }
|
|
48
|
+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
|
49
|
+
|
|
50
|
+
.layout { display: grid; grid-template-columns: 460px 1fr; height: calc(100vh - 53px); }
|
|
51
|
+
|
|
52
|
+
.list-pane { border-right: 1px solid var(--border); overflow-y: auto; background: var(--bg); }
|
|
53
|
+
.list-stats { padding: 10px 16px; color: var(--dim); font-size: 11px; border-bottom: 1px solid var(--border); }
|
|
54
|
+
.row {
|
|
55
|
+
padding: 10px 16px; border-bottom: 1px solid var(--border);
|
|
56
|
+
cursor: pointer; transition: background .1s;
|
|
57
|
+
}
|
|
58
|
+
.row:hover { background: var(--bg-2); }
|
|
59
|
+
.row.active { background: var(--bg-3); border-left: 3px solid var(--accent); padding-left: 13px; }
|
|
60
|
+
.row .r1 { display: flex; gap: 8px; align-items: baseline; margin-bottom: 4px; }
|
|
61
|
+
.row .r1 .ts { color: var(--dim); font-size: 11px; }
|
|
62
|
+
.row .r1 .path { color: var(--fg); font-size: 12px; }
|
|
63
|
+
.row .r1 .path.native { color: var(--accent); }
|
|
64
|
+
.row .r2 { color: var(--dim); font-size: 11px; display: flex; gap: 12px; flex-wrap: wrap; }
|
|
65
|
+
.row .r2 .pill { padding: 1px 6px; background: var(--bg-3); border-radius: 2px; }
|
|
66
|
+
.row .r2 .hit { color: var(--hit); }
|
|
67
|
+
.row .r2 .nohit { color: var(--warn); }
|
|
68
|
+
|
|
69
|
+
.detail-pane { overflow-y: auto; padding: 20px 28px; }
|
|
70
|
+
.detail-pane.empty { display: flex; align-items: center; justify-content: center; color: var(--dim); }
|
|
71
|
+
|
|
72
|
+
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
|
|
73
|
+
.stat {
|
|
74
|
+
padding: 12px 14px; border: 1px solid var(--border); background: var(--bg-2);
|
|
75
|
+
}
|
|
76
|
+
.stat .label { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
|
|
77
|
+
.stat .val { color: var(--fg); font-family: 'VT323', monospace; font-size: 24px; line-height: 1; }
|
|
78
|
+
.stat .val.accent { color: var(--accent); }
|
|
79
|
+
.stat .val.warn { color: var(--warn); }
|
|
80
|
+
.stat .val.crit { color: var(--crit); }
|
|
81
|
+
.stat .sub { color: var(--dim); font-size: 11px; margin-top: 2px; }
|
|
82
|
+
|
|
83
|
+
.section { margin-bottom: 24px; }
|
|
84
|
+
.section h3 { color: var(--accent); font-size: 12px; margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
85
|
+
|
|
86
|
+
.sys-block {
|
|
87
|
+
padding: 8px 12px; margin-bottom: 4px;
|
|
88
|
+
border-left: 2px solid var(--border); background: var(--bg-2);
|
|
89
|
+
font-size: 11px;
|
|
90
|
+
}
|
|
91
|
+
.sys-block.cached { border-left-color: var(--hit); }
|
|
92
|
+
.sys-block .head { color: var(--dim); margin-bottom: 4px; }
|
|
93
|
+
.sys-block .head .marker { color: var(--hit); margin-left: 8px; }
|
|
94
|
+
.sys-block .preview { color: var(--fg); white-space: pre-wrap; max-height: 80px; overflow: hidden; opacity: 0.7; }
|
|
95
|
+
|
|
96
|
+
.msg-row { padding: 6px 0; border-bottom: 1px solid var(--border); display: grid; grid-template-columns: 40px 80px 70px 1fr; gap: 12px; align-items: center; font-size: 11px; }
|
|
97
|
+
.msg-row .idx { color: var(--dim); text-align: right; }
|
|
98
|
+
.msg-row .role { padding: 1px 6px; background: var(--bg-3); text-align: center; }
|
|
99
|
+
.msg-row .role.user { color: #88B4FF; }
|
|
100
|
+
.msg-row .role.assistant { color: var(--accent); }
|
|
101
|
+
.msg-row .role.tool { color: var(--warn); }
|
|
102
|
+
.msg-row .role.system { color: var(--crit); }
|
|
103
|
+
.msg-row .bytes { color: var(--dim); text-align: right; }
|
|
104
|
+
.msg-row .preview { color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.85; }
|
|
105
|
+
.msg-row.cached { background: rgba(108, 201, 148, 0.04); }
|
|
106
|
+
|
|
107
|
+
.tool-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
108
|
+
.tool { padding: 6px 10px; background: var(--bg-2); border: 1px solid var(--border); font-size: 11px; }
|
|
109
|
+
|
|
110
|
+
.usage-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 8px; font-size: 11px; }
|
|
111
|
+
.usage-grid > div { padding: 8px 12px; background: var(--bg-2); border: 1px solid var(--border); }
|
|
112
|
+
.usage-grid .label { color: var(--dim); text-transform: uppercase; letter-spacing: 0.05em; font-size: 10px; }
|
|
113
|
+
.usage-grid .val { color: var(--fg); font-size: 18px; font-family: 'VT323', monospace; line-height: 1; margin-top: 4px; }
|
|
114
|
+
.usage-grid .val.hit { color: var(--hit); }
|
|
115
|
+
.usage-grid .val.dim { color: var(--dim); }
|
|
116
|
+
|
|
117
|
+
.placeholder { color: var(--dim); font-size: 12px; padding: 16px; text-align: center; }
|
|
118
|
+
|
|
119
|
+
/* Scrollbar */
|
|
120
|
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
121
|
+
::-webkit-scrollbar-track { background: var(--bg); }
|
|
122
|
+
::-webkit-scrollbar-thumb { background: var(--border); }
|
|
123
|
+
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
|
124
|
+
</style>
|
|
125
|
+
</head>
|
|
126
|
+
<body>
|
|
127
|
+
<header>
|
|
128
|
+
<div class="title display">mobygate :: inspector</div>
|
|
129
|
+
<div class="breadcrumb">~/.mobygate/captures/</div>
|
|
130
|
+
<div class="spacer"></div>
|
|
131
|
+
<button id="toggle" class="toggle">
|
|
132
|
+
<span class="dot"></span>
|
|
133
|
+
<span id="toggle-label">capture: ?</span>
|
|
134
|
+
</button>
|
|
135
|
+
<a href="/">← dashboard</a>
|
|
136
|
+
</header>
|
|
137
|
+
|
|
138
|
+
<div class="layout">
|
|
139
|
+
<div class="list-pane" id="list-pane">
|
|
140
|
+
<div class="list-stats" id="list-stats">loading…</div>
|
|
141
|
+
<div id="list"></div>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="detail-pane empty" id="detail">
|
|
144
|
+
<div>Select a capture from the left to inspect.</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<script>
|
|
149
|
+
let selectedFilename = null;
|
|
150
|
+
let captureCache = []; // last fetch result, used to compute deltas
|
|
151
|
+
|
|
152
|
+
// ──────────── helpers ────────────
|
|
153
|
+
|
|
154
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
155
|
+
const fmt = (n) => Number(n || 0).toLocaleString();
|
|
156
|
+
const fmtBytes = (b) => {
|
|
157
|
+
if (b < 1024) return b + ' B';
|
|
158
|
+
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
|
|
159
|
+
return (b/(1024*1024)).toFixed(1) + ' MB';
|
|
160
|
+
};
|
|
161
|
+
const fmtTime = (ms) => {
|
|
162
|
+
const d = new Date(ms);
|
|
163
|
+
return d.toLocaleTimeString('en-US', { hour12: false });
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ──────────── capture toggle ────────────
|
|
167
|
+
|
|
168
|
+
async function refreshToggleState() {
|
|
169
|
+
try {
|
|
170
|
+
const r = await fetch('/dashboard/captures-state');
|
|
171
|
+
const s = await r.json();
|
|
172
|
+
const btn = document.getElementById('toggle');
|
|
173
|
+
const lbl = document.getElementById('toggle-label');
|
|
174
|
+
btn.classList.toggle('on', !!s.enabled);
|
|
175
|
+
lbl.textContent = 'capture: ' + (s.enabled ? 'ON' : 'off') + (s.envVar ? ' (env)' : '');
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
document.getElementById('toggle').addEventListener('click', async () => {
|
|
180
|
+
const cur = document.getElementById('toggle').classList.contains('on');
|
|
181
|
+
try {
|
|
182
|
+
await fetch('/dashboard/captures-toggle', {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'content-type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({ enabled: !cur }),
|
|
186
|
+
});
|
|
187
|
+
refreshToggleState();
|
|
188
|
+
} catch {}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ──────────── list rendering ────────────
|
|
192
|
+
|
|
193
|
+
async function refreshList() {
|
|
194
|
+
try {
|
|
195
|
+
const r = await fetch('/dashboard/captures?limit=200');
|
|
196
|
+
const data = await r.json();
|
|
197
|
+
captureCache = data.captures || [];
|
|
198
|
+
renderList(captureCache, data);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
document.getElementById('list-stats').textContent = 'failed: ' + e.message;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderList(captures, meta) {
|
|
205
|
+
const stats = document.getElementById('list-stats');
|
|
206
|
+
if (!captures.length) {
|
|
207
|
+
stats.textContent = 'no captures yet — toggle capture on and send a request';
|
|
208
|
+
document.getElementById('list').innerHTML = '';
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
stats.textContent = `${captures.length} captures · ${meta.total} total`;
|
|
212
|
+
|
|
213
|
+
const html = captures.map(c => {
|
|
214
|
+
const isNative = c.path === '/v1/messages';
|
|
215
|
+
const hitClass = c.cacheHitPct == null ? '' : (parseFloat(c.cacheHitPct) > 50 ? 'hit' : 'nohit');
|
|
216
|
+
const hitText = c.cacheHitPct == null ? 'no resp yet' : `${c.cacheHitPct}% cache`;
|
|
217
|
+
const sel = c.filename === selectedFilename ? ' active' : '';
|
|
218
|
+
return `<div class="row${sel}" data-fn="${esc(c.filename)}">
|
|
219
|
+
<div class="r1">
|
|
220
|
+
<span class="ts">${fmtTime(c.ts)}</span>
|
|
221
|
+
<span class="path${isNative ? ' native' : ''}">${esc(c.path)}</span>
|
|
222
|
+
<span class="ts" style="margin-left:auto">${esc(c.model || '')}</span>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="r2">
|
|
225
|
+
<span class="pill">${c.msgCount} msgs</span>
|
|
226
|
+
<span class="pill">${fmt(c.grandTokens)} tok</span>
|
|
227
|
+
<span class="pill ${hitClass}">${hitText}</span>
|
|
228
|
+
${c.cacheControlSystem ? `<span class="pill">sys-cache ${esc(c.cacheControlSystem)}</span>` : ''}
|
|
229
|
+
</div>
|
|
230
|
+
</div>`;
|
|
231
|
+
}).join('');
|
|
232
|
+
document.getElementById('list').innerHTML = html;
|
|
233
|
+
|
|
234
|
+
// Wire row clicks
|
|
235
|
+
document.querySelectorAll('.row').forEach(el => {
|
|
236
|
+
el.addEventListener('click', () => loadDetail(el.dataset.fn));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ──────────── detail rendering ────────────
|
|
241
|
+
|
|
242
|
+
async function loadDetail(filename) {
|
|
243
|
+
selectedFilename = filename;
|
|
244
|
+
document.querySelectorAll('.row').forEach(r => r.classList.toggle('active', r.dataset.fn === filename));
|
|
245
|
+
const detail = document.getElementById('detail');
|
|
246
|
+
detail.classList.remove('empty');
|
|
247
|
+
detail.innerHTML = `<div class="placeholder">loading ${esc(filename)}…</div>`;
|
|
248
|
+
try {
|
|
249
|
+
const r = await fetch('/dashboard/captures/' + encodeURIComponent(filename));
|
|
250
|
+
const data = await r.json();
|
|
251
|
+
renderDetail(data);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
detail.innerHTML = `<div class="placeholder">failed: ${esc(e.message)}</div>`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function renderDetail({ filename, body, summary }) {
|
|
258
|
+
const sysBlocks = Array.isArray(body.system) ? body.system : (typeof body.system === 'string' ? [{ type: 'text', text: body.system }] : []);
|
|
259
|
+
const msgs = body.messages || [];
|
|
260
|
+
const tools = body.tools || [];
|
|
261
|
+
const sysBytes = sysBlocks.reduce((a, b) => a + (b?.text?.length || 0), 0);
|
|
262
|
+
const msgBytes = msgs.reduce((a, m) => {
|
|
263
|
+
if (typeof m.content === 'string') return a + m.content.length;
|
|
264
|
+
if (Array.isArray(m.content)) return a + m.content.reduce((aa, b) => aa + (b?.text?.length || 0) + (b?.input ? JSON.stringify(b.input).length : 0) + (typeof b?.content === 'string' ? b.content.length : 0), 0);
|
|
265
|
+
return a;
|
|
266
|
+
}, 0);
|
|
267
|
+
const toolBytes = JSON.stringify(tools).length;
|
|
268
|
+
const grand = sysBytes + msgBytes + toolBytes;
|
|
269
|
+
|
|
270
|
+
// Pull response stats out of the appended summary text
|
|
271
|
+
const grab = (re) => { const m = summary.match(re); return m ? m[1].trim() : null; };
|
|
272
|
+
const inputUncached = parseInt(grab(/input_tokens \(uncached\):\s+(\d+)/) || '0', 10);
|
|
273
|
+
const cacheRead = parseInt(grab(/cache_read_input_tokens:\s+(\d+)/) || '0', 10);
|
|
274
|
+
const cacheCreate = parseInt(grab(/cache_creation_input_tokens:\s+(\d+)/) || '0', 10);
|
|
275
|
+
const outputTokens = parseInt(grab(/output_tokens:\s+(\d+)/) || '0', 10);
|
|
276
|
+
const cacheHit = grab(/cache hit rate:\s+([\d.]+)%/);
|
|
277
|
+
const savings = grab(/savings from cache:\s+([\d.]+)%/);
|
|
278
|
+
const duration = grab(/duration:\s+(\d+) ms/);
|
|
279
|
+
|
|
280
|
+
const sysHtml = sysBlocks.length === 0
|
|
281
|
+
? '<div class="placeholder">no system block</div>'
|
|
282
|
+
: sysBlocks.map((b, i) => {
|
|
283
|
+
const cached = !!b.cache_control;
|
|
284
|
+
const preview = (b.text || '').slice(0, 600);
|
|
285
|
+
return `<div class="sys-block${cached ? ' cached' : ''}">
|
|
286
|
+
<div class="head">[${i}] ${esc(b.type || '?')} · ${fmt((b.text||'').length)} bytes${cached ? `<span class="marker">cache_control: ${esc(JSON.stringify(b.cache_control))}</span>` : ''}</div>
|
|
287
|
+
<div class="preview">${esc(preview)}${(b.text||'').length > 600 ? '…' : ''}</div>
|
|
288
|
+
</div>`;
|
|
289
|
+
}).join('');
|
|
290
|
+
|
|
291
|
+
const msgHtml = msgs.length > 60
|
|
292
|
+
? renderTrimmedMessages(msgs)
|
|
293
|
+
: msgs.map((m, i) => renderMessageRow(m, i)).join('');
|
|
294
|
+
|
|
295
|
+
const toolsHtml = tools.length === 0
|
|
296
|
+
? '<div class="placeholder">no tools declared</div>'
|
|
297
|
+
: `<div class="tool-list">${tools.map(t => {
|
|
298
|
+
const name = t.name || t.function?.name || '(unnamed)';
|
|
299
|
+
return `<div class="tool">${esc(name)}</div>`;
|
|
300
|
+
}).join('')}</div>`;
|
|
301
|
+
|
|
302
|
+
const usageHtml = (cacheRead + cacheCreate + inputUncached + outputTokens === 0)
|
|
303
|
+
? '<div class="placeholder">response not captured yet (request still in flight, or capture was off when response landed)</div>'
|
|
304
|
+
: `<div class="usage-grid">
|
|
305
|
+
<div>
|
|
306
|
+
<div class="label">cache hit rate</div>
|
|
307
|
+
<div class="val hit">${cacheHit || '0'}%</div>
|
|
308
|
+
<div class="label" style="margin-top:6px">savings</div>
|
|
309
|
+
<div class="val ${parseFloat(savings)>50?'hit':''}" style="font-size:14px">${savings || '0'}%</div>
|
|
310
|
+
</div>
|
|
311
|
+
<div>
|
|
312
|
+
<div class="label">input (uncached)</div>
|
|
313
|
+
<div class="val">${fmt(inputUncached)}</div>
|
|
314
|
+
</div>
|
|
315
|
+
<div>
|
|
316
|
+
<div class="label">cache read</div>
|
|
317
|
+
<div class="val hit">${fmt(cacheRead)}</div>
|
|
318
|
+
</div>
|
|
319
|
+
<div>
|
|
320
|
+
<div class="label">cache create</div>
|
|
321
|
+
<div class="val ${cacheCreate>0?'':'dim'}">${fmt(cacheCreate)}</div>
|
|
322
|
+
<div class="label" style="margin-top:6px">output</div>
|
|
323
|
+
<div class="val" style="font-size:14px">${fmt(outputTokens)}</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>`;
|
|
326
|
+
|
|
327
|
+
document.getElementById('detail').innerHTML = `
|
|
328
|
+
<div class="stat-grid">
|
|
329
|
+
<div class="stat">
|
|
330
|
+
<div class="label">model</div>
|
|
331
|
+
<div class="val accent">${esc(body.model || '?')}</div>
|
|
332
|
+
<div class="sub">${body.stream ? 'streaming' : 'sync'} · ${duration ? duration + ' ms' : '—'}</div>
|
|
333
|
+
</div>
|
|
334
|
+
<div class="stat">
|
|
335
|
+
<div class="label">messages</div>
|
|
336
|
+
<div class="val">${msgs.length}</div>
|
|
337
|
+
<div class="sub">${fmtBytes(msgBytes)}</div>
|
|
338
|
+
</div>
|
|
339
|
+
<div class="stat">
|
|
340
|
+
<div class="label">total wire</div>
|
|
341
|
+
<div class="val">${fmt(Math.round(grand/4))}</div>
|
|
342
|
+
<div class="sub">~tokens · ${fmtBytes(grand)}</div>
|
|
343
|
+
</div>
|
|
344
|
+
<div class="stat">
|
|
345
|
+
<div class="label">system block</div>
|
|
346
|
+
<div class="val ${sysBlocks.some(b=>b.cache_control)?'accent':'warn'}">${fmtBytes(sysBytes)}</div>
|
|
347
|
+
<div class="sub">${sysBlocks.length} block(s) · ${sysBlocks.filter(b=>b.cache_control).length} cached</div>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<div class="section">
|
|
352
|
+
<h3>Response usage</h3>
|
|
353
|
+
${usageHtml}
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
<div class="section">
|
|
357
|
+
<h3>System blocks</h3>
|
|
358
|
+
${sysHtml}
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div class="section">
|
|
362
|
+
<h3>Messages timeline (${msgs.length})</h3>
|
|
363
|
+
${msgHtml}
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<div class="section">
|
|
367
|
+
<h3>Tools (${tools.length})</h3>
|
|
368
|
+
${toolsHtml}
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<div class="section">
|
|
372
|
+
<h3>Raw summary</h3>
|
|
373
|
+
<pre style="background:var(--bg-2);padding:12px;border:1px solid var(--border);font-size:11px;color:var(--dim);white-space:pre-wrap;max-height:300px;overflow:auto">${esc(summary)}</pre>
|
|
374
|
+
</div>
|
|
375
|
+
`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function renderMessageRow(m, i) {
|
|
379
|
+
const role = m.role || '?';
|
|
380
|
+
const cached = Array.isArray(m.content) && m.content.some(b => b?.cache_control);
|
|
381
|
+
let preview = '';
|
|
382
|
+
let bytes = 0;
|
|
383
|
+
if (typeof m.content === 'string') {
|
|
384
|
+
preview = m.content.slice(0, 200);
|
|
385
|
+
bytes = m.content.length;
|
|
386
|
+
} else if (Array.isArray(m.content)) {
|
|
387
|
+
const parts = m.content.map(b => {
|
|
388
|
+
if (b?.type === 'text') return b.text || '';
|
|
389
|
+
if (b?.type === 'tool_use') return `🔧 ${b.name || '?'}(${JSON.stringify(b.input || {}).slice(0, 80)})`;
|
|
390
|
+
if (b?.type === 'tool_result') {
|
|
391
|
+
const tc = Array.isArray(b.content) ? (b.content.find(x => x.type === 'text')?.text || '') : (b.content || '');
|
|
392
|
+
return `← ${String(tc).slice(0, 100)}`;
|
|
393
|
+
}
|
|
394
|
+
if (b?.type === 'image') return '🖼️ [image]';
|
|
395
|
+
return JSON.stringify(b).slice(0, 80);
|
|
396
|
+
});
|
|
397
|
+
preview = parts.join(' · ').slice(0, 250);
|
|
398
|
+
bytes = m.content.reduce((a, b) => a + (b?.text?.length || 0) + (b?.input ? JSON.stringify(b.input).length : 0) + (typeof b?.content === 'string' ? b.content.length : 0), 0);
|
|
399
|
+
}
|
|
400
|
+
return `<div class="msg-row${cached ? ' cached' : ''}">
|
|
401
|
+
<span class="idx">${i}</span>
|
|
402
|
+
<span class="role ${role}">${role}</span>
|
|
403
|
+
<span class="bytes">${fmtBytes(bytes)}</span>
|
|
404
|
+
<span class="preview">${esc(preview)}${cached ? ' 🔒' : ''}</span>
|
|
405
|
+
</div>`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderTrimmedMessages(msgs) {
|
|
409
|
+
// Show first 5 and last 10 for long conversations
|
|
410
|
+
const head = msgs.slice(0, 5).map((m, i) => renderMessageRow(m, i)).join('');
|
|
411
|
+
const tail = msgs.slice(-10).map((m, i) => renderMessageRow(m, msgs.length - 10 + i)).join('');
|
|
412
|
+
return head + `<div class="placeholder">… ${msgs.length - 15} messages collapsed …</div>` + tail;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ──────────── kickoff ────────────
|
|
416
|
+
|
|
417
|
+
refreshToggleState();
|
|
418
|
+
refreshList();
|
|
419
|
+
setInterval(() => { refreshList(); refreshToggleState(); }, 3000);
|
|
420
|
+
</script>
|
|
421
|
+
</body>
|
|
422
|
+
</html>
|
package/lib/anthropic.js
CHANGED
|
@@ -27,6 +27,29 @@
|
|
|
27
27
|
|
|
28
28
|
import { v4 as uuidv4 } from 'uuid';
|
|
29
29
|
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// SDK usage extraction
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pulls the full token usage from an SDK 'result' message — defensive
|
|
36
|
+
* against shape variations (the Claude Agent SDK sometimes nests these
|
|
37
|
+
* under `.usage`, sometimes places them flat on the message). Returns
|
|
38
|
+
* a complete usage shape with cache_read / cache_creation fields zeroed
|
|
39
|
+
* out if absent. Used by the 4 mobygate handlers to populate response
|
|
40
|
+
* captures and dashboard cache-hit metrics.
|
|
41
|
+
*/
|
|
42
|
+
export function extractSdkUsage(message) {
|
|
43
|
+
if (!message) return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
|
44
|
+
const u = message.usage || message;
|
|
45
|
+
return {
|
|
46
|
+
input_tokens: u.input_tokens || 0,
|
|
47
|
+
output_tokens: u.output_tokens || 0,
|
|
48
|
+
cache_read_input_tokens: u.cache_read_input_tokens || 0,
|
|
49
|
+
cache_creation_input_tokens: u.cache_creation_input_tokens || 0,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
// ---------------------------------------------------------------------------
|
|
31
54
|
// Content extraction — read individual block types out of an Anthropic message
|
|
32
55
|
// ---------------------------------------------------------------------------
|