@goodandready/dsh-key-rotation 0.8.67 → 0.8.69
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/CHANGELOG.md +36 -0
- package/lib/client.js +68 -1
- package/lib/clone-lifecycle.js +199 -0
- package/lib/index.js +6 -0
- package/lib/key-drain.js +6 -4
- package/lib/ops-analytics.js +162 -0
- package/lib/ops-config-io.js +9 -2
- package/lib/ops-status.js +4 -0
- package/lib/ops-telemetry.js +8 -1
- package/lib/pool-builder.js +8 -0
- package/lib/pool-selection.js +149 -0
- package/lib/pool.js +12 -145
- package/lib/prompt-cache.js +252 -0
- package/lib/rotate.js +595 -704
- package/lib/stream-metrics.js +133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// lib/pool-selection.js — pool selection, sticky sessions, attempt sorting and budget metrics (#512).
|
|
2
|
+
// Extracted from lib/pool.js to enforce < 600 line modularity standard.
|
|
3
|
+
|
|
4
|
+
export const MAX_SESSION_STICKY_ENTRIES = 500;
|
|
5
|
+
|
|
6
|
+
export function setSessionSticky(pool, sessionId, candidate) {
|
|
7
|
+
if (!sessionId || !candidate || !pool?.state) return;
|
|
8
|
+
if (!(pool.state.sessionSticky instanceof Map)) {
|
|
9
|
+
pool.state.sessionSticky = new Map();
|
|
10
|
+
}
|
|
11
|
+
if (pool.state.sessionSticky.has(sessionId)) {
|
|
12
|
+
pool.state.sessionSticky.delete(sessionId);
|
|
13
|
+
} else if (pool.state.sessionSticky.size >= MAX_SESSION_STICKY_ENTRIES) {
|
|
14
|
+
const oldest = pool.state.sessionSticky.keys().next().value;
|
|
15
|
+
if (oldest !== undefined) pool.state.sessionSticky.delete(oldest);
|
|
16
|
+
}
|
|
17
|
+
pool.state.sessionSticky.set(sessionId, candidate);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getSessionSticky(pool, sessionId) {
|
|
21
|
+
if (!sessionId || !pool?.state?.sessionSticky) return null;
|
|
22
|
+
return pool.state.sessionSticky.get(sessionId) ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function clearSessionSticky(pool, sessionId) {
|
|
26
|
+
if (!sessionId || !pool?.state?.sessionSticky) return;
|
|
27
|
+
pool.state.sessionSticky.delete(sessionId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Pick a key pool for a (provider, model) pair. */
|
|
31
|
+
export function selectPool(modelPoolByProvider, providerToPool, provider, model) {
|
|
32
|
+
const byModel = modelPoolByProvider && modelPoolByProvider.get(provider);
|
|
33
|
+
const base = providerToPool && providerToPool.get(provider);
|
|
34
|
+
if (!byModel || !model) return base ?? null;
|
|
35
|
+
if (byModel.has(model)) return byModel.get(model);
|
|
36
|
+
let best = null;
|
|
37
|
+
for (const key of byModel.keys()) {
|
|
38
|
+
if (model.startsWith(key) && key.length > (best ? best.length : 0)) best = key;
|
|
39
|
+
}
|
|
40
|
+
return (best ? byModel.get(best) : base) ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sort candidate refs according to pool routing strategy.
|
|
45
|
+
* Returns a new array; does not mutate input.
|
|
46
|
+
*/
|
|
47
|
+
export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
48
|
+
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
49
|
+
const list = refs.slice();
|
|
50
|
+
if (strategy === 'sticky') {
|
|
51
|
+
const last = deps.sessionSticky ||
|
|
52
|
+
(deps.sessionId && (deps.pool?.state?.sessionSticky?.get?.(deps.sessionId) || deps.sessionStickyMap?.get?.(deps.sessionId))) ||
|
|
53
|
+
deps.lastUsed ||
|
|
54
|
+
(deps.pool && deps.pool.state && deps.pool.state.lastUsed);
|
|
55
|
+
if (last && list.includes(last)) {
|
|
56
|
+
const idx = list.indexOf(last);
|
|
57
|
+
if (idx > 0) {
|
|
58
|
+
list.splice(idx, 1);
|
|
59
|
+
list.unshift(last);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return list;
|
|
63
|
+
}
|
|
64
|
+
if (strategy === 'tier-priority') {
|
|
65
|
+
const tiers = deps.tiers || (deps.pool && deps.pool.tiers) || {};
|
|
66
|
+
return list.sort((a, b) => {
|
|
67
|
+
const ta = (tiers[a] && Number(tiers[a]) >= 1) ? Number(tiers[a]) : 1;
|
|
68
|
+
const tb = (tiers[b] && Number(tiers[b]) >= 1) ? Number(tiers[b]) : 1;
|
|
69
|
+
return ta - tb;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
|
|
73
|
+
const weights = deps.weights || (deps.pool && deps.pool.weightsMap) || {};
|
|
74
|
+
return list.sort((a, b) => {
|
|
75
|
+
const ca = deps.concurrencyTracker.getActive(a) ?? 0;
|
|
76
|
+
const cb = deps.concurrencyTracker.getActive(b) ?? 0;
|
|
77
|
+
const wa = (weights[a] && Number(weights[a]) > 0) ? Number(weights[a]) : 1;
|
|
78
|
+
const wb = (weights[b] && Number(weights[b]) > 0) ? Number(weights[b]) : 1;
|
|
79
|
+
const scoreA = ca / wa;
|
|
80
|
+
const scoreB = cb / wb;
|
|
81
|
+
if (scoreA !== scoreB) return scoreA - scoreB;
|
|
82
|
+
return ca - cb;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
|
|
86
|
+
return list.sort((a, b) => {
|
|
87
|
+
const sa = deps.latencyHistogram.snapshot(a);
|
|
88
|
+
const sb = deps.latencyHistogram.snapshot(b);
|
|
89
|
+
const la = (sa && Number.isFinite(sa.p95)) ? sa.p95 : ((sa && Number.isFinite(sa.avg)) ? sa.avg : 500);
|
|
90
|
+
const lb = (sb && Number.isFinite(sb.p95)) ? sb.p95 : ((sb && Number.isFinite(sb.avg)) ? sb.avg : 500);
|
|
91
|
+
return la - lb;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return list;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Keys of pool whose expiresAt falls within the next warnDays.
|
|
99
|
+
* Returns [{ ref, expiresInDays, expiresAt }], soonest first.
|
|
100
|
+
*/
|
|
101
|
+
export function expiringSoon(pool, warnDays = 7, now = Date.now()) {
|
|
102
|
+
if (!pool || !pool.expiresAt) return [];
|
|
103
|
+
const DAY_MS = 86400000;
|
|
104
|
+
const horizon = now + Math.max(1, warnDays) * DAY_MS;
|
|
105
|
+
return Object.entries(pool.expiresAt)
|
|
106
|
+
.filter(([, at]) => at > now && at <= horizon)
|
|
107
|
+
.map(([ref, at]) => ({ ref, expiresAt: at, expiresInDays: Math.max(0, Math.floor((at - now) / DAY_MS)) }))
|
|
108
|
+
.sort((a, b) => a.expiresAt - b.expiresAt);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Dedupe: true when a day-level notification for key is due. */
|
|
112
|
+
export function shouldNotifyDaily(lastNotified, key, now = Date.now()) {
|
|
113
|
+
const DAY_MS = 86400000;
|
|
114
|
+
if (!lastNotified.has(key)) {
|
|
115
|
+
lastNotified.set(key, now);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
const last = lastNotified.get(key);
|
|
119
|
+
if (now - last < DAY_MS) return false;
|
|
120
|
+
lastNotified.set(key, now);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Total spend of a pool on ISO day day (defaults to today) across costDays Map<ref, Map<day, cost>>. */
|
|
125
|
+
export function costForDay(costDays, day) {
|
|
126
|
+
const d = day ?? new Date().toISOString().slice(0, 10);
|
|
127
|
+
let total = 0;
|
|
128
|
+
for (const perRef of (costDays?.values() ?? [])) {
|
|
129
|
+
total += perRef.get(d) ?? 0;
|
|
130
|
+
}
|
|
131
|
+
return total;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Total spend over the last 7 ISO days ending today. */
|
|
135
|
+
export function costForWeek(costDays, now = Date.now()) {
|
|
136
|
+
let total = 0;
|
|
137
|
+
for (let i = 0; i < 7; i++) {
|
|
138
|
+
const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
|
|
139
|
+
total += costForDay(costDays, d);
|
|
140
|
+
}
|
|
141
|
+
return total;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Budget verdict for a pool: { spend, budget, ratio, warn, exceeded }. */
|
|
145
|
+
export function budgetVerdict(spend, budget) {
|
|
146
|
+
if (!budget || budget <= 0) return { spend, budget: 0, ratio: 0, warn: false, exceeded: false };
|
|
147
|
+
const ratio = spend / budget;
|
|
148
|
+
return { spend, budget, ratio, warn: ratio >= 0.8, exceeded: ratio >= 1 };
|
|
149
|
+
}
|
package/lib/pool.js
CHANGED
|
@@ -1,28 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
pool.state.sessionSticky.set(sessionId, candidate);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function getSessionSticky(pool, sessionId) {
|
|
18
|
-
if (!sessionId || !pool?.state?.sessionSticky) return null;
|
|
19
|
-
return pool.state.sessionSticky.get(sessionId) ?? null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function clearSessionSticky(pool, sessionId) {
|
|
23
|
-
if (!sessionId || !pool?.state?.sessionSticky) return;
|
|
24
|
-
pool.state.sessionSticky.delete(sessionId);
|
|
25
|
-
}
|
|
1
|
+
// lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
|
|
2
|
+
import {
|
|
3
|
+
MAX_SESSION_STICKY_ENTRIES, setSessionSticky, getSessionSticky, clearSessionSticky,
|
|
4
|
+
selectPool, sortAttemptList, expiringSoon, shouldNotifyDaily,
|
|
5
|
+
costForDay, costForWeek, budgetVerdict,
|
|
6
|
+
} from './pool-selection.js';
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
MAX_SESSION_STICKY_ENTRIES, setSessionSticky, getSessionSticky, clearSessionSticky,
|
|
10
|
+
selectPool, sortAttemptList, expiringSoon, shouldNotifyDaily,
|
|
11
|
+
costForDay, costForWeek, budgetVerdict,
|
|
12
|
+
};
|
|
26
13
|
|
|
27
14
|
import { resetModelQuotaIfNeeded } from './model-quota.js';
|
|
28
15
|
import { decryptSecret } from './crypto-storage.js';
|
|
@@ -288,19 +275,6 @@ export function parseRetryAfter(value) {
|
|
|
288
275
|
}
|
|
289
276
|
|
|
290
277
|
/** Pick a key pool for a (provider, model) pair. */
|
|
291
|
-
export function selectPool(modelPoolByProvider, providerToPool, provider, model) {
|
|
292
|
-
const byModel = modelPoolByProvider && modelPoolByProvider.get(provider);
|
|
293
|
-
const base = providerToPool && providerToPool.get(provider);
|
|
294
|
-
if (!byModel || !model) return base ?? null;
|
|
295
|
-
if (byModel.has(model)) return byModel.get(model);
|
|
296
|
-
let best = null;
|
|
297
|
-
for (const key of byModel.keys()) {
|
|
298
|
-
if (model.startsWith(key) && key.length > (best ? best.length : 0)) best = key;
|
|
299
|
-
}
|
|
300
|
-
return (best ? byModel.get(best) : base) ?? null;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Parse an expiry value (timestamp ms or ISO date string) to epoch ms. */
|
|
304
278
|
export function parseExpiry(v) {
|
|
305
279
|
if (typeof v === 'number' && v > 0) return v;
|
|
306
280
|
if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
|
|
@@ -434,61 +408,6 @@ export function isRateLimited(rate, threshold = 0.1) {
|
|
|
434
408
|
* - 'least-loaded': keys with lowest active concurrency count first
|
|
435
409
|
* - 'lowest-latency': keys with lowest p95 (or avg) latency first; unsampled keys neutral
|
|
436
410
|
*/
|
|
437
|
-
export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
438
|
-
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
439
|
-
const list = refs.slice();
|
|
440
|
-
if (strategy === 'sticky') {
|
|
441
|
-
const last = deps.sessionSticky ||
|
|
442
|
-
(deps.sessionId && (deps.pool?.state?.sessionSticky?.get?.(deps.sessionId) || deps.sessionStickyMap?.get?.(deps.sessionId))) ||
|
|
443
|
-
deps.lastUsed ||
|
|
444
|
-
(deps.pool && deps.pool.state && deps.pool.state.lastUsed);
|
|
445
|
-
if (last && list.includes(last)) {
|
|
446
|
-
const idx = list.indexOf(last);
|
|
447
|
-
if (idx > 0) {
|
|
448
|
-
list.splice(idx, 1);
|
|
449
|
-
list.unshift(last);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
return list;
|
|
453
|
-
}
|
|
454
|
-
if (strategy === 'tier-priority') {
|
|
455
|
-
const tiers = deps.tiers || (deps.pool && deps.pool.tiers) || {};
|
|
456
|
-
return list.sort((a, b) => {
|
|
457
|
-
const ta = (tiers[a] && Number(tiers[a]) >= 1) ? Number(tiers[a]) : 1;
|
|
458
|
-
const tb = (tiers[b] && Number(tiers[b]) >= 1) ? Number(tiers[b]) : 1;
|
|
459
|
-
return ta - tb;
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
|
|
463
|
-
const weights = deps.weights || (deps.pool && deps.pool.weightsMap) || {};
|
|
464
|
-
return list.sort((a, b) => {
|
|
465
|
-
const ca = deps.concurrencyTracker.getActive(a) ?? 0;
|
|
466
|
-
const cb = deps.concurrencyTracker.getActive(b) ?? 0;
|
|
467
|
-
const wa = (weights[a] && Number(weights[a]) > 0) ? Number(weights[a]) : 1;
|
|
468
|
-
const wb = (weights[b] && Number(weights[b]) > 0) ? Number(weights[b]) : 1;
|
|
469
|
-
const scoreA = ca / wa;
|
|
470
|
-
const scoreB = cb / wb;
|
|
471
|
-
if (scoreA !== scoreB) return scoreA - scoreB;
|
|
472
|
-
return ca - cb;
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
|
|
476
|
-
return list.sort((a, b) => {
|
|
477
|
-
const sa = deps.latencyHistogram.snapshot(a);
|
|
478
|
-
const sb = deps.latencyHistogram.snapshot(b);
|
|
479
|
-
const la = (sa && Number.isFinite(sa.p95)) ? sa.p95 : ((sa && Number.isFinite(sa.avg)) ? sa.avg : 500);
|
|
480
|
-
const lb = (sb && Number.isFinite(sb.p95)) ? sb.p95 : ((sb && Number.isFinite(sb.avg)) ? sb.avg : 500);
|
|
481
|
-
return la - lb;
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
return list;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Unified check whether an error or payload represents a switchable failure.
|
|
489
|
-
* Prioritizes explicit HTTP status codes and gRPC codes before text matching.
|
|
490
|
-
*/
|
|
491
|
-
/** Raw socket-level codes the taxonomy maps to TRANSPORT (see classifyFailure). */
|
|
492
411
|
const SOCKET_TRANSPORT_CODES = new Set([
|
|
493
412
|
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND',
|
|
494
413
|
'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'UND_ERR_HEADERS_TIMEOUT',
|
|
@@ -565,58 +484,6 @@ export function formatExhaustionMessage(provider, pool, now = Date.now()) {
|
|
|
565
484
|
* Keys of pool whose expiresAt falls within the next warnDays.
|
|
566
485
|
* Returns [{ ref, expiresInDays, expiresAt }], soonest first.
|
|
567
486
|
*/
|
|
568
|
-
export function expiringSoon(pool, warnDays = 7, now = Date.now()) {
|
|
569
|
-
if (!pool || !pool.expiresAt) return [];
|
|
570
|
-
const DAY_MS = 86400000;
|
|
571
|
-
const horizon = now + Math.max(1, warnDays) * DAY_MS;
|
|
572
|
-
return Object.entries(pool.expiresAt)
|
|
573
|
-
.filter(([, at]) => at > now && at <= horizon)
|
|
574
|
-
.map(([ref, at]) => ({ ref, expiresAt: at, expiresInDays: Math.max(0, Math.floor((at - now) / DAY_MS)) }))
|
|
575
|
-
.sort((a, b) => a.expiresAt - b.expiresAt);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/** Dedupe: true when a day-level notification for key is due. */
|
|
579
|
-
export function shouldNotifyDaily(lastNotified, key, now = Date.now()) {
|
|
580
|
-
const DAY_MS = 86400000;
|
|
581
|
-
if (!lastNotified.has(key)) {
|
|
582
|
-
lastNotified.set(key, now);
|
|
583
|
-
return true;
|
|
584
|
-
}
|
|
585
|
-
const last = lastNotified.get(key);
|
|
586
|
-
if (now - last < DAY_MS) return false;
|
|
587
|
-
lastNotified.set(key, now);
|
|
588
|
-
return true;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/** Total spend of a pool on ISO day day (defaults to today) across costDays Map<ref, Map<day, cost>>. */
|
|
592
|
-
export function costForDay(costDays, day) {
|
|
593
|
-
const d = day ?? new Date().toISOString().slice(0, 10);
|
|
594
|
-
let total = 0;
|
|
595
|
-
for (const perRef of (costDays?.values() ?? [])) {
|
|
596
|
-
total += perRef.get(d) ?? 0;
|
|
597
|
-
}
|
|
598
|
-
return total;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
/** Total spend over the last 7 ISO days ending today. */
|
|
602
|
-
export function costForWeek(costDays, now = Date.now()) {
|
|
603
|
-
let total = 0;
|
|
604
|
-
for (let i = 0; i < 7; i++) {
|
|
605
|
-
const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
|
|
606
|
-
total += costForDay(costDays, d);
|
|
607
|
-
}
|
|
608
|
-
return total;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/** Budget verdict for a pool: { spend, budget, ratio, warn, exceeded }. */
|
|
612
|
-
export function budgetVerdict(spend, budget) {
|
|
613
|
-
if (!budget || budget <= 0) return { spend, budget: 0, ratio: 0, warn: false, exceeded: false };
|
|
614
|
-
const ratio = spend / budget;
|
|
615
|
-
return { spend, budget, ratio, warn: ratio >= 0.8, exceeded: ratio >= 1 };
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
/** Safely reset a provider's circuit breaker to closed state. */
|
|
620
487
|
export function resetCircuitForProvider(circuitBreaker, provider) {
|
|
621
488
|
if (!circuitBreaker || !provider) return false;
|
|
622
489
|
const ok = bestEffort('resetCircuitForProvider', () => {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// lib/prompt-cache.js — prompt caching token tracking, economics calculator, and summary (#497).
|
|
2
|
+
// Pure helpers with zero dependencies.
|
|
3
|
+
|
|
4
|
+
const DAY_MS = 86400000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extracts prompt cache token counters from streaming/non-streaming usage payloads.
|
|
8
|
+
* Supports DeepSeek, Anthropic, and OpenAI formats.
|
|
9
|
+
* @param {object} usage
|
|
10
|
+
* @returns {{ cachedTokens: number, uncachedTokens: number, totalInputTokens: number } | null}
|
|
11
|
+
*/
|
|
12
|
+
export function extractPromptCacheTokens(usage) {
|
|
13
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
14
|
+
|
|
15
|
+
let cachedTokens = 0;
|
|
16
|
+
let uncachedTokens = 0;
|
|
17
|
+
let detected = false;
|
|
18
|
+
|
|
19
|
+
// 1. DeepSeek format: prompt_cache_hit_tokens, prompt_cache_miss_tokens
|
|
20
|
+
if (usage.prompt_cache_hit_tokens != null) {
|
|
21
|
+
cachedTokens = Math.max(0, Number(usage.prompt_cache_hit_tokens) || 0);
|
|
22
|
+
uncachedTokens = Math.max(0, Number(usage.prompt_cache_miss_tokens) || 0);
|
|
23
|
+
detected = true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 2. Anthropic format: cache_read_input_tokens, cache_creation_input_tokens
|
|
27
|
+
if (usage.cache_read_input_tokens != null) {
|
|
28
|
+
cachedTokens = Math.max(0, Number(usage.cache_read_input_tokens) || 0);
|
|
29
|
+
const creation = Math.max(0, Number(usage.cache_creation_input_tokens) || 0);
|
|
30
|
+
const rawInput = Math.max(0, Number(usage.input_tokens) || 0);
|
|
31
|
+
uncachedTokens = creation > 0 ? creation : Math.max(0, rawInput - cachedTokens);
|
|
32
|
+
detected = true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 3. OpenAI format: prompt_tokens_details.cached_tokens
|
|
36
|
+
if (usage.prompt_tokens_details?.cached_tokens != null) {
|
|
37
|
+
cachedTokens = Math.max(0, Number(usage.prompt_tokens_details.cached_tokens) || 0);
|
|
38
|
+
const totalP = Math.max(0, Number(usage.prompt_tokens) || 0);
|
|
39
|
+
uncachedTokens = Math.max(0, totalP - cachedTokens);
|
|
40
|
+
detected = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const explicitTotal = Number(usage.prompt_tokens ?? usage.input_tokens);
|
|
44
|
+
const totalInputTokens = Number.isFinite(explicitTotal) && explicitTotal > 0
|
|
45
|
+
? Math.max(cachedTokens, explicitTotal)
|
|
46
|
+
: (cachedTokens + uncachedTokens);
|
|
47
|
+
|
|
48
|
+
if (!detected && cachedTokens === 0 && totalInputTokens === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
cachedTokens,
|
|
54
|
+
uncachedTokens,
|
|
55
|
+
totalInputTokens,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Calculates financial savings in USD based on cached token count and model rates.
|
|
61
|
+
* Standard input cost minus discounted cache read cost (typically 90% savings).
|
|
62
|
+
*/
|
|
63
|
+
export function calculateCacheSavings(provider = '', model = '', cachedTokens = 0) {
|
|
64
|
+
if (!Number.isFinite(cachedTokens) || cachedTokens <= 0) return 0;
|
|
65
|
+
|
|
66
|
+
const key = `${provider}/${model}`.toLowerCase();
|
|
67
|
+
let savingsPerToken = 0.0000009; // Default: $0.90 per 1M tokens saved (90% off $1.00/M)
|
|
68
|
+
|
|
69
|
+
if (key.includes('deepseek')) {
|
|
70
|
+
// DeepSeek: standard $0.14/M, cache hit $0.014/M -> saves $0.126/M ($0.000000126/token)
|
|
71
|
+
savingsPerToken = 0.000000126;
|
|
72
|
+
} else if (key.includes('claude') || key.includes('anthropic')) {
|
|
73
|
+
// Anthropic: Sonnet base $3.00/M, cache read $0.30/M -> saves $2.70/M ($0.0000027/token)
|
|
74
|
+
savingsPerToken = 0.0000027;
|
|
75
|
+
} else if (key.includes('gpt-4') || key.includes('openai')) {
|
|
76
|
+
// OpenAI: GPT-4o base $2.50/M, cache read $1.25/M -> saves $1.25/M ($0.00000125/token)
|
|
77
|
+
savingsPerToken = 0.00000125;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const saved = cachedTokens * savingsPerToken;
|
|
81
|
+
return Math.round(saved * 1000000) / 1000000;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatSavings(val) {
|
|
85
|
+
if (!Number.isFinite(val) || val <= 0) return 0;
|
|
86
|
+
return val < 0.01 ? Math.round(val * 10000) / 10000 : Math.round(val * 100) / 100;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Records prompt cache usage in pool state.
|
|
91
|
+
*/
|
|
92
|
+
export function recordPromptCache(pool, ref, model, usage, now = Date.now()) {
|
|
93
|
+
if (!pool?.state) return null;
|
|
94
|
+
const parsed = extractPromptCacheTokens(usage);
|
|
95
|
+
if (!parsed || (parsed.cachedTokens === 0 && parsed.totalInputTokens === 0)) return null;
|
|
96
|
+
|
|
97
|
+
const savings = calculateCacheSavings(pool.provider ?? pool.base, model, parsed.cachedTokens);
|
|
98
|
+
|
|
99
|
+
if (!pool.state.promptCache) {
|
|
100
|
+
pool.state.promptCache = {
|
|
101
|
+
cachedTokens: 0,
|
|
102
|
+
totalInputTokens: 0,
|
|
103
|
+
savingsUsd: 0,
|
|
104
|
+
days: new Map(),
|
|
105
|
+
byRef: new Map(),
|
|
106
|
+
byModel: new Map(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pc = pool.state.promptCache;
|
|
111
|
+
pc.cachedTokens += parsed.cachedTokens;
|
|
112
|
+
pc.totalInputTokens += parsed.totalInputTokens;
|
|
113
|
+
pc.savingsUsd = Math.round((pc.savingsUsd + savings) * 1000000) / 1000000;
|
|
114
|
+
|
|
115
|
+
const todayIso = new Date(now).toISOString().slice(0, 10);
|
|
116
|
+
const dayEntry = pc.days.get(todayIso) || { cachedTokens: 0, totalInputTokens: 0, savingsUsd: 0 };
|
|
117
|
+
dayEntry.cachedTokens += parsed.cachedTokens;
|
|
118
|
+
dayEntry.totalInputTokens += parsed.totalInputTokens;
|
|
119
|
+
dayEntry.savingsUsd = Math.round((dayEntry.savingsUsd + savings) * 1000000) / 1000000;
|
|
120
|
+
pc.days.set(todayIso, dayEntry);
|
|
121
|
+
|
|
122
|
+
if (ref) {
|
|
123
|
+
const refEntry = pc.byRef.get(ref) || { cachedTokens: 0, totalInputTokens: 0, savingsUsd: 0 };
|
|
124
|
+
refEntry.cachedTokens += parsed.cachedTokens;
|
|
125
|
+
refEntry.totalInputTokens += parsed.totalInputTokens;
|
|
126
|
+
refEntry.savingsUsd = Math.round((refEntry.savingsUsd + savings) * 1000000) / 1000000;
|
|
127
|
+
pc.byRef.set(ref, refEntry);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const mKey = model || 'default';
|
|
131
|
+
const mEntry = pc.byModel.get(mKey) || { cachedTokens: 0, totalInputTokens: 0, savingsUsd: 0 };
|
|
132
|
+
mEntry.cachedTokens += parsed.cachedTokens;
|
|
133
|
+
mEntry.totalInputTokens += parsed.totalInputTokens;
|
|
134
|
+
mEntry.savingsUsd = Math.round((mEntry.savingsUsd + savings) * 1000000) / 1000000;
|
|
135
|
+
pc.byModel.set(mKey, mEntry);
|
|
136
|
+
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sumWindow(daysMap, dayKeys) {
|
|
141
|
+
let cachedTokens = 0;
|
|
142
|
+
let totalInputTokens = 0;
|
|
143
|
+
let savingsUsd = 0;
|
|
144
|
+
|
|
145
|
+
for (const d of dayKeys) {
|
|
146
|
+
const entry = daysMap?.get(d);
|
|
147
|
+
if (entry) {
|
|
148
|
+
cachedTokens += entry.cachedTokens ?? 0;
|
|
149
|
+
totalInputTokens += entry.totalInputTokens ?? 0;
|
|
150
|
+
savingsUsd += entry.savingsUsd ?? 0;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const hitRatioPct = totalInputTokens > 0
|
|
155
|
+
? Math.round((cachedTokens / totalInputTokens) * 1000) / 10
|
|
156
|
+
: 0;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
cachedTokens,
|
|
160
|
+
totalInputTokens,
|
|
161
|
+
hitRatioPct,
|
|
162
|
+
savingsUsd: formatSavings(savingsUsd),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Returns structured summary of prompt cache metrics for 24h, 7d, 30d, plus models and keys.
|
|
168
|
+
*/
|
|
169
|
+
export function getPromptCacheSummary(pool, now = Date.now()) {
|
|
170
|
+
const pc = pool?.state?.promptCache;
|
|
171
|
+
if (!pc) {
|
|
172
|
+
return {
|
|
173
|
+
allTime: { cachedTokens: 0, totalInputTokens: 0, hitRatioPct: 0, savingsUsd: 0 },
|
|
174
|
+
d1: { cachedTokens: 0, totalInputTokens: 0, hitRatioPct: 0, savingsUsd: 0 },
|
|
175
|
+
d7: { cachedTokens: 0, totalInputTokens: 0, hitRatioPct: 0, savingsUsd: 0 },
|
|
176
|
+
d30: { cachedTokens: 0, totalInputTokens: 0, hitRatioPct: 0, savingsUsd: 0 },
|
|
177
|
+
byModel: {},
|
|
178
|
+
byRef: {},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const makeDayKeys = (count) => {
|
|
183
|
+
const keys = [];
|
|
184
|
+
for (let i = 0; i < count; i++) {
|
|
185
|
+
keys.push(new Date(now - i * DAY_MS).toISOString().slice(0, 10));
|
|
186
|
+
}
|
|
187
|
+
return keys;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const d1 = sumWindow(pc.days, makeDayKeys(1));
|
|
191
|
+
const d7 = sumWindow(pc.days, makeDayKeys(7));
|
|
192
|
+
const d30 = sumWindow(pc.days, makeDayKeys(30));
|
|
193
|
+
|
|
194
|
+
const allHitRatio = pc.totalInputTokens > 0
|
|
195
|
+
? Math.round((pc.cachedTokens / pc.totalInputTokens) * 1000) / 10
|
|
196
|
+
: 0;
|
|
197
|
+
|
|
198
|
+
const byModel = {};
|
|
199
|
+
if (pc.byModel) {
|
|
200
|
+
for (const [m, e] of pc.byModel.entries()) {
|
|
201
|
+
byModel[m] = {
|
|
202
|
+
cachedTokens: e.cachedTokens,
|
|
203
|
+
totalInputTokens: e.totalInputTokens,
|
|
204
|
+
hitRatioPct: e.totalInputTokens > 0 ? Math.round((e.cachedTokens / e.totalInputTokens) * 1000) / 10 : 0,
|
|
205
|
+
savingsUsd: formatSavings(e.savingsUsd),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const byRef = {};
|
|
211
|
+
if (pc.byRef) {
|
|
212
|
+
for (const [r, e] of pc.byRef.entries()) {
|
|
213
|
+
byRef[r] = {
|
|
214
|
+
cachedTokens: e.cachedTokens,
|
|
215
|
+
totalInputTokens: e.totalInputTokens,
|
|
216
|
+
hitRatioPct: e.totalInputTokens > 0 ? Math.round((e.cachedTokens / e.totalInputTokens) * 1000) / 10 : 0,
|
|
217
|
+
savingsUsd: formatSavings(e.savingsUsd),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
allTime: {
|
|
224
|
+
cachedTokens: pc.cachedTokens,
|
|
225
|
+
totalInputTokens: pc.totalInputTokens,
|
|
226
|
+
hitRatioPct: allHitRatio,
|
|
227
|
+
savingsUsd: formatSavings(pc.savingsUsd),
|
|
228
|
+
},
|
|
229
|
+
d1,
|
|
230
|
+
d7,
|
|
231
|
+
d30,
|
|
232
|
+
byModel,
|
|
233
|
+
byRef,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Compact prompt cache history older than maxDays (default 30).
|
|
239
|
+
*/
|
|
240
|
+
export function compactPromptCache(pool, maxDays = 30, now = Date.now()) {
|
|
241
|
+
const days = pool?.state?.promptCache?.days;
|
|
242
|
+
if (!days) return 0;
|
|
243
|
+
const cutoffIso = new Date(now - maxDays * DAY_MS).toISOString().slice(0, 10);
|
|
244
|
+
let purged = 0;
|
|
245
|
+
for (const d of [...days.keys()]) {
|
|
246
|
+
if (d < cutoffIso) {
|
|
247
|
+
days.delete(d);
|
|
248
|
+
purged++;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return purged;
|
|
252
|
+
}
|