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 +2 -1
- package/packages/core/src/server/proxy.ts +21 -7
- package/packages/core/src/server/store.ts +125 -11
- package/packages/core/src/server/tokens.ts +210 -0
- package/packages/core/src/shared/types.ts +20 -0
- package/packages/web/dist/assets/index-D5hFJsHO.css +1 -0
- package/packages/web/dist/assets/index-ubKfQMez.js +290 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-4IcQkKZI.js +0 -290
- package/packages/web/dist/assets/index-CSq0O4fa.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myapikey",
|
|
3
|
-
"version": "0.
|
|
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: {
|
|
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
|
|
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(
|
|
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,
|
|
412
|
+
bump(tot, ok, err, e);
|
|
362
413
|
latencies.push(ms);
|
|
363
|
-
bump(acc(model, e.model), ok, err,
|
|
364
|
-
bump(acc(provider, e.providerId ?? e.provider ?? "?"), ok, err,
|
|
365
|
-
bump(acc(format, e.format ?? "?"), ok, err,
|
|
366
|
-
bump(acc(day, dayKey(e.ts)), ok, err,
|
|
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,
|
|
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(
|
|
548
|
-
|
|
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"
|