myapikey 0.4.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myapikey",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Personal LLM API gateway & proxy — one address + one API key for all your models. Forwards OpenAI & Anthropic calls to your backends with failover and a circuit breaker. Pure passthrough, no format translation. Self-hosted (CLI + web UI).",
6
6
  "keywords": [
@@ -69,6 +69,7 @@
69
69
  "@hono/node-server": "^1.13.5",
70
70
  "@hono/zod-validator": "^0.4.2",
71
71
  "commander": "^12.1.0",
72
+ "gpt-tokenizer": "^3.4.0",
72
73
  "hono": "^4.6.12",
73
74
  "tsx": "^4.19.0",
74
75
  "zod": "^3.23.8"
@@ -1,7 +1,8 @@
1
1
  import { Hono, type Context, type MiddlewareHandler } from "hono";
2
2
  import { trimBase } from "../shared/config";
3
- import type { Format, Provider, RouteKey } from "../shared/types";
3
+ import type { Format, Provider, RouteKey, Usage } from "../shared/types";
4
4
  import type { Store } from "./store";
5
+ import { UsageCollector } from "./tokens";
5
6
 
6
7
  /** HTTP statuses that should trigger failover to the next provider. */
7
8
  const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
@@ -29,6 +30,30 @@ function parseRetryAfter(v: string | null | undefined): number | undefined {
29
30
  return undefined;
30
31
  }
31
32
 
33
+ /** Parse a quota-reset DATETIME out of an upstream error body, for backends that
34
+ * put it in the message instead of a Retry-After header. Volcengine Ark's 1308
35
+ * ("已达到 5 小时的使用上限。您的限额将在 2026-08-11 18:33:11 重置。") is the case
36
+ * that bit us: no Retry-After, so the cooldown fell back to the escalating guess
37
+ * and re-hit the limit every 30/60/120…s. Returns ms-until-reset so the caller
38
+ * can cool for the real remaining window.
39
+ *
40
+ * Bare datetimes in these Chinese-vendor bodies are Beijing time (UTC+8); force
41
+ * that zone so the cooldown is right no matter what TZ the gateway itself runs
42
+ * in (Date.parse on a zone-less space-separated string would otherwise read it
43
+ * as the gateway's LOCAL time). An explicit zone (Z / ±HH:MM) is honored as-is.
44
+ * Returns undefined for no match / unparseable / already-in-the-past so the
45
+ * caller falls back to the escalating backoff. */
46
+ function parseResetFromBody(text: string): number | undefined {
47
+ if (!text) return undefined;
48
+ const m = text.match(/(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2})?)(Z|[+-]\d{2}:?\d{2})?/);
49
+ if (!m) return undefined;
50
+ const zone = m[3] ?? "+08:00";
51
+ const t = Date.parse(`${m[1]}T${m[2]}${zone}`);
52
+ if (!Number.isFinite(t)) return undefined;
53
+ const ms = t - Date.now();
54
+ return ms > 0 ? ms : undefined;
55
+ }
56
+
32
57
  /** Resolve the ordered, compatible provider list for a model on a routing slot. */
