mobygate 0.7.3 → 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/server.js CHANGED
@@ -76,8 +76,10 @@ import {
76
76
  makeStreamTranslator,
77
77
  hasAnthropicTools,
78
78
  mapStopReason,
79
+ extractSdkUsage,
79
80
  } from './lib/anthropic.js';
80
81
  import { resolveSessionKey } from './lib/session-derive.js';
82
+ import { captureRequest, captureResponse, isCaptureEnabled, CAPTURE_DIR_PATH } from './lib/request-capture.js';
81
83
 
82
84
  const __filename = fileURLToPath(import.meta.url);
83
85
  const __dirname = dirname(__filename);
@@ -444,6 +446,10 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
444
446
  let resolvedModel = model;
445
447
  let capturedSessionId = existing?.sdkSessionId || null;
446
448
  let clientDisconnected = false;
449
+ let inputTokens = 0;
450
+ let outputTokens = 0;
451
+ let cacheReadTokens = 0;
452
+ let cacheCreateTokens = 0;
447
453
 
448
454
  res.on('close', () => {
449
455
  clientDisconnected = true;
@@ -568,6 +574,11 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
568
574
  isFirst = false;
569
575
  }
570
576
  if (toolsEnabled && !bufferedText && message.result) bufferedText = message.result;
577
+ const usage = extractSdkUsage(message);
578
+ inputTokens = usage.input_tokens;
579
+ outputTokens = usage.output_tokens;
580
+ cacheReadTokens = usage.cache_read_input_tokens;
581
+ cacheCreateTokens = usage.cache_creation_input_tokens;
571
582
  break;
572
583
  }
573
584
  }
@@ -627,6 +638,13 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
627
638
  }
628
639
  res.write('data: [DONE]\n\n');
629
640
  res.end();
641
+ captureResponse({
642
+ requestId,
643
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens, cache_creation_input_tokens: cacheCreateTokens },
644
+ status: 'ok',
645
+ stopReason: collectedToolCalls.length > 0 ? 'tool_use' : 'end_turn',
646
+ model: resolvedModel,
647
+ });
630
648
  return;
631
649
  }
632
650
 
@@ -635,6 +653,14 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
635
653
  res.write('data: [DONE]\n\n');
636
654
  res.end();
637
655
  }
656
+
657
+ captureResponse({
658
+ requestId,
659
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens, cache_creation_input_tokens: cacheCreateTokens },
660
+ status: clientDisconnected ? 'client_disconnect' : 'ok',
661
+ stopReason: 'end_turn',
662
+ model: resolvedModel,
663
+ });
638
664
  }
639
665
 
640
666
  // ---------------------------------------------------------------------------
@@ -670,6 +696,9 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
670
696
  let resolvedModel = model;
671
697
  let inputTokens = 0;
672
698
  let outputTokens = 0;
699
+ let cacheReadTokens = 0;
700
+ let cacheCreateTokens = 0;
701
+ let stopReason = 'end_turn';
673
702
  let capturedSessionId = existing?.sdkSessionId || null;
674
703
  const abortController = new AbortController();
675
704
 
@@ -750,8 +779,12 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
750
779
  if (isAuthFailureText(resultText)) {
751
780
  throw new AuthFailureInResultText(resultText);
752
781
  }
753
- inputTokens = message.input_tokens || 0;
754
- outputTokens = message.output_tokens || 0;
782
+ const usage = extractSdkUsage(message);
783
+ inputTokens = usage.input_tokens;
784
+ outputTokens = usage.output_tokens;
785
+ cacheReadTokens = usage.cache_read_input_tokens;
786
+ cacheCreateTokens = usage.cache_creation_input_tokens;
787
+ if (message.subtype) stopReason = message.subtype;
755
788
  break;
756
789
  }
757
790
  }
@@ -818,6 +851,14 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
818
851
  }],
819
852
  usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens },
820
853
  });
854
+
855
+ captureResponse({
856
+ requestId,
857
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens, cache_creation_input_tokens: cacheCreateTokens },
858
+ status: 'ok',
859
+ stopReason,
860
+ model: resolvedModel,
861
+ });
821
862
  }
