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 +2 -1
- package/packages/core/src/server/proxy.ts +21 -7
- package/packages/core/src/server/store.ts +45 -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-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]);
|
|
@@ -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,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,
|
|
373
|
+
bump(tot, ok, err, e);
|
|
362
374
|
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,
|
|
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,
|
|
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 {
|
|
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"
|