@stackmeter/cli 0.1.2 → 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.
@@ -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
- 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;
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
- ` models.providers.openai.baseUrl = "${gatewayBaseUrl}"`
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
- ' Set models.providers.openai.baseUrl to "http://127.0.0.1:8787/v1"'
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 baseUrl we set
480
+ // No backup — just remove the env vars we set
471
481
  const { config: ocConfig } = readOpenClawConfig();
472
- if (ocConfig?.models?.providers?.openai?.baseUrl?.includes("127.0.0.1")) {
473
- delete ocConfig.models.providers.openai.baseUrl;
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 baseUrl from ${OC_CONFIG_PATH}`);
493
+ console.log(` \u2705 Removed gateway env vars from ${OC_CONFIG_PATH}`);
476
494
  }
477
495
  }
478
496
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmeter/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Track your SaaS AI costs from the terminal. One-command OpenClaw setup.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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");
@@ -177,9 +226,8 @@ export function startGateway(opts = {}) {
177
226
  return res.end('{"error":"Not found"}');
178
227
  }
179
228
 
180
- const isTracked =
181
- req.method === "POST" &&
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 + streaming Chat Completions, inject stream_options.include_usage
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
- // Build upstream request
209
- const targetUrl = new URL(req.url, OPENAI_BASE_URL);
210
- const upHeaders = { Authorization: `Bearer ${openaiKey}` };
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 p = extractUsageFromSSE(buf, smUrl, smToken);
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
- // 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";
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: ${OPENAI_BASE_URL}`);
379
+ console.log(` Proxying: OpenAI + Anthropic`);
292
380
  console.log(` Reporting: ${smUrl}`);
293
- console.log(` API key: ...${openaiKey.slice(-4)}`);
381
+ if (openaiKey) console.log(` OpenAI key: ...${openaiKey.slice(-4)}`);
294
382
  console.log("");
295
383
  resolve(server);
296
384
  });