myapikey 0.4.1 → 0.6.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.1",
3
+ "version": "0.6.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]);
@@ -149,6 +150,9 @@ interface SettleInfo {
149
150
  ok: boolean;
150
151
  status: number;
151
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;
152
156
  }
153
157
 
154
158
  /** Wrap an upstream body so every byte is forwarded to the client VERBATIM while
@@ -200,7 +204,14 @@ function errorFrame(key: RouteKey, reason: string): string {
200
204
 
201
205
  function observedBody(
202
206
  upstream: Response,
203
- 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
+ },
204
215
  ): ReadableStream<Uint8Array> {
205
216
  const reader = upstream.body?.getReader();
206
217
  const enc = new TextEncoder();
@@ -209,6 +220,7 @@ function observedBody(
209
220
  let tail = ""; // rolling window so a marker split across chunks is still caught
210
221
  let terminal = false;
211
222
  let settled = false;
223
+ const usage = new UsageCollector();
212
224
 
213
225
  const settle = (info: SettleInfo) => {
214
226
  if (settled) return;
@@ -234,13 +246,14 @@ function observedBody(
234
246
  injectError(controller, reason);
235
247
  settle({ ok: false, status: 502, error: reason });
236
248
  } else {
237
- settle({ ok: true, status: 200 });
249
+ settle({ ok: true, status: 200, usage: usage.finalize({ stream: opts.stream, key: opts.key, requestMessages: opts.requestMessages }) });
238
250
  }
239
251
  controller.close();
240
252
  return;
241
253
  }
254
+ const txt = dec.decode(value, { stream: true });
255
+ usage.feed(txt, { stream: opts.stream, key: opts.key });
242
256
  if (!terminal && markers.length) {
243
- const txt = dec.decode(value, { stream: true });
244
257
  const win = tail + txt;
245
258
  if (markers.some((m) => win.includes(m))) terminal = true;
246
259
  tail = win.slice(-128);
@@ -363,13 +376,14 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
363
376
  // the body's end (so the row reflects the real outcome, not just the
364
377
  // headers). See observedBody() for the detection rules.
365
378
  const ttfb = Date.now() - start;
366
- const body = observedBody(upstream, {
379
+ const out = observedBody(upstream, {
367
380
  stream,
368
381
  key,
382
+ requestMessages: body.messages,
369
383
  onSettle: (info) => {
370
384
  if (info.ok) {
371
385
  store.recordCircuitSuccess(provider.id);
372
- 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 });
373
387
  } else {
374
388
  // A pinned per-source probe takes no circuit side-effects (a manual
375
389
  // test must not trip the breaker) — mirrors the retryable branch.
@@ -378,7 +392,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
378
392
  }
379
393
  },
380
394
  });
381
- 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) });
382
396
  }
383
397
  if (RETRYABLE.has(upstream.status)) {
384
398
  lastStatus = upstream.status;
@@ -75,6 +75,39 @@ export interface StatBucket {
75
75
  success: number;
76
76
  error: number;
77
77
  avgMs: number;
78
+ /** Token totals for this bucket (success rows only). */
79
+ inputTokens: number;
80
+ outputTokens: number;
81
+ /** Prompt-cache read hits summed across this bucket's rows (Anthropic/Ark
82
+ * cache_read_input_tokens). 0 for buckets with no caching. */
83
+ cacheRead: number;
84
+ /** Prompt-cache creation (write) tokens summed across this bucket's rows
85
+ * (cache_creation_input_tokens). */
86
+ cacheCreation: number;
87
+ /** cacheRead / (inputTokens + cacheRead + cacheCreation): "of all prompt
88
+ * tokens, how many were served from cache." 0 when there were no prompt
89
+ * tokens. */
90
+ cacheHitRate: number;
91
+ }
92
+
93
+ /** One provider×model cell in the cache breakdown (GET /admin/stats). Groups
94
+ * the retained history by the stable provider id × the model name, so each
95
+ * source's per-model cache hit rate is visible. `provider` is the LIVE display
96
+ * name (resolved at read time from the config, so renaming a source doesn't
97
+ * split history — same invariant as `byProvider`). */
98
+ export interface ProviderModelStat {
99
+ /** Stable provider id, or "" when the row had none (legacy name-only). */
100
+ providerId: string;
101
+ /** Live provider display name. */
102
+ provider: string;
103
+ model: string;
104
+ calls: number;
105
+ success: number;
106
+ /** Fresh (non-cached) prompt tokens summed across this cell's rows. */
107
+ inputTokens: number;
108
+ cacheRead: number;
109
+ cacheCreation: number;
110
+ cacheHitRate: number;
78
111
  }
