@stackmeter/cli 0.1.2 → 0.1.4

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.
@@ -21,7 +21,6 @@ const target = args[1];
21
21
  // ── Constants ────────────────────────────────────────────
22
22
 
23
23
  const DEFAULT_BASE_URL = "https://stackmeter.app";
24
- const DEFAULT_GATEWAY_PORT = 8787;
25
24
  const LAUNCH_AGENT_LABEL = "com.stackmeter.gateway";
26
25
  const PLIST_PATH = join(
27
26
  homedir(), "Library", "LaunchAgents", `${LAUNCH_AGENT_LABEL}.plist`
@@ -33,7 +32,6 @@ const SM_LAUNCHER_PATH = join(SM_CONFIG_DIR, "start-gateway.mjs");
33
32
  const SM_GATEWAY_LOG = join(SM_CONFIG_DIR, "gateway.log");
34
33
  const SM_GATEWAY_ERR_LOG = join(SM_CONFIG_DIR, "gateway.err.log");
35
34
  const OC_CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
36
- const OC_BACKUP_PATH = join(homedir(), ".openclaw", "openclaw.json.stackmeter.bak");
37
35
 
38
36
  // ── Command routing ──────────────────────────────────────
39
37
 
@@ -86,11 +84,6 @@ function openBrowser(url) {
86
84
  exec(`${cmd} "${url}"`, () => {});
87
85
  }
88
86
 
89
- function redactKey(key) {
90
- if (!key || key.length < 8) return "****";
91
- return `...${key.slice(-4)}`;
92
- }
93
-
94
87
  // ── Config readers / writers ─────────────────────────────
95
88
 
96
89
  function readOpenClawConfig() {
@@ -299,88 +292,46 @@ async function connectOpenClaw() {
299
292
  console.log(" \u2705 Token verified!");
300
293
  console.log("");
301
294
 
302
- // ── Step 4: Read OpenClaw config ───────────────────
295
+ // ── Step 4: Verify OpenClaw is installed ─────────────
303
296
  const { config: ocConfig } = readOpenClawConfig();
304
- const existingApiKey =
305
- ocConfig?.models?.providers?.openai?.apiKey ||
306
- process.env.OPENAI_API_KEY ||
307
- "";
308
-
309
- if (!existingApiKey) {
310
- console.log(" \u274C OpenAI API key not found.");
297
+ if (!ocConfig) {
311
298
  console.log(
312
- " Set models.providers.openai.apiKey in ~/.openclaw/openclaw.json"
299
+ " \u26A0\uFE0F OpenClaw config not found at ~/.openclaw/openclaw.json"
313
300
  );
314
- console.log(" or: export OPENAI_API_KEY=sk-...");
315
- rl.close();
316
- process.exit(1);
301
+ console.log(" The log watcher will start anyway and wait for OpenClaw logs.");
302
+ console.log("");
317
303
  }
318
304
 
319
305
  if (autoFlag) {
320
- const gatewayPort = parseInt(
321
- process.env.STACKMETER_GATEWAY_PORT || String(DEFAULT_GATEWAY_PORT),
322
- 10
323
- );
324
- const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}/v1`;
325
-
326
- // ── Backup openclaw.json (first run only) ────────
327
- if (ocConfig && !existsSync(OC_BACKUP_PATH)) {
328
- copyFileSync(OC_CONFIG_PATH, OC_BACKUP_PATH);
329
- console.log(` \u{1F4CB} Backed up ${OC_CONFIG_PATH}`);
330
- console.log(` \u2192 ${OC_BACKUP_PATH}`);
331
- console.log("");
332
- }
333
-
334
- // ── Patch openclaw.json ──────────────────────────
335
- if (ocConfig) {
336
- if (!ocConfig.models) ocConfig.models = {};
337
- if (!ocConfig.models.providers) ocConfig.models.providers = {};
338
- if (!ocConfig.models.providers.openai)
339
- ocConfig.models.providers.openai = {};
340
- ocConfig.models.providers.openai.baseUrl = gatewayBaseUrl;
341
- writeFileSync(OC_CONFIG_PATH, JSON.stringify(ocConfig, null, 2) + "\n");
342
- console.log(` \u2705 Patched ${OC_CONFIG_PATH}`);
343
- console.log(
344
- ` models.providers.openai.baseUrl = "${gatewayBaseUrl}"`
345
- );
346
- } else {
347
- console.log(
348
- " \u274C OpenClaw config not found at ~/.openclaw/openclaw.json"
349
- );
350
- console.log(" Install OpenClaw first: https://openclaw.dev");
351
- rl.close();
352
- process.exit(1);
353
- }
354
- console.log("");
355
-
356
306
  // ── Write ~/.stackmeter/config.json ──────────────
357
307
  writeStackMeterConfig({
358
308
  token,
359
309
  url: apiUrl,
360
- gatewayPort,
361
310
  });
362
311
  console.log(` \u2705 Saved config to ${SM_CONFIG_PATH}`);
363
312
  console.log("");
364
313
 
365
- // ── Install gateway as persistent service ────────
314
+ // ── Install log watcher as persistent service ────
366
315
  if (isMac) {
367
- // Copy gateway to stable path so LaunchAgent survives npx cache eviction
368
- const gatewaySource = resolve(
369
- __dirname, "..", "src", "gateway", "server.mjs"
316
+ // Copy log watcher to stable path so LaunchAgent survives npx cache eviction
317
+ const watcherSource = resolve(
318
+ __dirname, "..", "src", "gateway", "log-watcher.mjs"
370
319
  );
371
- if (!existsSync(gatewaySource)) {
372
- console.log(` \u274C Gateway script not found at ${gatewaySource}`);
320
+ if (!existsSync(watcherSource)) {
321
+ console.log(` \u274C Log watcher script not found at ${watcherSource}`);
373
322
  rl.close();
374
323
  process.exit(1);
375
324
  }
376
325
 
377
- copyFileSync(gatewaySource, SM_GATEWAY_PATH);
326
+ copyFileSync(watcherSource, SM_GATEWAY_PATH);
378
327
  writeFileSync(
379
328
  SM_LAUNCHER_PATH,
380
329
  [
381
330
  "#!/usr/bin/env node",
382
- 'import { startGateway } from "./gateway.mjs";',
383
- "startGateway();",
331
+ 'import { startLogWatcher } from "./gateway.mjs";',
332
+ "startLogWatcher();",
333
+ // Keep process alive
334
+ "setInterval(() => {}, 60_000);",
384
335
  "",
385
336
  ].join("\n")
386
337
  );
@@ -388,20 +339,17 @@ async function connectOpenClaw() {
388
339
  installLaunchAgent(process.execPath, SM_LAUNCHER_PATH);
389
340
  loadLaunchAgent();
390
341
 
391
- console.log(" \u2705 Gateway installed as background service");
342
+ console.log(" \u2705 Log watcher installed as background service");
392
343
  console.log(` LaunchAgent: ${PLIST_PATH}`);
393
344
  console.log(` Logs: ${SM_GATEWAY_LOG}`);
394
345
  } else {
395
- // Non-macOS: start gateway in foreground
396
- console.log(" Starting gateway in foreground...");
397
- process.env.STACKMETER_URL = apiUrl;
398
- process.env.STACKMETER_TOKEN = token;
399
- if (!process.env.OPENAI_API_KEY)
400
- process.env.OPENAI_API_KEY = existingApiKey;
401
-
346
+ // Non-macOS: start log watcher in foreground
347
+ console.log(" Starting log watcher in foreground...");
402
348
  rl.close();
403
- const { startGateway } = await import("../src/gateway/server.mjs");
404
- await startGateway();
349
+ const { startLogWatcher } = await import("../src/gateway/log-watcher.mjs");
350
+ startLogWatcher({ smUrl: apiUrl, smToken: token });
351
+ // Keep process alive
352
+ setInterval(() => {}, 60_000);
405
353
  return;
406
354
  }
407
355
 
@@ -409,8 +357,9 @@ async function connectOpenClaw() {
409
357
  console.log(" \u2705 Done! OpenClaw is connected to StackMeter.");
410
358
  console.log("");
411
359
  console.log(
412
- " Just run OpenClaw normally \u2014 no extra flags needed."
360
+ " No changes to OpenClaw config needed \u2014 just run OpenClaw normally."
413
361
  );
362
+ console.log(" The log watcher reads OpenClaw's logs in /tmp/openclaw/");
414
363
  console.log(` View your usage at ${baseUrl}/app/ai-usage`);
415
364
  console.log("");
416
365
  rl.close();
@@ -418,27 +367,15 @@ async function connectOpenClaw() {
418
367
  // ── No --auto: Print manual steps ────────────────
419
368
  console.log(" \u2705 Token is valid. Follow these steps to complete setup:");
420
369
  console.log("");
421
- console.log(` 1. Edit ${OC_CONFIG_PATH}`);
422
- console.log(
423
- ' Set models.providers.openai.baseUrl to "http://127.0.0.1:8787/v1"'
424
- );
425
- console.log("");
426
- console.log(" 2. Set env vars:");
370
+ console.log(" 1. Set env vars:");
427
371
  console.log(` export STACKMETER_URL="${apiUrl}"`);
428
372
  console.log(` export STACKMETER_TOKEN="${token}"`);
429
- if (existingApiKey) {
430
- console.log(
431
- ` export OPENAI_API_KEY="${redactKey(existingApiKey)}"`
432
- );
433
- } else {
434
- console.log(' export OPENAI_API_KEY="sk-..." # your OpenAI key');
435
- }
436
373
  console.log("");
437
- console.log(" 3. Start the gateway:");
374
+ console.log(" 2. Start the log watcher:");
438
375
  console.log(" npx @stackmeter/cli gateway");
439
376
  console.log("");
440
377
  console.log(
441
- " 4. Start OpenClaw \u2014 traffic will flow through the gateway."
378
+ " 3. Run OpenClaw normally \u2014 the watcher reads its logs automatically."
442
379
  );
443
380
  console.log("");
444
381
  rl.close();
@@ -458,22 +395,7 @@ async function disconnectOpenClaw() {
458
395
  // ── Stop and remove LaunchAgent ────────────────────
459
396
  if (isMac) {
460
397
  unloadLaunchAgent();
461
- console.log(" \u2705 Gateway service stopped and removed");
462
- }
463
-
464
- // ── Restore openclaw.json from backup ──────────────
465
- if (existsSync(OC_BACKUP_PATH)) {
466
- copyFileSync(OC_BACKUP_PATH, OC_CONFIG_PATH);
467
- unlinkSync(OC_BACKUP_PATH);
468
- console.log(` \u2705 Restored ${OC_CONFIG_PATH} from backup`);
469
- } else {
470
- // No backup — just remove the baseUrl we set
471
- const { config: ocConfig } = readOpenClawConfig();
472
- if (ocConfig?.models?.providers?.openai?.baseUrl?.includes("127.0.0.1")) {
473
- delete ocConfig.models.providers.openai.baseUrl;
474
- writeFileSync(OC_CONFIG_PATH, JSON.stringify(ocConfig, null, 2) + "\n");
475
- console.log(` \u2705 Removed gateway baseUrl from ${OC_CONFIG_PATH}`);
476
- }
398
+ console.log(" \u2705 Log watcher service stopped and removed");
477
399
  }
478
400
 
479
401
  // ── Remove StackMeter config ───────────────────────
@@ -493,11 +415,13 @@ async function disconnectOpenClaw() {
493
415
  console.log("");
494
416
  }
495
417
 
496
- // ── gateway ──────────────────────────────────────────────
418
+ // ── gateway (now runs log watcher) ───────────────────────
497
419
 
498
420
  async function startGatewayCommand() {
499
- const { startGateway } = await import("../src/gateway/server.mjs");
500
- await startGateway();
421
+ const { startLogWatcher } = await import("../src/gateway/log-watcher.mjs");
422
+ startLogWatcher();
423
+ // Keep process alive
424
+ setInterval(() => {}, 60_000);
501
425
  }
502
426
 
503
427
  // ── gateway --self-test ──────────────────────────────────
@@ -507,17 +431,10 @@ async function selfTest() {
507
431
 
508
432
  // Read config from file or env
509
433
  const smConfig = readStackMeterConfig();
510
- const openaiKey =
511
- process.env.OPENAI_API_KEY ||
512
- (() => {
513
- const { config } = readOpenClawConfig();
514
- return config?.models?.providers?.openai?.apiKey;
515
- })();
516
434
  const smUrl = process.env.STACKMETER_URL || smConfig.url;
517
435
  const smToken = process.env.STACKMETER_TOKEN || smConfig.token;
518
436
 
519
437
  const missing = [];
520
- if (!openaiKey) missing.push("OPENAI_API_KEY");
521
438
  if (!smUrl) missing.push("STACKMETER_URL");
522
439
  if (!smToken) missing.push("STACKMETER_TOKEN");
523
440
  if (missing.length) {
@@ -531,89 +448,56 @@ async function selfTest() {
531
448
  process.exit(1);
532
449
  }
533
450
 
534
- // Set env vars so gateway picks them up
535
- if (!process.env.OPENAI_API_KEY) process.env.OPENAI_API_KEY = openaiKey;
536
- if (!process.env.STACKMETER_URL) process.env.STACKMETER_URL = smUrl;
537
- if (!process.env.STACKMETER_TOKEN) process.env.STACKMETER_TOKEN = smToken;
538
-
539
451
  console.log("");
540
- console.log(" \u{1F9EA} StackMeter Gateway \u2014 Self-test");
452
+ console.log(" \u{1F9EA} StackMeter \u2014 Self-test");
541
453
  console.log("");
542
454
 
543
- const { startGateway } = await import("../src/gateway/server.mjs");
544
-
545
- let emitStatus = 0;
546
- const server = await startGateway({
547
- port: 0, // OS-assigned port
548
- onEmit: (status) => {
549
- emitStatus = status;
550
- },
551
- });
552
-
553
- const addr = server.address();
554
- const gwUrl = `http://127.0.0.1:${addr.port}`;
555
-
556
- // 1/3 — Health check
557
- console.log(" 1/3 Health check...");
558
- const healthRes = await fetch(`${gwUrl}/health`);
559
- if (!healthRes.ok) {
560
- console.log(" \u274C Health check failed");
561
- server.close();
562
- process.exit(1);
563
- }
564
- console.log(" \u2705 Gateway healthy");
565
-
566
- // 2/3 — Send minimal completion through gateway
567
- console.log(
568
- " 2/3 Sending test completion (gpt-4o-mini, max_tokens=1)..."
569
- );
455
+ // 1/2 Test StackMeter API connectivity
456
+ console.log(" 1/2 Testing StackMeter API...");
570
457
  try {
571
- const compRes = await fetch(`${gwUrl}/v1/chat/completions`, {
458
+ const res = await fetch(smUrl, {
572
459
  method: "POST",
573
- headers: { "Content-Type": "application/json" },
460
+ headers: {
461
+ Authorization: `Bearer ${smToken}`,
462
+ "Content-Type": "application/json",
463
+ },
574
464
  body: JSON.stringify({
575
- model: "gpt-4o-mini",
576
- messages: [{ role: "user", content: "Hi" }],
577
- max_tokens: 1,
465
+ provider: "stackmeter",
466
+ model: "self-test",
467
+ inputTokens: 0,
468
+ outputTokens: 0,
469
+ costCents: 0,
470
+ sourceType: "openclaw",
471
+ sourceId: "cli-self-test",
472
+ dedupKey: `self-test-${Date.now()}`,
578
473
  }),
579
474
  });
580
475
 
581
- if (!compRes.ok) {
582
- const err = await compRes.text().catch(() => "");
583
- console.log(
584
- ` \u274C OpenAI returned ${compRes.status}: ${err.slice(0, 200)}`
585
- );
586
- server.close();
587
- process.exit(1);
476
+ if (res.ok) {
477
+ console.log(` \u2705 StackMeter API responded (${res.status})`);
478
+ } else {
479
+ const err = await res.text().catch(() => "");
480
+ console.log(` \u274C StackMeter API returned ${res.status}: ${err.slice(0, 200)}`);
588
481
  }
589
-
590
- const result = await compRes.json();
591
- console.log(
592
- ` \u2705 OpenAI response: ${result.usage?.total_tokens ?? "?"} tokens`
593
- );
594
482
  } catch (e) {
595
- console.log(` \u274C Request failed: ${e.message}`);
596
- server.close();
597
- process.exit(1);
483
+ console.log(` \u274C Could not reach StackMeter: ${e.message}`);
598
484
  }
599
485
 
600
- // 3/3Wait for async StackMeter emit
601
- console.log(" 3/3 Waiting for StackMeter ingestion...");
602
- await new Promise((r) => setTimeout(r, 2000));
486
+ // 2/2Check OpenClaw log file exists
487
+ console.log(" 2/2 Checking OpenClaw logs...");
488
+ const d = new Date();
489
+ const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
490
+ const logPath = `/tmp/openclaw/openclaw-${ymd}.log`;
603
491
 
604
- if (emitStatus >= 200 && emitStatus < 300) {
605
- console.log(` \u2705 StackMeter ingestion returned ${emitStatus}`);
606
- } else if (emitStatus > 0) {
607
- console.log(
608
- ` \u26A0\uFE0F StackMeter ingestion returned ${emitStatus}`
609
- );
492
+ if (existsSync(logPath)) {
493
+ console.log(` \u2705 OpenClaw log found: ${logPath}`);
610
494
  } else {
611
- console.log(" \u26A0\uFE0F Could not confirm StackMeter ingestion");
495
+ console.log(` \u26A0\uFE0F No log found at ${logPath}`);
496
+ console.log(" Start OpenClaw first, then re-run this test.");
612
497
  }
613
498
 
614
499
  console.log("");
615
500
  console.log(" Self-test complete.");
616
501
  console.log("");
617
- server.close();
618
502
  process.exit(0);
619
503
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmeter/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Track your SaaS AI costs from the terminal. One-command OpenClaw setup.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,303 @@
1
+ // StackMeter Log Watcher — Tails OpenClaw logs and emits usage events.
2
+ // Replaces the proxy gateway approach since OpenClaw doesn't support
3
+ // base URL overrides for its HTTP client.
4
+
5
+ import { readFileSync, existsSync, statSync, watchFile, unwatchFile } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ const OC_LOG_DIR = "/tmp/openclaw";
10
+
11
+ // Cost per 1M tokens in cents (best-effort estimates)
12
+ const MODEL_PRICING = {
13
+ // OpenAI
14
+ "gpt-4o": { input: 250, output: 1000 },
15
+ "gpt-4o-mini": { input: 15, output: 60 },
16
+ "gpt-4-turbo": { input: 1000, output: 3000 },
17
+ "gpt-4": { input: 3000, output: 6000 },
18
+ "gpt-3.5-turbo": { input: 50, output: 150 },
19
+ "o1": { input: 1500, output: 6000 },
20
+ "o1-mini": { input: 300, output: 1200 },
21
+ "o3-mini": { input: 110, output: 440 },
22
+ // Anthropic
23
+ "claude-opus-4-6": { input: 1500, output: 7500 },
24
+ "claude-opus-4-5": { input: 1500, output: 7500 },
25
+ "claude-sonnet-4-5": { input: 300, output: 1500 },
26
+ "claude-haiku-4-5": { input: 80, output: 400 },
27
+ "claude-3-opus": { input: 1500, output: 7500 },
28
+ "claude-3-5-sonnet": { input: 300, output: 1500 },
29
+ "claude-3-5-haiku": { input: 80, output: 400 },
30
+ "claude-3-haiku": { input: 25, output: 125 },
31
+ };
32
+
33
+ // Rough tokens/second estimates by model class for duration-based estimation
34
+ const TOKENS_PER_SEC = {
35
+ "gpt-4o": 80,
36
+ "gpt-4o-mini": 120,
37
+ "gpt-4-turbo": 40,
38
+ "gpt-4": 30,
39
+ "gpt-3.5-turbo": 100,
40
+ "o1": 20,
41
+ "o1-mini": 40,
42
+ "o3-mini": 60,
43
+ "claude-opus-4-6": 30,
44
+ "claude-opus-4-5": 30,
45
+ "claude-sonnet-4-5": 60,
46
+ "claude-haiku-4-5": 120,
47
+ "claude-3-opus": 30,
48
+ "claude-3-5-sonnet": 60,
49
+ "claude-3-5-haiku": 120,
50
+ "claude-3-haiku": 150,
51
+ };
52
+
53
+ function matchModel(model) {
54
+ for (const key of Object.keys(MODEL_PRICING)) {
55
+ if (model === key || model.startsWith(key + "-")) return key;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function estimateFromDuration(model, durationMs) {
61
+ const matched = matchModel(model);
62
+ const tps = matched ? (TOKENS_PER_SEC[matched] || 60) : 60;
63
+ const secs = durationMs / 1000;
64
+ // Rough split: ~30% input time, ~70% output time
65
+ const outputTokens = Math.round(secs * 0.7 * tps);
66
+ const inputTokens = Math.round(secs * 0.3 * tps * 0.5); // input is faster
67
+ return { inputTokens, outputTokens };
68
+ }
69
+
70
+ function estimateCostCents(model, inputTokens, outputTokens) {
71
+ const matched = matchModel(model);
72
+ if (!matched) return 0;
73
+ const pricing = MODEL_PRICING[matched];
74
+ return Math.round(
75
+ (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000
76
+ );
77
+ }
78
+
79
+ /* ── Config ──────────────────────────────────────────────── */
80
+
81
+ function readStackMeterConfig() {
82
+ const p = join(homedir(), ".stackmeter", "config.json");
83
+ if (!existsSync(p)) return {};
84
+ try { return JSON.parse(readFileSync(p, "utf-8")); }
85
+ catch { return {}; }
86
+ }
87
+
88
+ /* ── Emit usage event to StackMeter ──────────────────────── */
89
+
90
+ async function emitUsageEvent(event, smUrl, smToken) {
91
+ try {
92
+ const res = await fetch(smUrl, {
93
+ method: "POST",
94
+ headers: {
95
+ Authorization: `Bearer ${smToken}`,
96
+ "Content-Type": "application/json",
97
+ },
98
+ body: JSON.stringify(event),
99
+ });
100
+
101
+ if (res.ok) {
102
+ console.log(
103
+ ` [watcher] ${event.provider}/${event.model} ~${event.inputTokens}+${event.outputTokens} tokens` +
104
+ ` \u2192 ${event.costCents}\u00A2 (${res.status})`
105
+ );
106
+ } else {
107
+ const err = await res.text().catch(() => "");
108
+ console.error(` [watcher] emit failed (${res.status}): ${err}`);
109
+ }
110
+ return res.status;
111
+ } catch (err) {
112
+ console.error(` [watcher] emit error: ${err.message}`);
113
+ return 0;
114
+ }
115
+ }
116
+
117
+ /* ── Log parser ──────────────────────────────────────────── */
118
+
119
+ function parseLogLine(line) {
120
+ try {
121
+ const obj = JSON.parse(line);
122
+ const subsystem = obj["0"] || "";
123
+ const msg = typeof obj["1"] === "string" ? obj["1"] : "";
124
+ const ts = obj.time || obj._meta?.date;
125
+ return { subsystem, msg, data: obj["1"], ts };
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /* ── Log watcher ─────────────────────────────────────────── */
132
+
133
+ export function startLogWatcher(opts = {}) {
134
+ const smConfig = readStackMeterConfig();
135
+ const smUrl = opts.smUrl || process.env.STACKMETER_URL || smConfig.url;
136
+ const smToken = opts.smToken || process.env.STACKMETER_TOKEN || smConfig.token;
137
+ const onEmit = opts.onEmit;
138
+
139
+ if (!smUrl) {
140
+ console.error(" Error: STACKMETER_URL is required.");
141
+ console.error(" Run: npx @stackmeter/cli connect openclaw --auto");
142
+ process.exit(1);
143
+ }
144
+ if (!smToken) {
145
+ console.error(" Error: STACKMETER_TOKEN is required.");
146
+ console.error(" Run: npx @stackmeter/cli connect openclaw --auto");
147
+ process.exit(1);
148
+ }
149
+
150
+ // Track active runs: runId -> { provider, model, startTime }
151
+ const activeRuns = new Map();
152
+ // Track emitted dedupKeys to avoid re-emitting on file re-read
153
+ const emittedRuns = new Set();
154
+
155
+ let currentLogPath = null;
156
+ let fileOffset = 0;
157
+
158
+ function getLogPath() {
159
+ const d = new Date();
160
+ const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
161
+ return join(OC_LOG_DIR, `openclaw-${ymd}.log`);
162
+ }
163
+
164
+ function processLine(line) {
165
+ if (!line.trim()) return;
166
+ const parsed = parseLogLine(line);
167
+ if (!parsed) return;
168
+
169
+ const { msg, data, ts } = parsed;
170
+
171
+ // Match: "embedded run start: runId=xxx ... provider=yyy model=zzz"
172
+ if (typeof msg === "string" && msg.includes("embedded run start:")) {
173
+ const runId = msg.match(/runId=(\S+)/)?.[1];
174
+ const provider = msg.match(/provider=(\S+)/)?.[1];
175
+ const model = msg.match(/model=(\S+)/)?.[1];
176
+ if (runId && provider && model) {
177
+ activeRuns.set(runId, { provider, model, startTime: ts });
178
+ }
179
+ return;
180
+ }
181
+
182
+ // Match: "embedded run done: runId=xxx ... durationMs=NNN"
183
+ if (typeof msg === "string" && msg.includes("embedded run done:")) {
184
+ const runId = msg.match(/runId=(\S+)/)?.[1];
185
+ const durationMs = parseInt(msg.match(/durationMs=(\d+)/)?.[1] || "0", 10);
186
+ const aborted = msg.includes("aborted=true");
187
+
188
+ if (!runId || aborted) return;
189
+ if (emittedRuns.has(runId)) return;
190
+
191
+ const run = activeRuns.get(runId);
192
+ if (!run) return;
193
+
194
+ activeRuns.delete(runId);
195
+ emittedRuns.add(runId);
196
+
197
+ // Estimate tokens from duration
198
+ const { inputTokens, outputTokens } = estimateFromDuration(run.model, durationMs);
199
+ const costCents = estimateCostCents(run.model, inputTokens, outputTokens);
200
+
201
+ const event = {
202
+ provider: run.provider,
203
+ model: run.model,
204
+ inputTokens,
205
+ outputTokens,
206
+ costCents,
207
+ sourceType: "openclaw",
208
+ sourceId: "log-watcher",
209
+ ts: run.startTime || new Date().toISOString(),
210
+ dedupKey: `oc-${runId}`,
211
+ };
212
+
213
+ const p = emitUsageEvent(event, smUrl, smToken);
214
+ if (onEmit) p.then(onEmit);
215
+ return;
216
+ }
217
+ }
218
+
219
+ function readNewLines() {
220
+ const logPath = getLogPath();
221
+
222
+ // Date rolled over — switch to new file
223
+ if (logPath !== currentLogPath) {
224
+ if (currentLogPath) {
225
+ unwatchFile(currentLogPath);
226
+ }
227
+ currentLogPath = logPath;
228
+ fileOffset = 0;
229
+
230
+ // If file already exists, start from the end (don't replay old events)
231
+ if (existsSync(logPath)) {
232
+ const stat = statSync(logPath);
233
+ fileOffset = stat.size;
234
+ }
235
+
236
+ watchLogFile(logPath);
237
+ return;
238
+ }
239
+
240
+ if (!existsSync(logPath)) return;
241
+
242
+ const stat = statSync(logPath);
243
+ if (stat.size <= fileOffset) return;
244
+
245
+ // Read only new bytes
246
+ const fd = readFileSync(logPath, "utf-8");
247
+ const newContent = fd.slice(fileOffset);
248
+ fileOffset = fd.length;
249
+
250
+ const lines = newContent.split("\n");
251
+ for (const line of lines) {
252
+ processLine(line);
253
+ }
254
+ }
255
+
256
+ function watchLogFile(logPath) {
257
+ if (!existsSync(logPath)) {
258
+ // File doesn't exist yet — poll until it does
259
+ const check = setInterval(() => {
260
+ if (existsSync(logPath)) {
261
+ clearInterval(check);
262
+ watchLogFile(logPath);
263
+ }
264
+ }, 5000);
265
+ return;
266
+ }
267
+
268
+ watchFile(logPath, { interval: 1000 }, () => {
269
+ readNewLines();
270
+ });
271
+ }
272
+
273
+ // Check for date rollover every 60s
274
+ setInterval(() => {
275
+ const newPath = getLogPath();
276
+ if (newPath !== currentLogPath) {
277
+ readNewLines(); // triggers switchover
278
+ }
279
+ }, 60_000);
280
+
281
+ // Start watching
282
+ currentLogPath = getLogPath();
283
+
284
+ if (existsSync(currentLogPath)) {
285
+ const stat = statSync(currentLogPath);
286
+ fileOffset = stat.size; // start from end
287
+ }
288
+
289
+ watchLogFile(currentLogPath);
290
+
291
+ console.log("");
292
+ console.log(" StackMeter Log Watcher");
293
+ console.log(` Watching: ${OC_LOG_DIR}/openclaw-*.log`);
294
+ console.log(` Reporting: ${smUrl}`);
295
+ console.log("");
296
+
297
+ // Return a cleanup function
298
+ return {
299
+ stop() {
300
+ if (currentLogPath) unwatchFile(currentLogPath);
301
+ }
302
+ };
303
+ }
@@ -1,4 +1,4 @@
1
- // StackMeter Gateway — OpenAI-compatible proxy with usage tracking.
1
+ // StackMeter Gateway — OpenAI + Anthropic proxy with usage tracking.
2
2
  // Does NOT store prompts or responses. Only token counts + metadata.
3
3
 
4
4
  import { createServer } from "node:http";
@@ -7,11 +7,16 @@ import { readFileSync, existsSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
 
10
- const OPENAI_BASE_URL = "https://api.openai.com";
11
10
  const DEFAULT_PORT = 8787;
12
11
 
12
+ const UPSTREAM = {
13
+ openai: "api.openai.com",
14
+ anthropic: "api.anthropic.com",
15
+ };
16
+
13
17
  // Cost per 1M tokens in cents (best-effort estimates)
14
18
  const MODEL_PRICING = {
19
+ // OpenAI
15
20
  "gpt-4o": { input: 250, output: 1000 },
16
21
  "gpt-4o-mini": { input: 15, output: 60 },
17
22
  "gpt-4-turbo": { input: 1000, output: 3000 },
@@ -20,6 +25,15 @@ const MODEL_PRICING = {
20
25
  "o1": { input: 1500, output: 6000 },
21
26
  "o1-mini": { input: 300, output: 1200 },
22
27
  "o3-mini": { input: 110, output: 440 },
28
+ // Anthropic
29
+ "claude-opus-4-6": { input: 1500, output: 7500 },
30
+ "claude-opus-4-5": { input: 1500, output: 7500 },
31
+ "claude-sonnet-4-5": { input: 300, output: 1500 },
32
+ "claude-haiku-4-5": { input: 80, output: 400 },
33
+ "claude-3-opus": { input: 1500, output: 7500 },
34
+ "claude-3-5-sonnet": { input: 300, output: 1500 },
35
+ "claude-3-5-haiku": { input: 80, output: 400 },
36
+ "claude-3-haiku": { input: 25, output: 125 },
23
37
  };
24
38
 
25
39
  /* ── Config file readers (fallback when env vars not set) ── */
@@ -40,6 +54,15 @@ function readOpenClawApiKey() {
40
54
  } catch { return null; }
41
55
  }
42
56
 
57
+ /* ── Provider detection ───────────────────────────────────── */
58
+
59
+ function detectProvider(method, url) {
60
+ if (method !== "POST") return null;
61
+ if (url === "/v1/chat/completions" || url === "/v1/responses") return "openai";
62
+ if (url === "/v1/messages") return "anthropic";
63
+ return null;
64
+ }
65
+
43
66
  /* ── Pricing + usage helpers ──────────────────────────────── */
44
67
 
45
68
  function estimateCostCents(model, inputTokens, outputTokens) {
@@ -56,8 +79,8 @@ function estimateCostCents(model, inputTokens, outputTokens) {
56
79
  );
57
80
  }
58
81
 
59
- async function emitUsageEvent(usage, model, smUrl, smToken) {
60
- // Handle both Chat Completions (prompt_tokens) and Responses API (input_tokens)
82
+ async function emitUsageEvent(usage, model, provider, smUrl, smToken) {
83
+ // Handle both Chat Completions (prompt_tokens) and Anthropic/Responses API (input_tokens)
61
84
  const {
62
85
  prompt_tokens = 0, completion_tokens = 0,
63
86
  input_tokens = 0, output_tokens = 0,
@@ -67,7 +90,7 @@ async function emitUsageEvent(usage, model, smUrl, smToken) {
67
90
  const costCents = estimateCostCents(model, inTok, outTok);
68
91
 
69
92
  const event = {
70
- provider: "openai",
93
+ provider,
71
94
  model,
72
95
  inputTokens: inTok,
73
96
  outputTokens: outTok,
@@ -90,7 +113,7 @@ async function emitUsageEvent(usage, model, smUrl, smToken) {
90
113
 
91
114
  if (res.ok) {
92
115
  console.log(
93
- ` [stackmeter] ${model} ${inTok}+${outTok} tokens \u2192 ${costCents}\u00A2 (${res.status})`
116
+ ` [stackmeter] ${provider}/${model} ${inTok}+${outTok} tokens \u2192 ${costCents}\u00A2 (${res.status})`
94
117
  );
95
118
  } else {
96
119
  const err = await res.text().catch(() => "");
@@ -103,7 +126,9 @@ async function emitUsageEvent(usage, model, smUrl, smToken) {
103
126
  }
104
127
  }
105
128
 
106
- function extractUsageFromSSE(sseBuffer, smUrl, smToken) {
129
+ /* ── OpenAI SSE usage extraction ──────────────────────────── */
130
+
131
+ function extractUsageFromOpenAISSE(sseBuffer, smUrl, smToken) {
107
132
  const lines = sseBuffer.split("\n");
108
133
  let lastModel = "unknown";
109
134
  let lastUsage = null;
@@ -113,16 +138,46 @@ function extractUsageFromSSE(sseBuffer, smUrl, smToken) {
113
138
  try {
114
139
  const data = JSON.parse(line.slice(6));
115
140
  if (data.model) lastModel = data.model;
116
- // Chat Completions format
117
141
  if (data.usage) lastUsage = data.usage;
118
- // Responses API streaming format (usage inside response object)
142
+ // Responses API streaming (usage inside response object)
119
143
  if (data.response?.usage) lastUsage = data.response.usage;
120
144
  if (data.response?.model) lastModel = data.response.model;
121
145
  } catch {}
122
146
  }
123
147
 
124
148
  if (lastUsage) {
125
- return emitUsageEvent(lastUsage, lastModel, smUrl, smToken);
149
+ return emitUsageEvent(lastUsage, lastModel, "openai", smUrl, smToken);
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /* ── Anthropic SSE usage extraction ───────────────────────── */
155
+
156
+ function extractUsageFromAnthropicSSE(sseBuffer, smUrl, smToken) {
157
+ const lines = sseBuffer.split("\n");
158
+ let model = "unknown";
159
+ let inputTokens = 0;
160
+ let outputTokens = 0;
161
+
162
+ for (const line of lines) {
163
+ if (!line.startsWith("data: ")) continue;
164
+ try {
165
+ const data = JSON.parse(line.slice(6));
166
+ // message_start contains the model and input token count
167
+ if (data.type === "message_start" && data.message) {
168
+ model = data.message.model || model;
169
+ inputTokens = data.message.usage?.input_tokens || 0;
170
+ }
171
+ // message_delta contains the output token count
172
+ if (data.type === "message_delta" && data.usage) {
173
+ outputTokens = data.usage.output_tokens || 0;
174
+ }
175
+ } catch {}
176
+ }
177
+
178
+ if (inputTokens > 0 || outputTokens > 0) {
179
+ const usage = { input_tokens: inputTokens, output_tokens: outputTokens };
180
+ return emitUsageEvent(usage, model, "anthropic", smUrl, smToken);
126
181
  }
127
182
  return null;
128
183
  }
@@ -145,12 +200,6 @@ export function startGateway(opts = {}) {
145
200
  const smToken = process.env.STACKMETER_TOKEN || smConfig.token;
146
201
  const onEmit = opts.onEmit;
147
202
 
148
- if (!openaiKey) {
149
- console.error(" Error: OPENAI_API_KEY is required.");
150
- console.error(" Set it in ~/.openclaw/openclaw.json (models.providers.openai.apiKey)");
151
- console.error(" or: export OPENAI_API_KEY=sk-...");
152
- process.exit(1);
153
- }
154
203
  if (!smUrl) {
155
204
  console.error(" Error: STACKMETER_URL is required.");
156
205
  console.error(" Run: npx @stackmeter/cli connect openclaw --auto");
@@ -165,6 +214,8 @@ export function startGateway(opts = {}) {
165
214
  }
166
215
 
167
216
  const server = createServer(async (req, res) => {
217
+ console.log(` [gw] ${req.method} ${req.url}`);
218
+
168
219
  // Health check
169
220
  if (req.method === "GET" && req.url === "/health") {
170
221
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -173,13 +224,14 @@ export function startGateway(opts = {}) {
173
224
 
174
225
  // Only proxy /v1/* paths
175
226
  if (!req.url?.startsWith("/v1/")) {
227
+ console.log(` [gw] 404 — not a /v1/ path`);
176
228
  res.writeHead(404, { "Content-Type": "application/json" });
177
229
  return res.end('{"error":"Not found"}');
178
230
  }
179
231
 
180
- const isTracked =
181
- req.method === "POST" &&
182
- (req.url === "/v1/chat/completions" || req.url === "/v1/responses");
232
+ const provider = detectProvider(req.method, req.url);
233
+ const isTracked = provider !== null;
234
+ console.log(` [gw] provider=${provider} tracked=${isTracked}`);
183
235
 
184
236
  // Read body for methods that have one
185
237
  let body = Buffer.alloc(0);
@@ -189,13 +241,13 @@ export function startGateway(opts = {}) {
189
241
  body = Buffer.concat(chunks);
190
242
  }
191
243
 
192
- // If tracked + streaming Chat Completions, inject stream_options.include_usage
244
+ // If tracked OpenAI streaming, inject stream_options.include_usage
193
245
  let isStreaming = false;
194
246
  if (isTracked && body.length > 0) {
195
247
  try {
196
248
  const parsed = JSON.parse(body.toString());
197
249
  isStreaming = parsed.stream === true;
198
- if (isStreaming && req.url === "/v1/chat/completions") {
250
+ if (isStreaming && provider === "openai" && req.url === "/v1/chat/completions") {
199
251
  parsed.stream_options = {
200
252
  ...(parsed.stream_options || {}),
201
253
  include_usage: true,
@@ -204,10 +256,40 @@ export function startGateway(opts = {}) {
204
256
  }
205
257
  } catch {}
206
258
  }
259
+ if (isTracked) console.log(` [gw] streaming=${isStreaming} bodyLen=${body.length}`);
260
+
261
+ // Determine upstream host and build auth headers
262
+ const upstreamHost = provider ? UPSTREAM[provider] : UPSTREAM.openai;
263
+ const targetUrl = new URL(req.url, `https://${upstreamHost}`);
264
+ const upHeaders = {};
265
+
266
+ if (provider === "anthropic") {
267
+ // Forward Anthropic auth headers from client
268
+ const clientApiKey = req.headers["x-api-key"] || process.env.ANTHROPIC_API_KEY;
269
+ if (!clientApiKey) {
270
+ res.writeHead(401, { "Content-Type": "application/json" });
271
+ return res.end('{"error":"Missing Anthropic API key (x-api-key header or ANTHROPIC_API_KEY env)"}');
272
+ }
273
+ upHeaders["x-api-key"] = clientApiKey;
274
+ // Forward anthropic-version header
275
+ if (req.headers["anthropic-version"]) {
276
+ upHeaders["anthropic-version"] = req.headers["anthropic-version"];
277
+ } else {
278
+ upHeaders["anthropic-version"] = "2023-06-01";
279
+ }
280
+ // Forward anthropic-beta if present
281
+ if (req.headers["anthropic-beta"]) {
282
+ upHeaders["anthropic-beta"] = req.headers["anthropic-beta"];
283
+ }
284
+ } else {
285
+ // OpenAI: use stored key
286
+ if (!openaiKey) {
287
+ res.writeHead(401, { "Content-Type": "application/json" });
288
+ return res.end('{"error":"Missing OpenAI API key (OPENAI_API_KEY env or ~/.openclaw/openclaw.json)"}');
289
+ }
290
+ upHeaders["Authorization"] = `Bearer ${openaiKey}`;
291
+ }
207
292
 
208
- // Build upstream request
209
- const targetUrl = new URL(req.url, OPENAI_BASE_URL);
210
- const upHeaders = { Authorization: `Bearer ${openaiKey}` };
211
293
  if (body.length > 0) {
212
294
  upHeaders["Content-Type"] =
213
295
  req.headers["content-type"] || "application/json";
@@ -242,7 +324,10 @@ export function startGateway(opts = {}) {
242
324
  });
243
325
  upRes.on("end", () => {
244
326
  res.end();
245
- const p = extractUsageFromSSE(buf, smUrl, smToken);
327
+ const extractFn = provider === "anthropic"
328
+ ? extractUsageFromAnthropicSSE
329
+ : extractUsageFromOpenAISSE;
330
+ const p = extractFn(buf, smUrl, smToken);
246
331
  if (p && onEmit) p.then(onEmit);
247
332
  });
248
333
  } else {
@@ -256,11 +341,19 @@ export function startGateway(opts = {}) {
256
341
  res.end();
257
342
  try {
258
343
  const data = JSON.parse(Buffer.concat(parts).toString());
259
- // Handle both Chat Completions and Responses API formats
260
- const usage = data.usage || data.response?.usage;
261
- const model = data.model || data.response?.model || "unknown";
344
+ let usage, model;
345
+
346
+ if (provider === "anthropic") {
347
+ usage = data.usage;
348
+ model = data.model || "unknown";
349
+ } else {
350
+ // OpenAI Chat Completions or Responses API
351
+ usage = data.usage || data.response?.usage;
352
+ model = data.model || data.response?.model || "unknown";
353
+ }
354
+
262
355
  if (usage) {
263
- const p = emitUsageEvent(usage, model, smUrl, smToken);
356
+ const p = emitUsageEvent(usage, model, provider, smUrl, smToken);
264
357
  if (onEmit) p.then(onEmit);
265
358
  }
266
359
  } catch {}
@@ -288,9 +381,9 @@ export function startGateway(opts = {}) {
288
381
  console.log("");
289
382
  console.log(" StackMeter Gateway");
290
383
  console.log(` Listening: http://127.0.0.1:${addr.port}`);
291
- console.log(` Proxying: ${OPENAI_BASE_URL}`);
384
+ console.log(` Proxying: OpenAI + Anthropic`);
292
385
  console.log(` Reporting: ${smUrl}`);
293
- console.log(` API key: ...${openaiKey.slice(-4)}`);
386
+ if (openaiKey) console.log(` OpenAI key: ...${openaiKey.slice(-4)}`);
294
387
  console.log("");
295
388
  resolve(server);
296
389
  });