@elvatis_com/openclaw-cli-bridge-elvatis 2.6.2 → 2.7.0

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code, OpenCode, Pi) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `2.6.2`
5
+ **Current version:** `2.7.0`
6
6
 
7
7
  ---
8
8
 
@@ -398,7 +398,7 @@ Model fallback (v1.9.0):
398
398
  ```bash
399
399
  npm run lint # eslint (TypeScript-aware)
400
400
  npm run typecheck # tsc --noEmit
401
- npm test # vitest run (252 tests)
401
+ npm test # vitest run (261 tests)
402
402
  npm run ci # lint + typecheck + test
403
403
  ```
404
404
 
@@ -406,6 +406,15 @@ npm run ci # lint + typecheck + test
406
406
 
407
407
  ## Changelog
408
408
 
409
+ ### v2.7.0
410
+ - **feat:** Persistent per-model metrics — request counts, error rates, latency, and token usage now survive gateway restarts. Stored in `~/.openclaw/cli-bridge/metrics.json`, debounced writes (5s).
411
+ - **feat:** Token usage estimation for all models — CLI runners (claude, gemini, codex), web-session models (gemini, claude, chatgpt) now report estimated `prompt_tokens` and `completion_tokens` in the OpenAI-compatible `usage` response field (~4 chars/token heuristic). Grok models continue to use real token counts from the API.
412
+ - **feat:** Dashboard and `/healthz` now show actual token stats per model instead of zeros
413
+ - **test:** 9 new metrics tests — estimateTokens, MetricsCollector recording, sorting, reset (261 total)
414
+
415
+ ### v2.6.3
416
+ - **security:** Bump `vite` 8.0.2 → 8.0.5 — fixes 3 CVEs: `server.fs.deny` bypass via query strings, arbitrary file read via WebSocket, path traversal in optimized deps `.map` handling (merged Dependabot PR #18)
417
+
409
418
  ### v2.6.2
410
419
  - **fix:** Codex CLI `--quiet` flag removed in latest Codex version — replaced with `codex exec` subcommand for non-interactive execution. All `openai-codex/*` models were failing with "unexpected argument '--quiet'" error.
411
420
  - **fix:** Agent model routing — 10 agents referenced non-existent `google-gemini-cli` provider. Remapped to `vllm/cli-gemini/*` (OAuth-based, stable) for reliable Gemini access.
package/SKILL.md CHANGED
@@ -68,4 +68,4 @@ On gateway restart, if any session has expired, a **WhatsApp alert** is sent aut
68
68
 
69
69
  See `README.md` for full configuration reference and architecture diagram.
70
70
 
71
- **Version:** 2.6.2
71
+ **Version:** 2.7.0
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "slug": "openclaw-cli-bridge-elvatis",
4
4
  "name": "OpenClaw CLI Bridge",
5
- "version": "2.6.2",
5
+ "version": "2.7.0",
6
6
  "license": "MIT",
7
7
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
8
8
  "providers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "2.6.2",
3
+ "version": "2.7.0",
4
4
  "description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
5
5
  "type": "module",
6
6
  "openclaw": {
package/src/config.ts CHANGED
@@ -134,6 +134,9 @@ export const PENDING_FILE = join(OPENCLAW_DIR, "cli-bridge-pending.json");
134
134
  /** Provider session registry file. */
135
135
  export const PROVIDER_SESSIONS_FILE = join(OPENCLAW_DIR, "cli-bridge", "sessions.json");
136
136
 
137
+ /** Persistent metrics file — survives gateway restarts. */
138
+ export const METRICS_FILE = join(OPENCLAW_DIR, "cli-bridge", "metrics.json");
139
+
137
140
  /** Temporary directory for multimodal media files. */
138
141
  export const MEDIA_TMP_DIR = join(tmpdir(), "cli-bridge-media");
139
142
 
package/src/metrics.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * metrics.ts
3
3
  *
4
- * In-memory metrics collector for the CLI bridge proxy.
4
+ * Persistent metrics collector for the CLI bridge proxy.
5
5
  * Tracks request counts, errors, latency, and token usage per model.
6
6
  * All operations are O(1) — cannot block the event loop.
7
+ *
8
+ * Metrics are persisted to disk on every recordRequest() call (debounced)
9
+ * and restored on startup so stats survive gateway restarts.
7
10
  */
8
11
 
12
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
13
+ import { dirname } from "node:path";
14
+ import { METRICS_FILE } from "./config.js";
15
+
9
16
  export interface ModelMetrics {
10
17
  model: string;
11
18
  requests: number;
@@ -23,9 +30,37 @@ export interface MetricsSnapshot {
23
30
  models: ModelMetrics[]; // sorted by requests desc
24
31
  }
25
32
 
33
+ // ── Token estimation ────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Rough token count estimate: ~4 characters per token.
37
+ * This matches the commonly used GPT tokenizer heuristic and is
38
+ * accurate within ~15% for English text / code.
39
+ */
40
+ export function estimateTokens(text: string): number {
41
+ if (!text) return 0;
42
+ return Math.ceil(text.length / 4);
43
+ }
44
+
45
+ // ── Persistence format ──────────────────────────────────────────────────────
46
+
47
+ interface PersistedMetrics {
48
+ version: 1;
49
+ startedAt: number;
50
+ models: ModelMetrics[];
51
+ }
52
+
53
+ // ── Collector ───────────────────────────────────────────────────────────────
54
+
26
55
  class MetricsCollector {
27
56
  private startedAt = Date.now();
28
57
  private data = new Map<string, ModelMetrics>();
58
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
59
+ private dirty = false;
60
+
61
+ constructor() {
62
+ this.load();
63
+ }
29
64
 
30
65
  recordRequest(
31
66
  model: string,
@@ -53,6 +88,7 @@ class MetricsCollector {
53
88
  if (promptTokens) entry.promptTokens += promptTokens;
54
89
  if (completionTokens) entry.completionTokens += completionTokens;
55
90
  entry.lastRequestAt = Date.now();
91
+ this.scheduleSave();
56
92
  }
57
93
 
58
94
  getMetrics(): MetricsSnapshot {
@@ -79,6 +115,49 @@ class MetricsCollector {
79
115
  reset(): void {
80
116
  this.startedAt = Date.now();
81
117
  this.data.clear();
118
+ this.saveNow();
119
+ }
120
+
121
+ // ── Persistence ─────────────────────────────────────────────────────────
122
+
123
+ private load(): void {
124
+ try {
125
+ const raw = readFileSync(METRICS_FILE, "utf-8");
126
+ const persisted = JSON.parse(raw) as PersistedMetrics;
127
+ if (persisted.version === 1 && Array.isArray(persisted.models)) {
128
+ this.startedAt = persisted.startedAt;
129
+ for (const m of persisted.models) {
130
+ this.data.set(m.model, { ...m });
131
+ }
132
+ }
133
+ } catch {
134
+ // File doesn't exist or is corrupt — start fresh
135
+ }
136
+ }
137
+
138
+ private scheduleSave(): void {
139
+ this.dirty = true;
140
+ if (this.flushTimer) return;
141
+ // Debounce: save at most once per 5 seconds
142
+ this.flushTimer = setTimeout(() => {
143
+ this.flushTimer = null;
144
+ if (this.dirty) this.saveNow();
145
+ }, 5_000);
146
+ }
147
+
148
+ saveNow(): void {
149
+ this.dirty = false;
150
+ const persisted: PersistedMetrics = {
151
+ version: 1,
152
+ startedAt: this.startedAt,
153
+ models: Array.from(this.data.values()),
154
+ };
155
+ try {
156
+ mkdirSync(dirname(METRICS_FILE), { recursive: true });
157
+ writeFileSync(METRICS_FILE, JSON.stringify(persisted, null, 2) + "\n", "utf-8");
158
+ } catch {
159
+ // Best effort — don't crash the proxy for metrics I/O
160
+ }
82
161
  }
83
162
  }
84
163
 
@@ -19,7 +19,7 @@ import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrow
19
19
  import type { BrowserContext } from "playwright";
20
20
  import { renderStatusPage, type StatusProvider } from "./status-template.js";
21
21
  import { sessionManager } from "./session-manager.js";
22
- import { metrics } from "./metrics.js";
22
+ import { metrics, estimateTokens } from "./metrics.js";
23
23
  import { providerSessions } from "./provider-sessions.js";
24
24
  import {
25
25
  DEFAULT_PROXY_TIMEOUT_MS,
@@ -337,6 +337,10 @@ async function handleRequest(
337
337
  // Extract multimodal content (images, audio) from messages → temp files
338
338
  const { cleanMessages, mediaFiles } = extractMultimodalParts(messages);
339
339
 
340
+ // Estimate prompt tokens from message content (used when CLIs don't report usage)
341
+ const promptText = cleanMessages.map(m => typeof m.content === "string" ? m.content : "").join(" ");
342
+ const estPromptTokens = estimateTokens(promptText);
343
+
340
344
  opts.log(`[cli-bridge] ${model} · ${cleanMessages.length} msg(s) · stream=${stream}${hasTools ? ` · tools=${tools!.length}` : ""}${mediaFiles.length ? ` · media=${mediaFiles.length}` : ""}`);
341
345
 
342
346
  const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
@@ -385,7 +389,7 @@ async function handleRequest(
385
389
  }));
386
390
  }
387
391
  } catch (err) {
388
- metrics.recordRequest(model, Date.now() - grokStart, false);
392
+ metrics.recordRequest(model, Date.now() - grokStart, false, estPromptTokens);
389
393
  const msg = (err as Error).message;
390
394
  opts.warn(`[cli-bridge] Grok error for ${model}: ${msg}`);
391
395
  if (!res.headersSent) {
@@ -423,22 +427,23 @@ async function handleRequest(
423
427
  (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
424
428
  opts.log
425
429
  );
426
- metrics.recordRequest(model, Date.now() - geminiStart, true);
430
+ metrics.recordRequest(model, Date.now() - geminiStart, true, estPromptTokens, estimateTokens(result.content));
427
431
  sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
428
432
  res.write("data: [DONE]\n\n");
429
433
  res.end();
430
434
  } else {
431
435
  const result = await doGeminiComplete(geminiCtx, { messages: geminiMessages, model, timeoutMs }, opts.log);
432
- metrics.recordRequest(model, Date.now() - geminiStart, true);
436
+ const estComp = estimateTokens(result.content);
437
+ metrics.recordRequest(model, Date.now() - geminiStart, true, estPromptTokens, estComp);
433
438
  res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
434
439
  res.end(JSON.stringify({
435
440
  id, object: "chat.completion", created, model,
436
441
  choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
437
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
442
+ usage: { prompt_tokens: estPromptTokens, completion_tokens: estComp, total_tokens: estPromptTokens + estComp },
438
443
  }));
439
444
  }
440
445
  } catch (err) {
441
- metrics.recordRequest(model, Date.now() - geminiStart, false);
446
+ metrics.recordRequest(model, Date.now() - geminiStart, false, estPromptTokens);
442
447
  const msg = (err as Error).message;
443
448
  opts.warn(`[cli-bridge] Gemini browser error for ${model}: ${msg}`);
444
449
  if (!res.headersSent) {
@@ -476,22 +481,23 @@ async function handleRequest(
476
481
  (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
477
482
  opts.log
478
483
  );
479
- metrics.recordRequest(model, Date.now() - claudeStart, true);
484
+ metrics.recordRequest(model, Date.now() - claudeStart, true, estPromptTokens, estimateTokens(result.content));
480
485
  sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
481
486
  res.write("data: [DONE]\n\n");
482
487
  res.end();
483
488
  } else {
484
489
  const result = await doClaudeComplete(claudeCtx, { messages: claudeMessages, model, timeoutMs }, opts.log);
485
- metrics.recordRequest(model, Date.now() - claudeStart, true);
490
+ const estComp = estimateTokens(result.content);
491
+ metrics.recordRequest(model, Date.now() - claudeStart, true, estPromptTokens, estComp);
486
492
  res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
487
493
  res.end(JSON.stringify({
488
494
  id, object: "chat.completion", created, model,
489
495
  choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
490
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
496
+ usage: { prompt_tokens: estPromptTokens, completion_tokens: estComp, total_tokens: estPromptTokens + estComp },
491
497
  }));
492
498
  }
493
499
  } catch (err) {
494
- metrics.recordRequest(model, Date.now() - claudeStart, false);
500
+ metrics.recordRequest(model, Date.now() - claudeStart, false, estPromptTokens);
495
501
  const msg = (err as Error).message;
496
502
  opts.warn(`[cli-bridge] Claude browser error for ${model}: ${msg}`);
497
503
  if (!res.headersSent) {
@@ -530,22 +536,23 @@ async function handleRequest(
530
536
  (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
531
537
  opts.log
532
538
  );
533
- metrics.recordRequest(model, Date.now() - chatgptStart, true);
539
+ metrics.recordRequest(model, Date.now() - chatgptStart, true, estPromptTokens, estimateTokens(result.content));
534
540
  sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
535
541
  res.write("data: [DONE]\n\n");
536
542
  res.end();
537
543
  } else {
538
544
  const result = await doChatGPTComplete(chatgptCtx, { messages: chatgptMessages, model: chatgptModel, timeoutMs }, opts.log);
539
- metrics.recordRequest(model, Date.now() - chatgptStart, true);
545
+ const estComp = estimateTokens(result.content);
546
+ metrics.recordRequest(model, Date.now() - chatgptStart, true, estPromptTokens, estComp);
540
547
  res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
541
548
  res.end(JSON.stringify({
542
549
  id, object: "chat.completion", created, model,
543
550
  choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
544
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
551
+ usage: { prompt_tokens: estPromptTokens, completion_tokens: estComp, total_tokens: estPromptTokens + estComp },
545
552
  }));
546
553
  }
547
554
  } catch (err) {
548
- metrics.recordRequest(model, Date.now() - chatgptStart, false);
555
+ metrics.recordRequest(model, Date.now() - chatgptStart, false, estPromptTokens);
549
556
  const msg = (err as Error).message;
550
557
  opts.warn(`[cli-bridge] ChatGPT browser error for ${model}: ${msg}`);
551
558
  if (!res.headersSent) {
@@ -683,7 +690,8 @@ async function handleRequest(
683
690
  const cliStart = Date.now();
684
691
  try {
685
692
  result = await routeToCliRunner(model, cleanMessages, effectiveTimeout, routeOpts);
686
- metrics.recordRequest(model, Date.now() - cliStart, true);
693
+ const estCompletionTokens = estimateTokens(result.content ?? "");
694
+ metrics.recordRequest(model, Date.now() - cliStart, true, estPromptTokens, estCompletionTokens);
687
695
  providerSessions.recordRun(session.id, false);
688
696
  } catch (err) {
689
697
  const primaryDuration = Date.now() - cliStart;
@@ -694,17 +702,18 @@ async function handleRequest(
694
702
  providerSessions.recordRun(session.id, isTimeout);
695
703
  const fallbackModel = opts.modelFallbacks?.[model];
696
704
  if (fallbackModel) {
697
- metrics.recordRequest(model, primaryDuration, false);
705
+ metrics.recordRequest(model, primaryDuration, false, estPromptTokens);
698
706
  const reason = isTimeout ? `timeout by supervisor, session=${session.id} preserved` : msg;
699
707
  opts.warn(`[cli-bridge] ${model} failed (${reason}), falling back to ${fallbackModel}`);
700
708
  const fallbackStart = Date.now();
701
709
  try {
702
710
  result = await routeToCliRunner(fallbackModel, cleanMessages, effectiveTimeout, routeOpts);
703
- metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true);
711
+ const fbCompTokens = estimateTokens(result.content ?? "");
712
+ metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens);
704
713
  usedModel = fallbackModel;
705
714
  opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded`);
706
715
  } catch (fallbackErr) {
707
- metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false);
716
+ metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens);
708
717
  const fallbackMsg = (fallbackErr as Error).message;
709
718
  opts.warn(`[cli-bridge] fallback ${fallbackModel} also failed: ${fallbackMsg}`);
710
719
  if (sseHeadersSent) {
@@ -718,7 +727,7 @@ async function handleRequest(
718
727
  return;
719
728
  }
720
729
  } else {
721
- metrics.recordRequest(model, primaryDuration, false);
730
+ metrics.recordRequest(model, primaryDuration, false, estPromptTokens);
722
731
  opts.warn(`[cli-bridge] CLI error for ${model}: ${msg}`);
723
732
  if (sseHeadersSent) {
724
733
  res.write(`data: ${JSON.stringify({ error: { message: msg, type: "cli_error" } })}\n\n`);
@@ -806,7 +815,11 @@ async function handleRequest(
806
815
  finish_reason: finishReason,
807
816
  },
808
817
  ],
809
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
818
+ usage: {
819
+ prompt_tokens: estPromptTokens,
820
+ completion_tokens: estimateTokens(typeof message.content === "string" ? message.content : ""),
821
+ total_tokens: estPromptTokens + estimateTokens(typeof message.content === "string" ? message.content : ""),
822
+ },
810
823
  // Propagate session ID so callers can resume in the same session
811
824
  provider_session_id: session.id,
812
825
  };
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { readFileSync, unlinkSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ // We test estimateTokens directly and the MetricsCollector via the singleton
7
+ // after resetting. For persistence tests we mock METRICS_FILE.
8
+
9
+ describe("estimateTokens", () => {
10
+ it("returns 0 for empty string", async () => {
11
+ const { estimateTokens } = await import("../src/metrics.js");
12
+ expect(estimateTokens("")).toBe(0);
13
+ });
14
+
15
+ it("returns 0 for undefined/null-ish input", async () => {
16
+ const { estimateTokens } = await import("../src/metrics.js");
17
+ expect(estimateTokens(undefined as unknown as string)).toBe(0);
18
+ expect(estimateTokens(null as unknown as string)).toBe(0);
19
+ });
20
+
21
+ it("estimates ~1 token per 4 characters", async () => {
22
+ const { estimateTokens } = await import("../src/metrics.js");
23
+ // 100 chars → ceil(100/4) = 25 tokens
24
+ const text = "a".repeat(100);
25
+ expect(estimateTokens(text)).toBe(25);
26
+ });
27
+
28
+ it("rounds up partial tokens", async () => {
29
+ const { estimateTokens } = await import("../src/metrics.js");
30
+ // 5 chars → ceil(5/4) = 2
31
+ expect(estimateTokens("hello")).toBe(2);
32
+ });
33
+
34
+ it("handles realistic prompt sizes", async () => {
35
+ const { estimateTokens } = await import("../src/metrics.js");
36
+ // ~400 chars of English text → ~100 tokens
37
+ const text = "The quick brown fox jumps over the lazy dog. ".repeat(9); // 405 chars
38
+ const tokens = estimateTokens(text);
39
+ expect(tokens).toBeGreaterThan(90);
40
+ expect(tokens).toBeLessThan(110);
41
+ });
42
+ });
43
+
44
+ describe("MetricsCollector", () => {
45
+ it("records requests and tracks per-model stats", async () => {
46
+ const { metrics } = await import("../src/metrics.js");
47
+ metrics.reset();
48
+
49
+ metrics.recordRequest("test/model-a", 100, true, 50, 25);
50
+ metrics.recordRequest("test/model-a", 200, true, 60, 30);
51
+ metrics.recordRequest("test/model-b", 150, false, 40, 0);
52
+
53
+ const snap = metrics.getMetrics();
54
+ expect(snap.totalRequests).toBe(3);
55
+ expect(snap.totalErrors).toBe(1);
56
+
57
+ const modelA = snap.models.find(m => m.model === "test/model-a");
58
+ expect(modelA).toBeDefined();
59
+ expect(modelA!.requests).toBe(2);
60
+ expect(modelA!.errors).toBe(0);
61
+ expect(modelA!.promptTokens).toBe(110);
62
+ expect(modelA!.completionTokens).toBe(55);
63
+ expect(modelA!.totalLatencyMs).toBe(300);
64
+
65
+ const modelB = snap.models.find(m => m.model === "test/model-b");
66
+ expect(modelB).toBeDefined();
67
+ expect(modelB!.requests).toBe(1);
68
+ expect(modelB!.errors).toBe(1);
69
+ expect(modelB!.promptTokens).toBe(40);
70
+ });
71
+
72
+ it("sorts models by request count descending", async () => {
73
+ const { metrics } = await import("../src/metrics.js");
74
+ metrics.reset();
75
+
76
+ metrics.recordRequest("low", 10, true);
77
+ metrics.recordRequest("high", 10, true);
78
+ metrics.recordRequest("high", 10, true);
79
+ metrics.recordRequest("high", 10, true);
80
+ metrics.recordRequest("mid", 10, true);
81
+ metrics.recordRequest("mid", 10, true);
82
+
83
+ const snap = metrics.getMetrics();
84
+ expect(snap.models[0].model).toBe("high");
85
+ expect(snap.models[1].model).toBe("mid");
86
+ expect(snap.models[2].model).toBe("low");
87
+ });
88
+
89
+ it("reset clears all data", async () => {
90
+ const { metrics } = await import("../src/metrics.js");
91
+ metrics.recordRequest("test/x", 10, true, 5, 5);
92
+ metrics.reset();
93
+
94
+ const snap = metrics.getMetrics();
95
+ expect(snap.totalRequests).toBe(0);
96
+ expect(snap.models).toHaveLength(0);
97
+ });
98
+
99
+ it("handles missing token counts gracefully", async () => {
100
+ const { metrics } = await import("../src/metrics.js");
101
+ metrics.reset();
102
+
103
+ // No token args — should not crash, tokens stay 0
104
+ metrics.recordRequest("test/no-tokens", 50, true);
105
+ const snap = metrics.getMetrics();
106
+ const m = snap.models[0];
107
+ expect(m.promptTokens).toBe(0);
108
+ expect(m.completionTokens).toBe(0);
109
+ });
110
+ });