@elvatis_com/openclaw-cli-bridge-elvatis 2.6.3 → 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 +8 -2
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/config.ts +3 -0
- package/src/metrics.ts +80 -1
- package/src/proxy-server.ts +33 -20
- package/test/metrics.test.ts +110 -0
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.
|
|
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 (
|
|
401
|
+
npm test # vitest run (261 tests)
|
|
402
402
|
npm run ci # lint + typecheck + test
|
|
403
403
|
```
|
|
404
404
|
|
|
@@ -406,6 +406,12 @@ 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
|
+
|
|
409
415
|
### v2.6.3
|
|
410
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)
|
|
411
417
|
|
package/SKILL.md
CHANGED
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
package/src/proxy-server.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
+
});
|