33
58
  function candidates(store: Store, model: string, key: RouteKey): Provider[] {
34
59
  const d = store.get();
@@ -125,6 +150,9 @@ interface SettleInfo {
125
150
  ok: boolean;
126
151
  status: number;
127
152
  error?: string;
153
+ /** Token usage captured from the body as it flowed (success rows only).
154
+ * Undefined for failed/truncated streams and for a body with no usage. */
155
+ usage?: Usage;
128
156
  }
129
157
 
130
158
  /** Wrap an upstream body so every byte is forwarded to the client VERBATIM while
@@ -176,7 +204,14 @@ function errorFrame(key: RouteKey, reason: string): string {
176
204
 
177
205
  function observedBody(
178
206
  upstream: Response,
179
- opts: { stream: boolean; key: RouteKey; onSettle: (info: SettleInfo) => void },
207
+ opts: {
208
+ stream: boolean;
209
+ key: RouteKey;
210
+ /** The original request's `messages`, used only to estimate prompt tokens
211
+ * on the openai-chat-stream fallback path (see tokens.ts). */
212
+ requestMessages?: unknown;
213
+ onSettle: (info: SettleInfo) => void;
214
+ },
180
215
  ): ReadableStream<Uint8Array> {
181
216
  const reader = upstream.body?.getReader();
182
217
  const enc = new TextEncoder();
@@ -185,6 +220,7 @@ function observedBody(
185
220
  let tail = ""; // rolling window so a marker split across chunks is still caught
186
221
  let terminal = false;
187
222
  let settled = false;
223
+ const usage = new UsageCollector();
188
224
 
189
225
  const settle = (info: SettleInfo) => {
190
226
  if (settled) return;
@@ -210,13 +246,14 @@ function observedBody(
210
246
  injectError(controller, reason);
211
247
  settle({ ok: false, status: 502, error: reason });
212
248
  } else {
213
- settle({ ok: true, status: 200 });
249
+ settle({ ok: true, status: 200, usage: usage.finalize({ stream: opts.stream, key: opts.key, requestMessages: opts.requestMessages }) });
214
250
  }
215
251
  controller.close();
216
252
  return;
217
253
  }
254
+ const txt = dec.decode(value, { stream: true });
255
+ usage.feed(txt, { stream: opts.stream, key: opts.key });
218
256
  if (!terminal && markers.length) {
219
- const txt = dec.decode(value, { stream: true });
220
257
  const win = tail + txt;
221
258
  if (markers.some((m) => win.includes(m))) terminal = true;
222
259
  tail = win.slice(-128);
@@ -339,13 +376,14 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
339
376
  // the body's end (so the row reflects the real outcome, not just the
340
377
  // headers). See observedBody() for the detection rules.
341
378
  const ttfb = Date.now() - start;
342
- const body = observedBody(upstream, {
379
+ const out = observedBody(upstream, {
343
380
  stream,
344
381
  key,
382
+ requestMessages: body.messages,
345
383
  onSettle: (info) => {
346
384
  if (info.ok) {
347
385
  store.recordCircuitSuccess(provider.id);
348
- store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: 200, ms: ttfb, stream });
386
+ store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: 200, ms: ttfb, stream, usage: info.usage });
349
387
  } else {
350
388
  // A pinned per-source probe takes no circuit side-effects (a manual
351
389
  // test must not trip the breaker) — mirrors the retryable branch.
@@ -354,7 +392,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
354
392
  }
355
393
  },
356
394
  });
357
- return new Response(body, { status: upstream.status, headers: downHeaders(upstream, isProbe ? provider.name : undefined) });
395
+ return new Response(out, { status: upstream.status, headers: downHeaders(upstream, isProbe ? provider.name : undefined) });
358
396
  }
359
397
  if (RETRYABLE.has(upstream.status)) {
360
398
  lastStatus = upstream.status;
@@ -365,9 +403,12 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
365
403
  if (pinId) break; // per-source probe: fail fast, no circuit impact.
366
404
  // A 429/overloaded upstream usually carries Retry-After; honoring it
367
405
  // cools for exactly as long as asked (clamped) instead of the escalating
368
- // guess. Absent (5xx often, or a proxy that stripped it) escalate.
406
+ // guess. Absent (5xx often, OR a quota error that buried the reset time
407
+ // in the BODY — e.g. Volcengine Ark's 1308 "您的限额将在 <datetime> 重置")
408
+ // → parse that deadline out of the body, else fall back to escalating.
369
409
  const retryAfterMs = parseRetryAfter(upstream.headers.get("retry-after"));
370
- const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs);
410
+ const resetInMs = retryAfterMs ? undefined : parseResetFromBody(txt);
411
+ const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs ?? resetInMs, !!resetInMs);
371
412
  if (r.entered) {
372
413
  store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, kind: "cooldown", cooldownMs: r.cooldownMs, fails: r.fails, error: lastErr });
373
414
  }
@@ -27,6 +27,13 @@ const CB_CAP = 300_000;
27
27
  // lenient "Retry-After: 0"/sub-second) shouldn't read as "no cooldown" and let
28
28
  // us re-hammer a just-rate-limited source in a tight loop.
29
29
  const CB_MIN = 1_000;
30
+ // Ceiling for a reset DEADLINE parsed out of an error body (e.g. Volcengine Ark's
31
+ // 1308 "您的限额将在 <datetime> 重置" — a quota window, not a backoff guess).
32
+ // Larger than CB_CAP because a quota reset is a real future event the source
33
+ // explicitly told us about: while cooling the source is SKIPPED, so honoring the
34
+ // true reset avoids re-probing a source we KNOW is rate-limited. Sanity-bound so a
35
+ // malformed body can't take a source offline for more than a work day.
36
+ const RESET_CAP_MS = 6 * 60 * 60 * 1000;
30
37
 
