@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.
- package/bin/stackmeter.mjs +65 -181
- package/package.json +1 -1
- package/src/gateway/log-watcher.mjs +303 -0
- package/src/gateway/server.mjs +124 -31
package/bin/stackmeter.mjs
CHANGED
|
@@ -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:
|
|
295
|
+
// ── Step 4: Verify OpenClaw is installed ─────────────
|
|
303
296
|
const { config: ocConfig } = readOpenClawConfig();
|
|
304
|
-
|
|
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
|
-
"
|
|
299
|
+
" \u26A0\uFE0F OpenClaw config not found at ~/.openclaw/openclaw.json"
|
|
313
300
|
);
|
|
314
|
-
console.log("
|
|
315
|
-
|
|
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
|
|
314
|
+
// ── Install log watcher as persistent service ────
|
|
366
315
|
if (isMac) {
|
|
367
|
-
// Copy
|
|
368
|
-
const
|
|
369
|
-
__dirname, "..", "src", "gateway", "
|
|
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(
|
|
372
|
-
console.log(` \u274C
|
|
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(
|
|
326
|
+
copyFileSync(watcherSource, SM_GATEWAY_PATH);
|
|
378
327
|
writeFileSync(
|
|
379
328
|
SM_LAUNCHER_PATH,
|
|
380
329
|
[
|
|
381
330
|
"#!/usr/bin/env node",
|
|
382
|
-
'import {
|
|
383
|
-
"
|
|
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
|
|
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
|
|
396
|
-
console.log(" Starting
|
|
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 {
|
|
404
|
-
|
|
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
|
-
"
|
|
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(
|
|
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("
|
|
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
|
-
"
|
|
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
|
|
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 {
|
|
500
|
-
|
|
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
|
|
452
|
+
console.log(" \u{1F9EA} StackMeter \u2014 Self-test");
|
|
541
453
|
console.log("");
|
|
542
454
|
|
|
543
|
-
|
|
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
|
|
458
|
+
const res = await fetch(smUrl, {
|
|
572
459
|
method: "POST",
|
|
573
|
-
headers: {
|
|
460
|
+
headers: {
|
|
461
|
+
Authorization: `Bearer ${smToken}`,
|
|
462
|
+
"Content-Type": "application/json",
|
|
463
|
+
},
|
|
574
464
|
body: JSON.stringify({
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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 (
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
596
|
-
server.close();
|
|
597
|
-
process.exit(1);
|
|
483
|
+
console.log(` \u274C Could not reach StackMeter: ${e.message}`);
|
|
598
484
|
}
|
|
599
485
|
|
|
600
|
-
//
|
|
601
|
-
console.log("
|
|
602
|
-
|
|
486
|
+
// 2/2 — Check 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 (
|
|
605
|
-
console.log(` \u2705
|
|
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(
|
|
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
|
@@ -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
|
+
}
|
package/src/gateway/server.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// StackMeter Gateway — OpenAI
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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:
|
|
384
|
+
console.log(` Proxying: OpenAI + Anthropic`);
|
|
292
385
|
console.log(` Reporting: ${smUrl}`);
|
|
293
|
-
console.log(`
|
|
386
|
+
if (openaiKey) console.log(` OpenAI key: ...${openaiKey.slice(-4)}`);
|
|
294
387
|
console.log("");
|
|
295
388
|
resolve(server);
|
|
296
389
|
});
|