myapikey 0.4.1 → 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.1",
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]);
@@ -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,9 @@ 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;
78
81
  }
79
82
 
80
83
  /** One day in the stats time series. */
@@ -98,6 +101,15 @@ export interface StatsResult {
98
101
  avgMs: number;
99
102
  p50Ms: number;
100
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;
101
113
  };
102
114
  byModel: StatBucket[];
103
115
  byProvider: StatBucket[];
@@ -327,7 +339,7 @@ export class Store {
327
339
  const empty: StatsResult = {
328
340
  from,
329
341
  to,
330
- 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 },
331
343
  byModel: [],
332
344
  byProvider: [],
333
345
  byFormat: [],
@@ -358,12 +370,12 @@ export class Store {
358
370
  const ok = e.status >= 200 && e.status < 300;
359
371
  const err = e.status >= 400;
360
372
  const ms = e.ms || 0;
361
- bump(tot, ok, err, ms);
373
+ bump(tot, ok, err, e);
362
374
  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);
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);
367
379
  }
368
380
 
369
381
  latencies.sort((a, b) => a - b);
@@ -398,6 +410,10 @@ export class Store {
398
410
  avgMs: tot.calls ? Math.round(tot.sumMs / tot.calls) : 0,
399
411
  p50Ms: pick(0.5),
400
412
  p95Ms: pick(0.95),
413
+ inputTokens: tot.sumInput,
414
+ outputTokens: tot.sumOutput,
415
+ cacheRead: tot.sumCacheRead,
416
+ cacheCreation: tot.sumCacheCreation,
401
417
  },
402
418
  byModel: [...model].map(([k, a]) => ({ key: k, ...fields(a) })).sort(sortDesc),
403
419
  byProvider: [...provider]
@@ -523,15 +539,26 @@ interface Acc {
523
539
  success: number;
524
540
  error: number;
525
541
  sumMs: number;
542
+ sumInput: number;
543
+ sumOutput: number;
544
+ sumCacheRead: number;
545
+ sumCacheCreation: number;
526
546
  }
527
547
  function newAcc(): Acc {
528
- 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 };
529
549
  }
530
- function bump(a: Acc, ok: boolean, err: boolean, ms: number): void {
550
+ function bump(a: Acc, ok: boolean, err: boolean, e: LogEntry): void {
531
551
  a.calls++;
532
552
  if (ok) a.success++;
533
553
  if (err) a.error++;
534
- 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
+ }
535
562
  }
536
563
  /** Get-or-create a bucket entry in a stats map. */
537
564
  function acc(m: Map<string, Acc>, k: string): Acc {
@@ -544,8 +571,15 @@ function dayKey(ts: number): string {
544
571
  const d = new Date(ts);
545
572
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
546
573
  }
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 };
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
+ };
549
583
  }
550
584
 
551
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"