822
863
 
823
864
  // ---------------------------------------------------------------------------
@@ -870,6 +911,8 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
870
911
  let resolvedModel = model;
871
912
  let inputTokens = 0;
872
913
  let outputTokens = 0;
914
+ let cacheReadTokens = 0;
915
+ let cacheCreateTokens = 0;
873
916
  let capturedSessionId = existing?.sdkSessionId || null;
874
917
  let stopReason = 'end_turn';
875
918
  const abortController = new AbortController();
@@ -950,8 +993,11 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
950
993
  if (isAuthFailureText(resultText)) {
951
994
  throw new AuthFailureInResultText(resultText);
952
995
  }
953
- inputTokens = message.input_tokens || 0;
954
- outputTokens = message.output_tokens || 0;
996
+ const usage = extractSdkUsage(message);
997
+ inputTokens = usage.input_tokens;
998
+ outputTokens = usage.output_tokens;
999
+ cacheReadTokens = usage.cache_read_input_tokens;
1000
+ cacheCreateTokens = usage.cache_creation_input_tokens;
955
1001
  stopReason = mapStopReason(message);
956
1002
  break;
957
1003
  }
@@ -990,6 +1036,14 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
990
1036
  requestId,
991
1037
  stopReason,
992
1038
  }));
1039
+
1040
+ captureResponse({
1041
+ requestId,
1042
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens, cache_creation_input_tokens: cacheCreateTokens },
1043
+ status: 'ok',
1044
+ stopReason,
1045
+ model: resolvedModel,
1046
+ });
993
1047
  }
994
1048
 
995
1049
  async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
@@ -1033,6 +1087,8 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
1033
1087
  let capturedSessionId = existing?.sdkSessionId || null;
1034
1088
  let inputTokens = 0;
1035
1089
  let outputTokens = 0;
1090
+ let cacheReadTokens = 0;
1091
+ let cacheCreateTokens = 0;
1036
1092
  let stopReason = 'end_turn';
1037
1093
  let clientDisconnected = false;
1038
1094
  let textEmittedSoFar = ''; // dedup against same-message reflow from SDK
@@ -1182,8 +1238,11 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
1182
1238
  if (isAuthFailureText(message.result || '') && !tx.hasStarted) {
1183
1239
  throw new AuthFailureInResultText(message.result);
1184
1240
  }
1185
- inputTokens = message.input_tokens || 0;
1186
- outputTokens = message.output_tokens || 0;
1241
+ const usage = extractSdkUsage(message);
1242
+ inputTokens = usage.input_tokens;
1243
+ outputTokens = usage.output_tokens;
1244
+ cacheReadTokens = usage.cache_read_input_tokens;
1245
+ cacheCreateTokens = usage.cache_creation_input_tokens;
1187
1246
  if (!toolUseEmitted) stopReason = mapStopReason(message);
1188
1247
  break;
1189
1248
  }
@@ -1214,6 +1273,14 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
1214
1273
  }
1215
1274
 
1216
1275
  tx.finish({ stopReason, usage: { output_tokens: outputTokens } });
1276
+
1277
+ captureResponse({
1278
+ requestId,
1279
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens, cache_creation_input_tokens: cacheCreateTokens },
1280
+ status: 'ok',
1281
+ stopReason,
1282
+ model: resolvedModel,
1283
+ });
1217
1284
  }
1218
1285
 
1219
1286
  // ---------------------------------------------------------------------------
@@ -1311,6 +1378,19 @@ app.get('/', async (_req, res) => {
1311
1378
  }
1312
1379
  });
1313
1380
 
1381
+ // /inspector — session inspector UI for browsing captures.
1382
+ // Backed by /dashboard/captures and /dashboard/captures/:filename.
1383
+ app.get('/inspector', async (_req, res) => {
1384
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1385
+ try {
1386
+ const { readFile } = await import('fs/promises');
1387
+ const html = await readFile(join(__dirname, 'inspector.html'), 'utf8');
1388
+ res.type('html').send(html);
1389
+ } catch (e) {
1390
+ res.status(404).type('text').send(`inspector.html not found at ${join(__dirname, 'inspector.html')}`);
1391
+ }
1392
+ });
1393
+
1314
1394
  // POST /v1/chat/completions