31
38
  /** RPM pacing window: a source's `rpm` cap counts calls within this trailing
32
39
  * window. 60s matches the usual "requests per minute" limit. */
@@ -68,6 +75,9 @@ export interface StatBucket {
68
75
  success: number;
69
76
  error: number;
70
77
  avgMs: number;
78
+ /** Token totals for this bucket (success rows only). */
79
+ inputTokens: number;
80
+ outputTokens: number;
71
81
  }
72
82
 
73
83
  /** One day in the stats time series. */
@@ -91,6 +101,15 @@ export interface StatsResult {
91
101
  avgMs: number;
92
102
  p50Ms: number;
93
103
  p95Ms: number;
104
+ /** Token totals across the window (success rows only). cacheRead/
105
+ * cacheCreation are prompt-cache hits (Anthropic) — the cached input
106
+ * tokens, counted separately from `inputTokens`. A small fraction of
107
+ * rows (OpenAI chat streams where the upstream omitted usage) contribute
108
+ * local tokenizer ESTIMATES rather than billed counts. */
109
+ inputTokens: number;
110
+ outputTokens: number;
111
+ cacheRead: number;
112
+ cacheCreation: number;
94
113
  };
95
114
  byModel: StatBucket[];
96
115
  byProvider: StatBucket[];
