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 +2 -1
- package/packages/core/src/server/proxy.ts +50 -9
- package/packages/core/src/server/store.ts +70 -20
- package/packages/core/src/server/tokens.ts +210 -0
- package/packages/core/src/shared/types.ts +20 -0
- package/packages/web/dist/assets/index-CQ5wHzvl.js +290 -0
- package/packages/web/dist/assets/index-CmZjYdaK.css +1 -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.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: {
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
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,
|
|
373
|
+
bump(tot, ok, err, e);
|
|
355
374
|
latencies.push(ms);
|
|
356
|
-
bump(acc(model, e.model), ok, err,
|
|
357
|
-
bump(acc(provider, e.providerId ?? e.provider ?? "?"), ok, err,
|
|
358
|
-
bump(acc(format, e.format ?? "?"), ok, err,
|
|
359
|
-
bump(acc(day, dayKey(e.ts)), ok, err,
|
|
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
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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"
|