@stackmeter/cli 0.1.1 → 0.1.3
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 +30 -12
- package/package.json +1 -1
- package/src/gateway/server.mjs +119 -31
package/bin/stackmeter.mjs
CHANGED
|
@@ -322,6 +322,8 @@ async function connectOpenClaw() {
|
|
|
322
322
|
10
|
|
323
323
|
);
|
|
324
324
|
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}/v1`;
|
|
325
|
+
// Anthropic baseUrl doesn't include /v1 (SDK adds it)
|
|
326
|
+
const gatewayBase = `http://127.0.0.1:${gatewayPort}`;
|
|
325
327
|
|
|
326
328
|
// ── Backup openclaw.json (first run only) ────────
|
|
327
329
|
if (ocConfig && !existsSync(OC_BACKUP_PATH)) {
|
|
@@ -331,17 +333,22 @@ async function connectOpenClaw() {
|
|
|
331
333
|
console.log("");
|
|
332
334
|
}
|
|
333
335
|
|
|
334
|
-
// ── Patch openclaw.json
|
|
336
|
+
// ── Patch openclaw.json (env vars for SDK routing) ─
|
|
335
337
|
if (ocConfig) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
ocConfig.
|
|
338
|
+
// Patch the env section with SDK-recognized base URL env vars.
|
|
339
|
+
// The OpenAI and Anthropic SDKs respect OPENAI_BASE_URL and
|
|
340
|
+
// ANTHROPIC_BASE_URL, which OpenClaw injects at runtime.
|
|
341
|
+
if (!ocConfig.env) ocConfig.env = {};
|
|
342
|
+
ocConfig.env.OPENAI_BASE_URL = gatewayBaseUrl;
|
|
343
|
+
ocConfig.env.ANTHROPIC_BASE_URL = gatewayBase;
|
|
344
|
+
|
|
341
345
|
writeFileSync(OC_CONFIG_PATH, JSON.stringify(ocConfig, null, 2) + "\n");
|
|
342
346
|
console.log(` \u2705 Patched ${OC_CONFIG_PATH}`);
|
|
343
347
|
console.log(
|
|
344
|
-
`
|
|
348
|
+
` env.OPENAI_BASE_URL = "${gatewayBaseUrl}"`
|
|
349
|
+
);
|
|
350
|
+
console.log(
|
|
351
|
+
` env.ANTHROPIC_BASE_URL = "${gatewayBase}"`
|
|
345
352
|
);
|
|
346
353
|
} else {
|
|
347
354
|
console.log(
|
|
@@ -420,7 +427,10 @@ async function connectOpenClaw() {
|
|
|
420
427
|
console.log("");
|
|
421
428
|
console.log(` 1. Edit ${OC_CONFIG_PATH}`);
|
|
422
429
|
console.log(
|
|
423
|
-
'
|
|
430
|
+
' Add to the "env" section: "OPENAI_BASE_URL": "http://127.0.0.1:8787/v1"'
|
|
431
|
+
);
|
|
432
|
+
console.log(
|
|
433
|
+
' "ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"'
|
|
424
434
|
);
|
|
425
435
|
console.log("");
|
|
426
436
|
console.log(" 2. Set env vars:");
|
|
@@ -467,12 +477,20 @@ async function disconnectOpenClaw() {
|
|
|
467
477
|
unlinkSync(OC_BACKUP_PATH);
|
|
468
478
|
console.log(` \u2705 Restored ${OC_CONFIG_PATH} from backup`);
|
|
469
479
|
} else {
|
|
470
|
-
// No backup — just remove the
|
|
480
|
+
// No backup — just remove the env vars we set
|
|
471
481
|
const { config: ocConfig } = readOpenClawConfig();
|
|
472
|
-
|
|
473
|
-
|
|
482
|
+
let changed = false;
|
|
483
|
+
if (ocConfig?.env?.OPENAI_BASE_URL?.includes("127.0.0.1")) {
|
|
484
|
+
delete ocConfig.env.OPENAI_BASE_URL;
|
|
485
|
+
changed = true;
|
|
486
|
+
}
|
|
487
|
+
if (ocConfig?.env?.ANTHROPIC_BASE_URL?.includes("127.0.0.1")) {
|
|
488
|
+
delete ocConfig.env.ANTHROPIC_BASE_URL;
|
|
489
|
+
changed = true;
|
|
490
|
+
}
|
|
491
|
+
if (changed) {
|
|
474
492
|
writeFileSync(OC_CONFIG_PATH, JSON.stringify(ocConfig, null, 2) + "\n");
|
|
475
|
-
console.log(` \u2705 Removed gateway
|
|
493
|
+
console.log(` \u2705 Removed gateway env vars from ${OC_CONFIG_PATH}`);
|
|
476
494
|
}
|
|
477
495
|
}
|
|
478
496
|
|
package/package.json
CHANGED
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");
|
|
@@ -177,9 +226,8 @@ export function startGateway(opts = {}) {
|
|
|
177
226
|
return res.end('{"error":"Not found"}');
|
|
178
227
|
}
|
|
179
228
|
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
(req.url === "/v1/chat/completions" || req.url === "/v1/responses");
|
|
229
|
+
const provider = detectProvider(req.method, req.url);
|
|
230
|
+
const isTracked = provider !== null;
|
|
183
231
|
|
|
184
232
|
// Read body for methods that have one
|
|
185
233
|
let body = Buffer.alloc(0);
|
|
@@ -189,13 +237,13 @@ export function startGateway(opts = {}) {
|
|
|
189
237
|
body = Buffer.concat(chunks);
|
|
190
238
|
}
|
|
191
239
|
|
|
192
|
-
// If tracked
|
|
240
|
+
// If tracked OpenAI streaming, inject stream_options.include_usage
|
|
193
241
|
let isStreaming = false;
|
|
194
242
|
if (isTracked && body.length > 0) {
|
|
195
243
|
try {
|
|
196
244
|
const parsed = JSON.parse(body.toString());
|
|
197
245
|
isStreaming = parsed.stream === true;
|
|
198
|
-
if (isStreaming && req.url === "/v1/chat/completions") {
|
|
246
|
+
if (isStreaming && provider === "openai" && req.url === "/v1/chat/completions") {
|
|
199
247
|
parsed.stream_options = {
|
|
200
248
|
...(parsed.stream_options || {}),
|
|
201
249
|
include_usage: true,
|
|
@@ -205,9 +253,38 @@ export function startGateway(opts = {}) {
|
|
|
205
253
|
} catch {}
|
|
206
254
|
}
|
|
207
255
|
|
|
208
|
-
//
|
|
209
|
-
const
|
|
210
|
-
const
|
|
256
|
+
// Determine upstream host and build auth headers
|
|
257
|
+
const upstreamHost = provider ? UPSTREAM[provider] : UPSTREAM.openai;
|
|
258
|
+
const targetUrl = new URL(req.url, `https://${upstreamHost}`);
|
|
259
|
+
const upHeaders = {};
|
|
260
|
+
|
|
261
|
+
if (provider === "anthropic") {
|
|
262
|
+
// Forward Anthropic auth headers from client
|
|
263
|
+
const clientApiKey = req.headers["x-api-key"] || process.env.ANTHROPIC_API_KEY;
|
|
264
|
+
if (!clientApiKey) {
|
|
265
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
266
|
+
return res.end('{"error":"Missing Anthropic API key (x-api-key header or ANTHROPIC_API_KEY env)"}');
|
|
267
|
+
}
|
|
268
|
+
upHeaders["x-api-key"] = clientApiKey;
|
|
269
|
+
// Forward anthropic-version header
|
|
270
|
+
if (req.headers["anthropic-version"]) {
|
|
271
|
+
upHeaders["anthropic-version"] = req.headers["anthropic-version"];
|
|
272
|
+
} else {
|
|
273
|
+
upHeaders["anthropic-version"] = "2023-06-01";
|
|
274
|
+
}
|
|
275
|
+
// Forward anthropic-beta if present
|
|
276
|
+
if (req.headers["anthropic-beta"]) {
|
|
277
|
+
upHeaders["anthropic-beta"] = req.headers["anthropic-beta"];
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
// OpenAI: use stored key
|
|
281
|
+
if (!openaiKey) {
|
|
282
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
283
|
+
return res.end('{"error":"Missing OpenAI API key (OPENAI_API_KEY env or ~/.openclaw/openclaw.json)"}');
|
|
284
|
+
}
|
|
285
|
+
upHeaders["Authorization"] = `Bearer ${openaiKey}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
211
288
|
if (body.length > 0) {
|
|
212
289
|
upHeaders["Content-Type"] =
|
|
213
290
|
req.headers["content-type"] || "application/json";
|
|
@@ -242,7 +319,10 @@ export function startGateway(opts = {}) {
|
|
|
242
319
|
});
|
|
243
320
|
upRes.on("end", () => {
|
|
244
321
|
res.end();
|
|
245
|
-
const
|
|
322
|
+
const extractFn = provider === "anthropic"
|
|
323
|
+
? extractUsageFromAnthropicSSE
|
|
324
|
+
: extractUsageFromOpenAISSE;
|
|
325
|
+
const p = extractFn(buf, smUrl, smToken);
|
|
246
326
|
if (p && onEmit) p.then(onEmit);
|
|
247
327
|
});
|
|
248
328
|
} else {
|
|
@@ -256,11 +336,19 @@ export function startGateway(opts = {}) {
|
|
|
256
336
|
res.end();
|
|
257
337
|
try {
|
|
258
338
|
const data = JSON.parse(Buffer.concat(parts).toString());
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
339
|
+
let usage, model;
|
|
340
|
+
|
|
341
|
+
if (provider === "anthropic") {
|
|
342
|
+
usage = data.usage;
|
|
343
|
+
model = data.model || "unknown";
|
|
344
|
+
} else {
|
|
345
|
+
// OpenAI Chat Completions or Responses API
|
|
346
|
+
usage = data.usage || data.response?.usage;
|
|
347
|
+
model = data.model || data.response?.model || "unknown";
|
|
348
|
+
}
|
|
349
|
+
|
|
262
350
|
if (usage) {
|
|
263
|
-
const p = emitUsageEvent(usage, model, smUrl, smToken);
|
|
351
|
+
const p = emitUsageEvent(usage, model, provider, smUrl, smToken);
|
|
264
352
|
if (onEmit) p.then(onEmit);
|
|
265
353
|
}
|
|
266
354
|
} catch {}
|
|
@@ -288,9 +376,9 @@ export function startGateway(opts = {}) {
|
|
|
288
376
|
console.log("");
|
|
289
377
|
console.log(" StackMeter Gateway");
|
|
290
378
|
console.log(` Listening: http://127.0.0.1:${addr.port}`);
|
|
291
|
-
console.log(` Proxying:
|
|
379
|
+
console.log(` Proxying: OpenAI + Anthropic`);
|
|
292
380
|
console.log(` Reporting: ${smUrl}`);
|
|
293
|
-
console.log(`
|
|
381
|
+
if (openaiKey) console.log(` OpenAI key: ...${openaiKey.slice(-4)}`);
|
|
294
382
|
console.log("");
|
|
295
383
|
resolve(server);
|
|
296
384
|
});
|