@@ -320,7 +339,7 @@ export class Store {
320
339
  const empty: StatsResult = {
321
340
  from,
322
341
  to,
323
- totals: { calls: 0, success: 0, error: 0, errorRate: 0, avgMs: 0, p50Ms: 0, p95Ms: 0 },
342
+ totals: { calls: 0, success: 0, error: 0, errorRate: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0 },
324
343
  byModel: [],
325
344
  byProvider: [],
326
345
  byFormat: [],
@@ -351,12 +370,12 @@ export class Store {
351
370
  const ok = e.status >= 200 && e.status < 300;
352
371
  const err = e.status >= 400;
353
372
  const ms = e.ms || 0;
354
- bump(tot, ok, err, ms);
373
+ bump(tot, ok, err, e);
355
374
  latencies.push(ms);
356
- bump(acc(model, e.model), ok, err, ms);
357
- bump(acc(provider, e.providerId ?? e.provider ?? "?"), ok, err, ms);
358
- bump(acc(format, e.format ?? "?"), ok, err, ms);
359
- bump(acc(day, dayKey(e.ts)), ok, err, ms);
375
+ bump(acc(model, e.model), ok, err, e);
376
+ bump(acc(provider, e.providerId ?? e.provider ?? "?"), ok, err, e);
377
+ bump(acc(format, e.format ?? "?"), ok, err, e);
378
+ bump(acc(day, dayKey(e.ts)), ok, err, e);
360
379
  }
361
380
 
362
381
  latencies.sort((a, b) => a - b);
@@ -391,6 +410,10 @@ export class Store {
391
410
  avgMs: tot.calls ? Math.round(tot.sumMs / tot.calls) : 0,
392
411
  p50Ms: pick(0.5),
393
412
  p95Ms: pick(0.95),
413
+ inputTokens: tot.sumInput,
414
+ outputTokens: tot.sumOutput,
415
+ cacheRead: tot.sumCacheRead,
416
+ cacheCreation: tot.sumCacheCreation,
394
417
  },
395
418
  byModel: [...model].map(([k, a]) => ({ key: k, ...fields(a) })).sort(sortDesc),
396
419
  byProvider: [...provider]
@@ -417,20 +440,29 @@ export class Store {
417
440
  * across cooldown expirations and is reset only by success — unless the
418
441
  * provider has been quiet for > CAP, in which case it starts fresh at 1.
419
442
  * When the upstream told us exactly how long to back off (`retryAfterMs`,
420
- * parsed from a 429/overloaded Retry-After header), honor it clamped to
421
- * [CB_MIN, CAP] instead of the escalating guess: the source isn't sicker,
422
- * it just said when it'll be ready. `fails` still increments either way so a
423
- * later hint-less failure continues the escalation from where it left off.
424
- * Returns `entered` = transitioned from healthy cooling this call (the
425
- * caller logs a cooldown row only then, to avoid timeline spam), plus the
426
- * fails count and cooldown duration for that row. */
427
- recordCircuitFailure(id: string, status: number, reason: string, retryAfterMs?: number): { entered: boolean; fails: number; cooldownMs: number } {
443
+ * parsed from a 429/overloaded Retry-After header, OR a reset deadline parsed
444
+ * from a Volcengine-Ark-style error body `resetDeadline` selects the larger
445
+ * RESET_CAP_MS ceiling for the latter), honor it clamped to [CB_MIN, cap]
446
+ * instead of the escalating guess: the source isn't sicker, it just said when
447
+ * it'll be ready. `fails` still increments either way so a later hint-less
448
+ * failure continues the escalation from where it left off. Returns `entered` =
449
+ * transitioned from healthy cooling this call (the caller logs a cooldown
450
+ * row only then, to avoid timeline spam), plus the fails count and cooldown
451
+ * duration for that row. */
452
+ recordCircuitFailure(id: string, status: number, reason: string, retryAfterMs?: number, resetDeadline?: boolean): { entered: boolean; fails: number; cooldownMs: number } {
428
453
  const now = Date.now();
429
454
  const cur = this.circuit.get(id);
430
455
  const stale = !cur || now - cur.lastTs > CB_CAP;
431
456
  const fails = stale ? 1 : cur!.fails + 1;
432
457
  const hint = retryAfterMs && Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? retryAfterMs : 0;
433
- const cooldownMs = hint ? Math.min(CB_CAP, Math.max(CB_MIN, Math.round(hint))) : Math.min(CB_CAP, CB_BASE * 2 ** (fails - 1));
458
+ // A hint's ceiling depends on what it represents: a Retry-After backoff guess
459
+ // caps at CB_CAP (5min — OpenAI-style org-quota Retry-Afters can span
460
+ // hours/days, and we'd rather re-probe than write the source off that long);
461
+ // a reset DEADLINE parsed from an Ark-style body caps at RESET_CAP_MS (a real
462
+ // future event worth waiting for). The no-hint escalating guess always caps at
463
+ // CB_CAP.
464
+ const cap = resetDeadline ? RESET_CAP_MS : CB_CAP;
465
+ const cooldownMs = hint ? Math.min(cap, Math.max(CB_MIN, Math.round(hint))) : Math.min(CB_CAP, CB_BASE * 2 ** (fails - 1));
434
466
  const until = now + cooldownMs;
435
467
  const wasCooling = !!cur && cur.until > now;
436
468
  this.circuit.set(id, { fails, until, lastStatus: status, lastReason: reason, lastTs: now });
@@ -507,15 +539,26 @@ interface Acc {
507
539
  success: number;
508
540
  error: number;
509
541
  sumMs: number;
542
+ sumInput: number;
543
+ sumOutput: number;
544
+ sumCacheRead: number;
545
+ sumCacheCreation: number;
510
546
  }
511
547
  function newAcc(): Acc {
512
- return { calls: 0, success: 0, error: 0, sumMs: 0 };
548
+ return { calls: 0, success: 0, error: 0, sumMs: 0, sumInput: 0, sumOutput: 0, sumCacheRead: 0, sumCacheCreation: 0 };
513
549
  }
514
- function bump(a: Acc, ok: boolean, err: boolean, ms: number): void {
550
+ function bump(a: Acc, ok: boolean, err: boolean, e: LogEntry): void {
515
551
  a.calls++;
516
552
  if (ok) a.success++;
517
553
  if (err) a.error++;
518
- a.sumMs += ms;
554
+ a.sumMs += e.ms || 0;
555
+ const u = e.usage;
556
+ if (u) {
557
+ a.sumInput += u.input || 0;
558
+ a.sumOutput += u.output || 0;
559
+ a.sumCacheRead += u.cacheRead || 0;
560
+ a.sumCacheCreation += u.cacheCreation || 0;
561
+ }
519
562
  }
520
563
  /** Get-or-create a bucket entry in a stats map. */
521
564
  function acc(m: Map<string, Acc>, k: string): Acc {
@@ -528,8 +571,15 @@ function dayKey(ts: number): string {
528
571
  const d = new Date(ts);
529
572
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
530
573
  }
531
- function fields(a: Acc): Pick<StatBucket, "calls" | "success" | "error" | "avgMs"> {
532
- return { calls: a.calls, success: a.success, error: a.error, avgMs: a.calls ? Math.round(a.sumMs / a.calls) : 0 };
574
+ function fields(a: Acc): Pick<StatBucket, "calls" | "success" | "error" | "avgMs" | "inputTokens" | "outputTokens"> {
575
+ return {
576
+ calls: a.calls,
577
+ success: a.success,
578
+ error: a.error,
579
+ avgMs: a.calls ? Math.round(a.sumMs / a.calls) : 0,
580
+ inputTokens: a.sumInput,
581
+ outputTokens: a.sumOutput,
582
+ };
533
583
  }
534
584
 
535
585
  /**
@@ -0,0 +1,210 @@
1
+ /** Token-usage capture for the proxy's stream bucket.
2
+ *
3
+ * `observedBody()` in proxy.ts already wraps the upstream body in a
4
+ * `ReadableStream` that forwards every byte to the client VERBATIM while
5
+ * watching for clean termination. This module hangs a second tap off that same
6
+ * loop: as each decoded chunk flows past, a `UsageCollector` extracts token
7
+ * usage out of band. The bytes themselves are never held back or altered.
8
+ *
9
+ * Strategy is HYBRID, by design (see shared/types.ts `Usage`):
10
+ * - UPSTREAM-REPORTED usage wins wherever the backend sends it — that's the
11
+ * exact billed count. Anthropic streams (message_start/message_delta), every
12
+ * non-streaming JSON body, and /responses (response.completed) all carry it.
13
+ * - OpenAI /chat/completions STREAMS usually omit usage (most agents don't set
14
+ * stream_options.include_usage). For THAT one wire we fall back to a local
15
+ * tokenizer estimate (gpt-tokenizer, o200k_base) of the request messages
16
+ * (prompt) + the accumulated completion text, flagged `estimated: true`. */
17
+ import { countTokens as bpeCountTokens } from "gpt-tokenizer";
18
+ import type { RouteKey, Usage } from "../shared/types";
19
+
20
+ /** Count tokens with gpt-tokenizer's default o200k_base encoding (gpt-4o /
21
+ * gpt-4.1 / o1 / …). Used ONLY for the OpenAI chat streaming fallback estimate;
22
+ * everywhere else the upstream's exact usage is used. o200k_base is a fair ≈
23
+ * for modern OpenAI-family models and rougher for non-OpenAI OpenAI-compatible
24
+ * backends (DeepSeek/Qwen/Ark) — which is why these rows are marked estimated.
25
+ * Never throws: a tokenizer hiccup must not break the passthrough. */
26
+ export function countTokens(text: string): number {
27
+ if (!text) return 0;
28
+ try {
29
+ return bpeCountTokens(text);
30
+ } catch {
31
+ return 0;
32
+ }
33
+ }
34
+
35
+ /** Rough local estimate of the PROMPT token count for an OpenAI chat request,
36
+ * used only on the streaming fallback path. Sums the token counts of every
37
+ * string `content` (and multi-part `text` blocks) across the messages array —
38
+ * non-text parts (images, tool I/O) are undercounted, acceptable for an
39
+ * estimate marked ≈. */
40
+ export function estimatePromptTokens(messages: unknown): number {
41
+ if (!Array.isArray(messages)) return 0;
42
+ let n = 0;
43
+ for (const m of messages) {
44
+ if (!m || typeof m !== "object") continue;
45
+ const content = (m as { content?: unknown }).content;
46
+ if (typeof content === "string") {
47
+ n += countTokens(content);
48
+ } else if (Array.isArray(content)) {
49
+ for (const part of content) {
50
+ const text = (part as { text?: unknown } | null)?.text;
51
+ if (typeof text === "string") n += countTokens(text);
52
+ }
53
+ }
54
+ }
55
+ return n;
56
+ }
57
+
58
+ /** Coerce a JSON value to a finite non-negative integer, or undefined. */
59
+ function num(v: unknown): number | undefined {
60
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.trunc(v) : undefined;
61
+ }
62
+
63
+ type UsageFields = Partial<Pick<Usage, "input" | "output" | "cacheRead" | "cacheCreation">>;
64
+
65
+ /** Pull whatever usage fields are present out of one parsed SSE `data:` payload
66
+ * (streaming) or the whole JSON body (non-streaming). The collector merges
67
+ * these across events per-field (last writer wins) — each protocol's terminal
68
+ * usage is authoritative:
69
+ * - anthropic: `message_start.message.usage` (input + cache) and the running
70
+ * `message_delta.usage.output_tokens` (output); a non-streaming Message
71
+ * carries `usage` directly (both).
72
+ * - openai chat: only the final chunk (with stream_options.include_usage) has
73
+ * `usage` (prompt/completion tokens); the non-streaming body has it too.
74
+ * - responses: `response.usage` on response.completed/in-progress, or top-level
75
+ * `usage` on a non-streaming Response. Accepts both the `*_tokens` and bare
76
+ * `input`/`output` spellings the API has used over time. */
77
+ function extractUsage(obj: any, key: RouteKey): UsageFields | null {
78
+ if (!obj || typeof obj !== "object") return null;
79
+
80
+ if (key === "anthropic") {
81
+ const u = obj.message?.usage ?? obj.usage;
82
+ if (u && (typeof u.input_tokens === "number" || typeof u.output_tokens === "number")) {
83
+ return {
84
+ input: num(u.input_tokens),
85
+ output: num(u.output_tokens),
86
+ cacheRead: num(u.cache_read_input_tokens),
87
+ cacheCreation: num(u.cache_creation_input_tokens),
88
+ };
89
+ }
90
+ return null;
91
+ }
92
+ if (key === "responses") {
93
+ const u = obj.response?.usage ?? obj.usage;
94
+ if (u) {
95
+ const input = num(u.input_tokens) ?? num(u.input);
96
+ const output = num(u.output_tokens) ?? num(u.output);
97
+ if (typeof input === "number" || typeof output === "number") return { input, output };
98
+ }
99
+ return null;
100
+ }
101
+ // openai /chat/completions
102
+ if (obj.usage) {
103
+ return { input: num(obj.usage.prompt_tokens), output: num(obj.usage.completion_tokens) };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /** Accumulates usage out of the bytes flowing through the stream bucket. Feed it
109
+ * every decoded chunk as it passes; call `finalize` once at stream end. Bounded
110
+ * memory: streaming holds only the partial line currently being assembled plus
111
+ * (openai-chat only) the accumulated completion text — which is the assistant
112
+ * message itself, no larger than what the client already receives. */
113
+ export class UsageCollector {
114
+ private partial = ""; // streaming: the in-progress line without a trailing \n
115
+ private buf = ""; // non-streaming: the whole body, parsed once at finalize
116
+ private input?: number;
117
+ private output?: number;
118
+ private cacheRead?: number;
119
+ private cacheCreation?: number;
120
+ /** True once ANY upstream-reported usage has been seen — selects the exact
121
+ * path in finalize() over the local-estimate fallback. */
122
+ private upstream = false;
123
+ /** openai-chat-stream only: concatenated `choices[].delta.content`, for the
124
+ * local completion-token estimate when the upstream omits usage. */
125
+ private completionText = "";
126
+
127
+ feed(text: string, opts: { stream: boolean; key: RouteKey }): void {
128
+ if (!opts.stream) {
129
+ this.buf += text;
130
+ return;
131
+ }
132
+ // Streaming SSE: parse complete lines now, keep the tail partial.
133
+ this.partial += text;
134
+ let nl: number;
135
+ while ((nl = this.partial.indexOf("\n")) >= 0) {
136
+ const line = this.partial.slice(0, nl);
137
+ this.partial = this.partial.slice(nl + 1);
138
+ this.parseLine(line, opts.key);
139
+ }
140
+ }
141
+
142
+ private parseLine(line: string, key: RouteKey): void {
143
+ const colon = line.indexOf(":");
144
+ if (colon < 0) return;
145
+ if (line.slice(0, colon).trim() !== "data") return; // only `data:` lines carry JSON
146
+ const payload = line.slice(colon + 1).trimStart();
147
+ if (!payload || payload === "[DONE]") return;
148
+ let obj: any;
149
+ try {
150
+ obj = JSON.parse(payload);
151
+ } catch {
152
+ return; // a non-JSON data line (e.g. a backend's bespoke marker) — ignore
153
+ }
154
+ const u = extractUsage(obj, key);
155
+ if (u) {
156
+ this.upstream = true;
157
+ if (typeof u.input === "number") this.input = u.input;
158
+ if (typeof u.output === "number") this.output = u.output;
159
+ if (typeof u.cacheRead === "number") this.cacheRead = u.cacheRead;
160
+ if (typeof u.cacheCreation === "number") this.cacheCreation = u.cacheCreation;
161
+ }
162
+ // Accumulate completion text for the openai-chat-stream estimate fallback.
163
+ // Stop once the upstream reported usage (the terminal chunk) — there's
164
+ // nothing after it and no estimate will be needed.
165
+ if (key === "openai" && !this.upstream && Array.isArray(obj.choices)) {
166
+ for (const ch of obj.choices) {
167
+ const c = ch?.delta?.content;
168
+ if (typeof c === "string") this.completionText += c;
169
+ }
170
+ }
171
+ }
172
+
173
+ /** Resolve the final usage (or undefined if none could be determined). Call
174
+ * once, at stream end. `requestMessages` is the original chat request's
175
+ * `messages` — used only to estimate prompt tokens on the openai-chat-stream
176
+ * fallback path. */
177
+ finalize(opts: { stream: boolean; key: RouteKey; requestMessages?: unknown }): Usage | undefined {
178
+ if (!opts.stream) {
179
+ // Non-streaming: parse the whole JSON body once.
180
+ let obj: any;
181
+ try {
182
+ obj = JSON.parse(this.buf);
183
+ } catch {
184
+ return undefined;
185
+ }
186
+ const u = extractUsage(obj, opts.key);
187
+ if (!u) return undefined;
188
+ return { input: u.input ?? 0, output: u.output ?? 0, cacheRead: u.cacheRead, cacheCreation: u.cacheCreation };
189
+ }
190
+ // Flush any trailing line that had no trailing newline.
191
+ if (this.partial.trim()) this.parseLine(this.partial, opts.key);
192
+ if (this.upstream) {
193
+ return {
194
+ input: this.input ?? 0,
195
+ output: this.output ?? 0,
196
+ cacheRead: this.cacheRead,
197
+ cacheCreation: this.cacheCreation,
198
+ };
199
+ }
200
+ // Fallback: local estimate, ONLY for the openai chat streaming path (the one
201
+ // wire whose streams routinely omit usage). Anthropic / /responses streams
202
+ // always report usage, so they never reach here.
203
+ if (opts.key === "openai") {
204
+ const out = countTokens(this.completionText);
205
+ const inp = estimatePromptTokens(opts.requestMessages);
206
+ if (out || inp) return { input: inp, output: out, estimated: true };
207
+ }
208
+ return undefined;
209
+ }
210
+ }
@@ -86,6 +86,22 @@ export interface GateConfig {
86
86
  models: Record<string, ModelEntry>;
87
87
  }
88
88
 
89
+ /** Token usage for a single call. Captured from the upstream's reported usage
90
+ * when available — that's the exact billed count (Anthropic streams, non-
91
+ * streaming bodies, /responses all carry it). For OpenAI /chat/completions
92
+ * streams where the upstream omits usage (most agents don't set
93
+ * stream_options.include_usage), `estimated` is set and input/output come from
94
+ * a local tokenizer approximation (gpt-tokenizer, o200k_base) instead — the UI
95
+ * renders those with a ≈ marker. cacheRead/cacheCreation (prompt-caching hits,
96
+ * Anthropic-only) are surfaced separately from `input`. */
97
+ export interface Usage {
98
+ input: number;
99
+ output: number;
100
+ cacheRead?: number;
101
+ cacheCreation?: number;
102
+ estimated?: boolean;
103
+ }
104
+
89
105
  /** One persisted call-history entry (stored in logs.jsonl, one JSON object per line). */
90
106
  export interface LogEntry {
91
107
  ts: number;
@@ -101,6 +117,10 @@ export interface LogEntry {
101
117
  ms: number;
102
118
  /** Whether the request asked for streaming. */
103
119
  stream: boolean;
120
+ /** Token usage for this call (success rows only). Absent when the upstream
121
+ * reported none AND no local estimate was possible (e.g. a failed/truncated
122
+ * stream), or on legacy log lines written before usage tracking. */
123
+ usage?: Usage;
104
124
  /** Short upstream error text on non-2xx (omitted on success). */
105
125
  error?: string;
106
126
  /** Row kind. Absent on legacy lines → treated as a normal call. "cooldown"