1315
1395
  app.post('/v1/chat/completions', async (req, res) => {
1316
1396
  const requestId = uuidv4().replace(/-/g, '').slice(0, 24);
@@ -1339,6 +1419,9 @@ app.post('/v1/chat/completions', async (req, res) => {
1339
1419
 
1340
1420
  console.log(`[${new Date().toISOString()}] ${body.stream ? 'stream' : 'sync'} | model=${body.model} → ${resolveModel(body.model)} | msgs=${body.messages.length}${sessionTag}`);
1341
1421
 
1422
+ // Diagnostic capture — off by default, enable with MOBY_CAPTURE=1.
1423
+ captureRequest({ path: '/v1/chat/completions', body, requestId, sessionKey, sessionKeySource });
1424
+
1342
1425
  // Dashboard: request.start
1343
1426
  const startedAt = Date.now();
1344
1427
  const imageBlocks = collectImages(body.messages).length;
@@ -1411,6 +1494,10 @@ app.post('/v1/messages', async (req, res) => {
1411
1494
 
1412
1495
  console.log(`[${new Date().toISOString()}] anthropic ${body.stream ? 'stream' : 'sync'} | model=${body.model} → ${resolveModel(body.model)} | msgs=${body.messages.length}${sessionTag}`);
1413
1496
 
1497
+ // Diagnostic capture — off by default, enable with MOBY_CAPTURE=1.
1498
+ // Writes raw body + summary to ~/.mobygate/captures/.
1499
+ captureRequest({ path: '/v1/messages', body, requestId, sessionKey, sessionKeySource });
1500
+
1414
1501
  // Dashboard event — same shape as the OpenAI route, just labeled by path.
1415
1502
  const startedAt = Date.now();
1416
1503
  const imageBlocks = collectAnthropicImages(body.messages || []).length;
@@ -1659,6 +1746,158 @@ app.get('/dashboard/logs', requireLocalOrigin, async (req, res) => {
1659
1746
  }
1660
1747
  });
1661
1748
 
1749
+ // ---------------------------------------------------------------------------
1750
+ // Captures — diagnostic request/response inspector for the dashboard
1751
+ // ---------------------------------------------------------------------------
1752
+ //
1753
+ // These endpoints back the session-inspector UI. They expose the contents
1754
+ // of `~/.mobygate/captures/` (created by lib/request-capture.js) so the
1755
+ // dashboard can list past requests, drill into individual ones, and
1756
+ // toggle capture on/off live without restarting mobygate.
1757
+ //
1758
+ // All capture endpoints require local-origin (DNS-rebinding protection)
1759
+ // because they expose full request bodies including conversation content.
1760
+
1761
+ // Helper: parse a capture filename and return its components.
1762
+ // "2026-04-28_03-49-05_v1-chat-completions_abc123.json" → { ts, slug, requestId }
1763
+ function parseCaptureFilename(name) {
1764
+ const match = name.match(/^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})_([\w-]+)_([0-9a-f]+)\.(json|summary\.txt)$/);
1765
+ if (!match) return null;
1766
+ return {
1767
+ timestamp: match[1].replace('_', 'T').replace(/-/g, (m, i) => i < 10 ? '-' : ':') + 'Z',
1768
+ slug: '/' + match[2].replace(/-/g, '/'),
1769
+ requestId: match[3],
1770
+ type: match[4],
1771
+ };
1772
+ }
1773
+
1774
+ // Quick-read a summary.txt for the listing card (avoid loading the full JSON).
1775
+ async function readSummaryQuick(summaryPath) {
1776
+ const { readFile } = await import('fs/promises');
1777
+ const text = await readFile(summaryPath, 'utf8');
1778
+ const grab = (re, dflt = null) => { const m = text.match(re); return m ? m[1].trim() : dflt; };
1779
+ return {
1780
+ sessionKey: grab(/^session_key:\s+(\S+)/m),
1781
+ sessionSrc: grab(/^session_source:\s+(\S+)/m),
1782
+ model: grab(/^model:\s+(.+?)$/m),
1783
+ stream: grab(/^stream:\s+(\S+)/m) === 'true',
1784
+ msgCount: parseInt(grab(/^messages:\s+(\d+)/m, '0'), 10),
1785
+ sysBytes: parseInt(grab(/^\s*system:\s+(\d+)/m, '0'), 10),
1786
+ grandBytes: parseInt(grab(/^grand total:\s+(\d+)/m, '0'), 10),
1787
+ grandTokens: parseInt(grab(/≈\s+(\d+)\s+input/, '0'), 10),
1788
+ cacheControlSystem: grab(/cache_control:\s+(\d+\/\d+)\s+system/),
1789
+ // Response side (only present after captureResponse fires)
1790
+ inputTokens: parseInt(grab(/input_tokens \(uncached\):\s+(\d+)/, '0'), 10),
1791
+ cacheRead: parseInt(grab(/cache_read_input_tokens:\s+(\d+)/, '0'), 10),
1792
+ cacheCreate: parseInt(grab(/cache_creation_input_tokens:\s+(\d+)/, '0'), 10),
1793
+ outputTokens: parseInt(grab(/output_tokens:\s+(\d+)/, '0'), 10),
1794
+ cacheHitPct: grab(/cache hit rate:\s+([\d.]+)%/),
1795
+ durationMs: parseInt(grab(/^duration:\s+(\d+) ms/m, '0'), 10),
1796
+ status: grab(/^status:\s+(\S+)/m),
1797
+ stopReason: grab(/^stop_reason:\s+(\S+)/m),
1798
+ };
1799
+ }
1800
+
1801
+ // GET /dashboard/captures — list captures (newest first), with summary stats.
1802
+ app.get('/dashboard/captures', requireLocalOrigin, async (req, res) => {
1803
+ try {
1804
+ const { readdir, stat } = await import('fs/promises');
1805
+ const { CAPTURE_DIR_PATH } = await import('./lib/request-capture.js');
1806
+ const limit = Math.min(500, parseInt(req.query.limit || '100', 10));
1807
+
1808
+ let entries;
1809
+ try {
1810
+ entries = await readdir(CAPTURE_DIR_PATH);
1811
+ } catch {
1812
+ return res.json({ captures: [], dir: CAPTURE_DIR_PATH, note: 'capture dir does not exist (capture has not run yet)' });
1813
+ }
1814
+
1815
+ // Pair .json with .summary.txt by matching the base name.
1816
+ const jsonFiles = entries.filter((n) => n.endsWith('.json'));
1817
+ const items = [];
1818
+ for (const jsonName of jsonFiles) {
1819
+ const summaryName = jsonName.replace(/\.json$/, '.summary.txt');
1820
+ if (!entries.includes(summaryName)) continue;
1821
+ const meta = parseCaptureFilename(jsonName);
1822
+ if (!meta) continue;
1823
+ const fullJson = join(CAPTURE_DIR_PATH, jsonName);
1824
+ const fullSummary = join(CAPTURE_DIR_PATH, summaryName);
1825
+ try {
1826
+ const [stJson, stSummary, summary] = await Promise.all([
1827
+ stat(fullJson),
1828
+ stat(fullSummary),
1829
+ readSummaryQuick(fullSummary),
1830
+ ]);
1831
+ items.push({
1832
+ filename: jsonName,
1833
+ summaryFilename: summaryName,
1834
+ ts: stJson.mtimeMs,
1835
+ path: meta.slug,
1836
+ requestId: meta.requestId,
1837
+ jsonBytes: stJson.size,
1838
+ ...summary,
1839
+ });
1840
+ } catch {}
1841
+ }
1842
+ items.sort((a, b) => b.ts - a.ts);
1843
+ res.json({ captures: items.slice(0, limit), total: items.length, dir: CAPTURE_DIR_PATH });
1844
+ } catch (e) {
1845
+ res.status(500).json({ error: e.message });
1846
+ }
1847
+ });
1848
+
1849
+ // GET /dashboard/captures/:filename — full body + summary for one capture.
1850
+ // Filename must end in .json (we serve the body and infer the summary path).
1851
+ app.get('/dashboard/captures/:filename', requireLocalOrigin, async (req, res) => {
1852
+ try {
1853
+ const { readFile } = await import('fs/promises');
1854
+ const { CAPTURE_DIR_PATH } = await import('./lib/request-capture.js');
1855
+ const filename = req.params.filename;
1856
+ // Defense in depth: reject anything with path separators or .. — must
1857
+ // be a bare filename within the capture dir.
1858
+ if (!/^[\w.-]+\.json$/.test(filename)) {
1859
+ return res.status(400).json({ error: 'invalid filename' });
1860
+ }
1861
+ const jsonPath = join(CAPTURE_DIR_PATH, filename);
1862
+ const summaryPath = join(CAPTURE_DIR_PATH, filename.replace(/\.json$/, '.summary.txt'));
1863
+ const [bodyRaw, summaryRaw] = await Promise.all([
1864
+ readFile(jsonPath, 'utf8'),
1865
+ readFile(summaryPath, 'utf8').catch(() => '(summary not found)'),
1866
+ ]);
1867
+ res.json({
1868
+ filename,
1869
+ body: JSON.parse(bodyRaw),
1870
+ summary: summaryRaw,
1871
+ });
1872
+ } catch (e) {
1873
+ res.status(404).json({ error: e.message });
1874
+ }
1875
+ });
1876
+
1877
+ // GET /dashboard/captures-state — is capture currently enabled?
1878
+ app.get('/dashboard/captures-state', requireLocalOrigin, async (_req, res) => {
1879
+ const { isCaptureEnabled, CAPTURE_DIR_PATH, CAPTURE_TOGGLE_FILE } = await import('./lib/request-capture.js');
1880
+ res.json({
1881
+ enabled: isCaptureEnabled(),
1882
+ captureDir: CAPTURE_DIR_PATH,
1883
+ toggleFile: CAPTURE_TOGGLE_FILE,
1884
+ envVar: !!(process.env.MOBY_CAPTURE === '1' || process.env.MOBY_CAPTURE === 'true'),
1885
+ });
1886
+ });
1887
+
1888
+ // POST /dashboard/captures-toggle — flip the touch file on/off.
1889
+ // Body: { enabled: true | false }
1890
+ app.post('/dashboard/captures-toggle', requireLocalOrigin, async (req, res) => {
1891
+ try {
1892
+ const { setCaptureEnabled } = await import('./lib/request-capture.js');
1893
+ const target = !!req.body?.enabled;
1894
+ const newState = await setCaptureEnabled(target);
1895
+ res.json({ enabled: newState });
1896
+ } catch (e) {
1897
+ res.status(500).json({ error: e.message });
1898
+ }
1899
+ });
1900
+
1662
1901
  // ---------------------------------------------------------------------------
1663
1902
  // Updater — dashboard-driven "update available → update now" flow
1664
1903
  // ---------------------------------------------------------------------------
@@ -1727,6 +1966,9 @@ app.listen(PORT, BIND, async () => {
1727
1966
  console.log(` model ${DEFAULT_MODEL}`);
1728
1967
  console.log(` session TTL ${ttlMin} min`);
1729
1968
  console.log(` dashboard http://localhost:${PORT}`);
1969
+ if (isCaptureEnabled()) {
1970
+ console.log(` capture ON → ${CAPTURE_DIR_PATH.replace(process.env.HOME || '', '~')}`);
1971
+ }
1730
1972
  console.log('');
1731
1973
  dashboardBus.emitEvent({ type: 'server.boot', port: PORT, bind: BIND, defaultModel: DEFAULT_MODEL });
1732
1974
  });