@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.
@@ -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
- export const MAX_SESSION_STICKY_ENTRIES = 500;
2
-
3
- export function setSessionSticky(pool, sessionId, candidate) {
4
- if (!sessionId || !candidate || !pool?.state) return;
5
- if (!(pool.state.sessionSticky instanceof Map)) {
6
- pool.state.sessionSticky = new Map();
7
- }
8
- if (pool.state.sessionSticky.has(sessionId)) {
9
- pool.state.sessionSticky.delete(sessionId);
10
- } else if (pool.state.sessionSticky.size >= MAX_SESSION_STICKY_ENTRIES) {
11
- const oldest = pool.state.sessionSticky.keys().next().value;
12
- if (oldest !== undefined) pool.state.sessionSticky.delete(oldest);
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
+ }