79
112
 
80
113
  /** One day in the stats time series. */
@@ -98,10 +131,23 @@ export interface StatsResult {
98
131
  avgMs: number;
99
132
  p50Ms: number;
100
133
  p95Ms: number;
134
+ /** Token totals across the window (success rows only). cacheRead/
135
+ * cacheCreation are prompt-cache hits (Anthropic) — the cached input
136
+ * tokens, counted separately from `inputTokens`. A small fraction of
137
+ * rows (OpenAI chat streams where the upstream omitted usage) contribute
138
+ * local tokenizer ESTIMATES rather than billed counts. */
139
+ inputTokens: number;
140
+ outputTokens: number;
141
+ cacheRead: number;
142
+ cacheCreation: number;
101
143
  };
102
144
  byModel: StatBucket[];
103
145
  byProvider: StatBucket[];
104
146
  byFormat: StatBucket[];
147
+ /** Cache hit rate per source × model — every retained (provider, model) cell,
148
+ * so the UI can show each source's per-model caching. Rows with no cache
149
+ * activity carry 0 cache fields; the UI filters those for the cache view. */
150
+ byProviderModel: ProviderModelStat[];
105
151
  byDay: StatDay[];
106
152
  }
107
153
 
@@ -327,10 +373,11 @@ export class Store {
327
373
  const empty: StatsResult = {
328
374
  from,
329
375
  to,
330
- totals: { calls: 0, success: 0, error: 0, errorRate: 0, avgMs: 0, p50Ms: 0, p95Ms: 0 },
376
+ totals: { calls: 0, success: 0, error: 0, errorRate: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0 },
331
377
  byModel: [],
332
378
  byProvider: [],
333
379
  byFormat: [],
380
+ byProviderModel: [],
334
381
  byDay: [],
335
382
  };
336
383
  if (!existsSync(this.logsPath)) return empty;
@@ -340,6 +387,10 @@ export class Store {
340
387
  const provider = new Map<string, Acc>();
341
388
  const format = new Map<string, Acc>();
342
389
  const day = new Map<string, Acc>();
390
+ /** provider×model cells for the cache breakdown. Keyed by `pidmodel`
391
+ * (null byte so a provider name can't collide with a model name). The
392
+ * display name is resolved at projection time, not stored here. */
393
+ const providerModel = new Map<string, { providerId: string; model: string; a: Acc }>();
343
394
  const tot = newAcc();
344
395
  const latencies: number[] = [];
345
396
 
@@ -358,12 +409,21 @@ export class Store {
358
409
  const ok = e.status >= 200 && e.status < 300;
359
410
  const err = e.status >= 400;
360
411
  const ms = e.ms || 0;
361
- bump(tot, ok, err, ms);
412
+ bump(tot, ok, err, e);
362
413
  latencies.push(ms);
363
- bump(acc(model, e.model), ok, err, ms);
364
- bump(acc(provider, e.providerId ?? e.provider ?? "?"), ok, err, ms);
365
- bump(acc(format, e.format ?? "?"), ok, err, ms);
366
- bump(acc(day, dayKey(e.ts)), ok, err, ms);
414
+ bump(acc(model, e.model), ok, err, e);
415
+ bump(acc(provider, e.providerId ?? e.provider ?? "?"), ok, err, e);
416
+ bump(acc(format, e.format ?? "?"), ok, err, e);
417
+ bump(acc(day, dayKey(e.ts)), ok, err, e);
418
+ // provider×model cell for the cache breakdown.
419
+ const pid = e.providerId ?? e.provider ?? "?";
420
+ const pmKey = pid + "" + e.model;
421
+ let pm = providerModel.get(pmKey);
422
+ if (!pm) {
423
+ pm = { providerId: pid, model: e.model, a: newAcc() };
424
+ providerModel.set(pmKey, pm);
425
+ }
426
+ bump(pm.a, ok, err, e);
367
427
  }
368
428
 
369
429
  latencies.sort((a, b) => a - b);
@@ -398,6 +458,10 @@ export class Store {
398
458
  avgMs: tot.calls ? Math.round(tot.sumMs / tot.calls) : 0,
399
459
  p50Ms: pick(0.5),
400
460
  p95Ms: pick(0.95),
461
+ inputTokens: tot.sumInput,
462
+ outputTokens: tot.sumOutput,
463
+ cacheRead: tot.sumCacheRead,
464
+ cacheCreation: tot.sumCacheCreation,
401
465
  },
402
466
  byModel: [...model].map(([k, a]) => ({ key: k, ...fields(a) })).sort(sortDesc),
403
467
  byProvider: [...provider]
@@ -407,6 +471,26 @@ export class Store {
407
471
  })
408
472
  .sort(sortDesc),
409
473
  byFormat: [...format].map(([k, a]) => ({ key: k, ...fields(a) })).sort(sortDesc),
474
+ byProviderModel: [...providerModel.values()]
475
+ .map(({ providerId: pid, model: m, a }) => {
476
+ // Resolve the live display name when pid is a real provider id (so a
477
+ // rename doesn't split history); otherwise fall back to the raw key.
478
+ const isId = !!pid && pid !== "?" && this.data.providers.some((p) => p.id === pid);
479
+ return {
480
+ providerId: isId ? pid : "",
481
+ provider: isId ? providerName.get(pid) ?? pid : pid,
482
+ model: m,
483
+ calls: a.calls,
484
+ success: a.success,
485
+ inputTokens: a.sumInput,
486
+ cacheRead: a.sumCacheRead,
487
+ cacheCreation: a.sumCacheCreation,
488
+ cacheHitRate: hitRate(a.sumCacheRead, a.sumInput, a.sumCacheCreation),
489
+ } satisfies ProviderModelStat;
490
+ })
491
+ // Group siblings together (provider name asc), busiest models first —
492
+ // the UI regroups by provider regardless, this just keeps it readable.
493
+ .sort((x, y) => (x.provider < y.provider ? -1 : x.provider > y.provider ? 1 : y.calls - x.calls)),
410
494
  byDay,
411
495
  };
412
496
  }
@@ -523,15 +607,26 @@ interface Acc {
523
607
  success: number;
524
608
  error: number;
525
609
  sumMs: number;
610
+ sumInput: number;
611
+ sumOutput: number;
612
+ sumCacheRead: number;
613
+ sumCacheCreation: number;
526
614
  }
527
615
  function newAcc(): Acc {
528
- return { calls: 0, success: 0, error: 0, sumMs: 0 };
616
+ return { calls: 0, success: 0, error: 0, sumMs: 0, sumInput: 0, sumOutput: 0, sumCacheRead: 0, sumCacheCreation: 0 };
529
617
  }
530
- function bump(a: Acc, ok: boolean, err: boolean, ms: number): void {
618
+ function bump(a: Acc, ok: boolean, err: boolean, e: LogEntry): void {
531
619
  a.calls++;
532
620
  if (ok) a.success++;
533
621
  if (err) a.error++;
534
- a.sumMs += ms;
622
+ a.sumMs += e.ms || 0;
623
+ const u = e.usage;
624
+ if (u) {
625
+ a.sumInput += u.input || 0;
626
+ a.sumOutput += u.output || 0;
627
+ a.sumCacheRead += u.cacheRead || 0;
628
+ a.sumCacheCreation += u.cacheCreation || 0;
629
+ }
535
630
  }
536
631
  /** Get-or-create a bucket entry in a stats map. */
537
632
  function acc(m: Map<string, Acc>, k: string): Acc {
@@ -544,8 +639,27 @@ function dayKey(ts: number): string {
544
639
  const d = new Date(ts);
545
640
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
546
641
  }
547
- function fields(a: Acc): Pick<StatBucket, "calls" | "success" | "error" | "avgMs"> {
548
- return { calls: a.calls, success: a.success, error: a.error, avgMs: a.calls ? Math.round(a.sumMs / a.calls) : 0 };
642
+ function fields(
643
+ a: Acc,
644
+ ): Pick<StatBucket, "calls" | "success" | "error" | "avgMs" | "inputTokens" | "outputTokens" | "cacheRead" | "cacheCreation" | "cacheHitRate"> {
645
+ return {
646
+ calls: a.calls,
647
+ success: a.success,
648
+ error: a.error,
649
+ avgMs: a.calls ? Math.round(a.sumMs / a.calls) : 0,
650
+ inputTokens: a.sumInput,
651
+ outputTokens: a.sumOutput,
652
+ cacheRead: a.sumCacheRead,
653
+ cacheCreation: a.sumCacheCreation,
654
+ cacheHitRate: hitRate(a.sumCacheRead, a.sumInput, a.sumCacheCreation),
655
+ };
656
+ }
657
+ /** Cache hit rate: cacheRead / (input + cacheRead + cacheCreation) — "of all
658
+ * prompt tokens, how many were served from cache." 0 when there were no prompt
659
+ * tokens (guard against divide-by-zero). */
660
+ function hitRate(cacheRead: number, input: number, cacheCreation: number): number {
661
+ const denom = input + cacheRead + cacheCreation;
662
+ return denom > 0 ? cacheRead / denom : 0;
549
663
  }
550
664
 
551
665
  /**
@@ -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"