@goodandready/dsh-key-rotation 0.8.67 → 0.8.68
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 +16 -0
- package/lib/client.js +68 -1
- package/lib/ops-analytics.js +162 -0
- package/lib/ops-status.js +4 -0
- package/lib/ops-telemetry.js +8 -1
- package/lib/prompt-cache.js +252 -0
- package/lib/rotate.js +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.68 - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- **24h Error Breakdown & Load Distribution Analytics (#505)**:
|
|
7
|
+
- Rolling 24-hour analytics engine (`lib/ops-analytics.js`): tracks sliding 24-hour request outcomes, key load distribution percentages, failovers, and cascade occurrences without memory leaks via bounded ring buffers.
|
|
8
|
+
- Multi-category failure classification: categorizes upstream errors into `429 Rate Limit`, `5xx Server`, `Timeout`, `Quota`, `Auth`, and `Other`.
|
|
9
|
+
- Health Index indicator: calculates the percentage of requests served clean on first attempt without failovers or retries (`cleanRequests / totalRequests * 100%`).
|
|
10
|
+
- Cooldown recovery telemetry: computes the average recovery time (`avgCooldownRecoveryMs`) from recent failure and recovery events.
|
|
11
|
+
- Settings Card UI: renders interactive Health Index badges (`🛡️ Health Index: 98% (clean)`), 24h error breakdown pills (`429: 2`, `5xx: 1`, `Timeout: 0`), cascade counters (`🔀 Cascades: X`), and average recovery time pills.
|
|
12
|
+
- **Prompt Cache Savings Dashboard & Calculator (#497)**:
|
|
13
|
+
- Multi-provider prompt cache parser (`lib/prompt-cache.js`): extracts prefix cache hit and miss tokens across streaming and non-streaming responses from DeepSeek (`prompt_cache_hit_tokens`), Anthropic (`cache_read_input_tokens`), and OpenAI (`prompt_tokens_details.cached_tokens`).
|
|
14
|
+
- Financial savings engine: computes real-time USD cost savings based on provider-specific token discount tariffs (~90% discount on cache hits).
|
|
15
|
+
- Multi-window rolling aggregation: aggregates cached tokens, total input tokens, hit ratio (%), and dollar savings across 24h (`d1`), 7d (`d7`), and 30d (`d30`) sliding windows, plus per-model and per-key breakdowns.
|
|
16
|
+
- Settings Card Widget: compact «⚡ Prompt Cache Savings» card displaying 24h, 7d, and 30d cache hit tokens, effective cache hit ratio (%), and cumulative dollar savings.
|
|
17
|
+
- Telemetry & reporting: exposes prompt cache summaries and 24h analytics in `/dsh-key-rotation/status` and `/dsh-key-rotation/usage`.
|
|
18
|
+
|
|
3
19
|
## 0.8.67 - 2026-10-19
|
|
4
20
|
|
|
5
21
|
### Features
|
package/lib/client.js
CHANGED
|
@@ -283,6 +283,15 @@ window.__ModuleLoader__.load({
|
|
|
283
283
|
passphrasePlaceholder: 'Enter master passphrase for encryption/decryption',
|
|
284
284
|
jsonConfigPlaceholder: 'Paste JSON configuration here or choose a file...',
|
|
285
285
|
previewSummary: 'Diff: {created} new providers, {updated} updated providers, {keys} keys ({added} new).',
|
|
286
|
+
promptCacheTitle: 'Prompt Cache Savings',
|
|
287
|
+
promptCacheDesc: 'Tokens served from prefix cache across sessions and estimated cost savings.',
|
|
288
|
+
promptCacheHitRatio: 'Hit Ratio',
|
|
289
|
+
promptCacheSaved: 'saved',
|
|
290
|
+
analytics24hTitle: '24h Pool Health & Load Balance',
|
|
291
|
+
healthIndex: 'Health Index',
|
|
292
|
+
cleanRequests: 'clean',
|
|
293
|
+
errorBreakdown: '24h Errors',
|
|
294
|
+
avgRecovery: 'Avg Recovery',
|
|
286
295
|
};
|
|
287
296
|
|
|
288
297
|
const zh = {
|
|
@@ -516,6 +525,15 @@ window.__ModuleLoader__.load({
|
|
|
516
525
|
passphrasePlaceholder: '输入用于加密/解密的主密码',
|
|
517
526
|
jsonConfigPlaceholder: '在此粘贴 JSON 配置或选择文件...',
|
|
518
527
|
previewSummary: '差异: 新增 {created} 个提供商, 更新 {updated} 个提供商, 包含 {keys} 个密钥 (新增 {added} 个)。',
|
|
528
|
+
promptCacheTitle: 'Prompt 缓存节省',
|
|
529
|
+
promptCacheDesc: '会话间前缀缓存命中 Token 及预估节省成本。',
|
|
530
|
+
promptCacheHitRatio: '缓存命中率',
|
|
531
|
+
promptCacheSaved: '已节省',
|
|
532
|
+
analytics24hTitle: '24小时池健康度与负载分布',
|
|
533
|
+
healthIndex: '健康指数',
|
|
534
|
+
cleanRequests: '无重试',
|
|
535
|
+
errorBreakdown: '24小时错误',
|
|
536
|
+
avgRecovery: '平均恢复',
|
|
519
537
|
};
|
|
520
538
|
|
|
521
539
|
// Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
|
|
@@ -2482,7 +2500,55 @@ window.__ModuleLoader__.load({
|
|
|
2482
2500
|
total > 0 && keyUsages.length > 1 ? h('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
2483
2501
|
h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, '📈 ' + t('sparklineTraffic') + ':'),
|
|
2484
2502
|
h(Sparkline, { points: keyUsages.map(x => x.usage), width: 140, height: 20 })
|
|
2485
|
-
) : null
|
|
2503
|
+
) : null,
|
|
2504
|
+
(() => {
|
|
2505
|
+
const an = ps && ps.analytics24h ? ps.analytics24h : null;
|
|
2506
|
+
if (!an || an.totalRequests === 0) return null;
|
|
2507
|
+
const hColor = an.healthIndexPct >= 80 ? 'var(--dsw-alias-state-success-primary)' : an.healthIndexPct >= 50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)';
|
|
2508
|
+
return h('div', { style: { marginTop: '8px', display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '6px' } },
|
|
2509
|
+
h('span', { className: 'krot-badge', style: { color: hColor, fontWeight: 700 } }, '🛡️ ' + t('healthIndex') + ': ' + an.healthIndexPct + '% (' + an.firstAttemptSuccesses + '/' + an.totalRequests + ' ' + t('cleanRequests') + ')'),
|
|
2510
|
+
an.errorBreakdown.rateLimit > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, '429: ' + an.errorBreakdown.rateLimit) : null,
|
|
2511
|
+
an.errorBreakdown.server > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, '5xx: ' + an.errorBreakdown.server) : null,
|
|
2512
|
+
an.errorBreakdown.timeout > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, 'Timeout: ' + an.errorBreakdown.timeout) : null,
|
|
2513
|
+
an.errorBreakdown.quota > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, 'Quota: ' + an.errorBreakdown.quota) : null,
|
|
2514
|
+
an.cascades > 0 ? h('span', { className: 'krot-badge' }, '🔀 Cascades: ' + an.cascades) : null,
|
|
2515
|
+
an.avgCooldownRecoveryMs > 0 ? h('span', { className: 'krot-badge' }, '⏱️ ' + t('avgRecovery') + ': ' + Math.round(an.avgCooldownRecoveryMs / 1000) + 's') : null
|
|
2516
|
+
);
|
|
2517
|
+
})()
|
|
2518
|
+
);
|
|
2519
|
+
})();
|
|
2520
|
+
|
|
2521
|
+
const promptCacheWidget = (() => {
|
|
2522
|
+
const ps = status[entry.provider];
|
|
2523
|
+
const pc = ps && ps.promptCache ? ps.promptCache : null;
|
|
2524
|
+
if (!pc || (!pc.d30?.cachedTokens && !pc.allTime?.cachedTokens)) return null;
|
|
2525
|
+
const d1 = pc.d1 || { cachedTokens: 0, savingsUsd: 0, hitRatioPct: 0 };
|
|
2526
|
+
const d7 = pc.d7 || { cachedTokens: 0, savingsUsd: 0, hitRatioPct: 0 };
|
|
2527
|
+
const d30 = pc.d30 || { cachedTokens: 0, savingsUsd: 0, hitRatioPct: 0 };
|
|
2528
|
+
const savedStr = d30.savingsUsd > 0 ? '$' + d30.savingsUsd.toFixed(2) : '$0.00';
|
|
2529
|
+
return h('div', {
|
|
2530
|
+
className: 'krot-prompt-cache-card',
|
|
2531
|
+
style: {
|
|
2532
|
+
marginTop: '6px',
|
|
2533
|
+
marginBottom: '6px',
|
|
2534
|
+
padding: '8px 10px',
|
|
2535
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
2536
|
+
borderRadius: '6px',
|
|
2537
|
+
border: '1px solid var(--dsw-alias-border-subtle)',
|
|
2538
|
+
display: 'flex',
|
|
2539
|
+
flexDirection: 'column',
|
|
2540
|
+
gap: '4px'
|
|
2541
|
+
}
|
|
2542
|
+
},
|
|
2543
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
|
|
2544
|
+
h('span', { style: { fontWeight: 600, fontSize: '12px' } }, '⚡ ' + t('promptCacheTitle')),
|
|
2545
|
+
h('span', { className: 'krot-badge krot-badge-ok', style: { fontSize: '11px', fontWeight: 600 } }, savedStr + ' ' + t('promptCacheSaved'))
|
|
2546
|
+
),
|
|
2547
|
+
h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '10px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
2548
|
+
h('span', null, h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, '24h: '), (d1.cachedTokens || 0).toLocaleString() + ' tok (' + d1.hitRatioPct + '%)'),
|
|
2549
|
+
h('span', null, h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, '7d: '), (d7.cachedTokens || 0).toLocaleString() + ' tok (' + d7.hitRatioPct + '%)'),
|
|
2550
|
+
h('span', null, h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, '30d: '), (d30.cachedTokens || 0).toLocaleString() + ' tok (' + d30.hitRatioPct + '%)')
|
|
2551
|
+
)
|
|
2486
2552
|
);
|
|
2487
2553
|
})();
|
|
2488
2554
|
|
|
@@ -2646,6 +2712,7 @@ window.__ModuleLoader__.load({
|
|
|
2646
2712
|
),
|
|
2647
2713
|
filterBar,
|
|
2648
2714
|
loadChart,
|
|
2715
|
+
promptCacheWidget,
|
|
2649
2716
|
h('div', { className: 'krot-keys' }, keyRows),
|
|
2650
2717
|
modelPools,
|
|
2651
2718
|
h('div', { className: 'krot-foot' },
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// lib/ops-analytics.js — 24h error breakdown, load distribution, and health index analytics (#505).
|
|
2
|
+
// Pure helpers with zero dependencies.
|
|
3
|
+
|
|
4
|
+
const DAY_MS = 86400000;
|
|
5
|
+
const MAX_RING_BUFFER = 200;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Classifies an error code or message into canonical categories:
|
|
9
|
+
* 'rateLimit' | 'server' | 'timeout' | 'quota' | 'auth' | 'other'
|
|
10
|
+
*/
|
|
11
|
+
export function classifyErrorCategory(code, message = '') {
|
|
12
|
+
const c = String(code ?? '').toUpperCase();
|
|
13
|
+
const m = String(message ?? '').toLowerCase();
|
|
14
|
+
|
|
15
|
+
if (c === '429' || c.includes('RATE_LIMIT') || m.includes('rate limit') || m.includes('too many requests')) {
|
|
16
|
+
return 'rateLimit';
|
|
17
|
+
}
|
|
18
|
+
if (/^5\d\d$/.test(c) || c === 'SERVER' || c === 'BAD_GATEWAY' || c === 'SERVICE_UNAVAILABLE' || m.includes('internal server error')) {
|
|
19
|
+
return 'server';
|
|
20
|
+
}
|
|
21
|
+
if (c.includes('TIMEOUT') || c === 'ETIMEDOUT' || c === 'ESOCKETTIMEDOUT' || m.includes('timeout') || m.includes('timed out')) {
|
|
22
|
+
return 'timeout';
|
|
23
|
+
}
|
|
24
|
+
if (c === 'QUOTA' || c === '402' || c.includes('QUOTA') || m.includes('quota') || m.includes('credit') || m.includes('insufficient_quota')) {
|
|
25
|
+
return 'quota';
|
|
26
|
+
}
|
|
27
|
+
if (c === 'AUTH' || c === '401' || c === '403' || m.includes('unauthorized') || m.includes('invalid_api_key') || m.includes('forbidden')) {
|
|
28
|
+
return 'auth';
|
|
29
|
+
}
|
|
30
|
+
return 'other';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function initPoolAnalytics(pool) {
|
|
34
|
+
if (!pool.state.analytics24h) {
|
|
35
|
+
pool.state.analytics24h = {
|
|
36
|
+
requests: [], // Ring buffer of { at, ref, success, attempts, failovers, cascaded }
|
|
37
|
+
errorEvents: [], // Ring buffer of { at, category, code, ref }
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return pool.state.analytics24h;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Records a failover error encountered during key rotation.
|
|
45
|
+
*/
|
|
46
|
+
export function recordFailureCategory(pool, code, message = '', ref = '', now = Date.now()) {
|
|
47
|
+
if (!pool?.state) return;
|
|
48
|
+
const a = initPoolAnalytics(pool);
|
|
49
|
+
const category = classifyErrorCategory(code, message);
|
|
50
|
+
a.errorEvents.push({
|
|
51
|
+
at: now,
|
|
52
|
+
category,
|
|
53
|
+
code: String(code ?? 'UNKNOWN'),
|
|
54
|
+
ref: ref || pool.state.lastUsed || '',
|
|
55
|
+
});
|
|
56
|
+
if (a.errorEvents.length > MAX_RING_BUFFER) {
|
|
57
|
+
a.errorEvents.shift();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Records a completed request outcome in the pool's 24h analytics buffer.
|
|
63
|
+
*/
|
|
64
|
+
export function recordRequestOutcome(pool, outcome, now = Date.now()) {
|
|
65
|
+
if (!pool?.state) return;
|
|
66
|
+
const a = initPoolAnalytics(pool);
|
|
67
|
+
|
|
68
|
+
const entry = {
|
|
69
|
+
at: now,
|
|
70
|
+
ref: outcome.ref || pool.state.lastUsed || '',
|
|
71
|
+
success: outcome.success !== false,
|
|
72
|
+
attempts: Number(outcome.attempts) || 1,
|
|
73
|
+
failovers: Number(outcome.failovers) || 0,
|
|
74
|
+
cascaded: Boolean(outcome.cascaded),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
a.requests.push(entry);
|
|
78
|
+
if (a.requests.length > MAX_RING_BUFFER) {
|
|
79
|
+
a.requests.shift();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Computes 24h analytics snapshot for the pool.
|
|
85
|
+
*/
|
|
86
|
+
export function compute24hAnalytics(pool, now = Date.now()) {
|
|
87
|
+
const cutoff = now - DAY_MS;
|
|
88
|
+
const a = pool?.state?.analytics24h;
|
|
89
|
+
const requests = a?.requests?.filter(r => r.at >= cutoff) ?? [];
|
|
90
|
+
const errorEvents = a?.errorEvents?.filter(e => e.at >= cutoff) ?? [];
|
|
91
|
+
|
|
92
|
+
const totalRequests = requests.length;
|
|
93
|
+
let firstAttemptSuccesses = 0;
|
|
94
|
+
let cascades = 0;
|
|
95
|
+
const keyCounts = new Map();
|
|
96
|
+
const errors = {
|
|
97
|
+
rateLimit: 0,
|
|
98
|
+
server: 0,
|
|
99
|
+
timeout: 0,
|
|
100
|
+
quota: 0,
|
|
101
|
+
auth: 0,
|
|
102
|
+
other: 0,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
for (const r of requests) {
|
|
106
|
+
if (r.ref) {
|
|
107
|
+
keyCounts.set(r.ref, (keyCounts.get(r.ref) ?? 0) + 1);
|
|
108
|
+
}
|
|
109
|
+
if (r.success && r.attempts <= 1 && r.failovers === 0) {
|
|
110
|
+
firstAttemptSuccesses++;
|
|
111
|
+
}
|
|
112
|
+
if (r.cascaded) {
|
|
113
|
+
cascades++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const ev of errorEvents) {
|
|
118
|
+
if (ev.category && errors[ev.category] != null) {
|
|
119
|
+
errors[ev.category]++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Calculate load distribution across pool refs
|
|
124
|
+
const refs = pool?.refs ?? [...keyCounts.keys()];
|
|
125
|
+
const loadDistribution = refs.map(ref => {
|
|
126
|
+
const count = keyCounts.get(ref) ?? 0;
|
|
127
|
+
const pct = totalRequests > 0 ? Math.round((count / totalRequests) * 100) : 0;
|
|
128
|
+
return { ref, count, pct };
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const totalErrors = Object.values(errors).reduce((sum, n) => sum + n, 0);
|
|
132
|
+
|
|
133
|
+
// Compute average cooldown recovery time from pool.state.events
|
|
134
|
+
let cooldownTotalMs = 0;
|
|
135
|
+
let cooldownCount = 0;
|
|
136
|
+
const events = pool?.state?.events ?? [];
|
|
137
|
+
for (const ev of events) {
|
|
138
|
+
if (ev.at >= cutoff && ev.type === 'fail' && Number.isFinite(ev.cooldownMs) && ev.cooldownMs > 0) {
|
|
139
|
+
cooldownTotalMs += ev.cooldownMs;
|
|
140
|
+
cooldownCount++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const avgCooldownRecoveryMs = cooldownCount > 0 ? Math.round(cooldownTotalMs / cooldownCount) : 0;
|
|
144
|
+
|
|
145
|
+
// Health index: % of requests handled without retries or failover
|
|
146
|
+
const healthIndexPct = totalRequests > 0
|
|
147
|
+
? Math.round((firstAttemptSuccesses / totalRequests) * 100)
|
|
148
|
+
: 100;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
totalRequests,
|
|
152
|
+
firstAttemptSuccesses,
|
|
153
|
+
healthIndexPct,
|
|
154
|
+
loadDistribution,
|
|
155
|
+
errorBreakdown: {
|
|
156
|
+
...errors,
|
|
157
|
+
total: totalErrors,
|
|
158
|
+
},
|
|
159
|
+
cascades: Math.max(cascades, pool?.state?.cascadeCount ?? 0),
|
|
160
|
+
avgCooldownRecoveryMs,
|
|
161
|
+
};
|
|
162
|
+
}
|
package/lib/ops-status.js
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
import { bucketInfo, tpmInfo } from './bucket.js';
|
|
18
18
|
import { isBlockedUntilReset } from './quota-window.js';
|
|
19
19
|
import { sanitizeSnapshot } from './sanitize-snapshot.js';
|
|
20
|
+
import { compute24hAnalytics } from './ops-analytics.js';
|
|
21
|
+
import { getPromptCacheSummary } from './prompt-cache.js';
|
|
20
22
|
import {
|
|
21
23
|
STATUS_PATH,
|
|
22
24
|
HEALTH_PATH,
|
|
@@ -177,6 +179,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
177
179
|
routingStrategy: pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin',
|
|
178
180
|
proactiveRateLimitGuard: pool.proactiveRateLimitGuard ?? runtime.proactiveRateLimitGuard ?? true,
|
|
179
181
|
smoothedRequests: pool.state.smoothedRequests ?? 0,
|
|
182
|
+
promptCache: getPromptCacheSummary(pool, now),
|
|
183
|
+
analytics24h: compute24hAnalytics(pool, now),
|
|
180
184
|
});
|
|
181
185
|
} catch (e) {
|
|
182
186
|
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
|
package/lib/ops-telemetry.js
CHANGED
|
@@ -21,6 +21,8 @@ import { simulateRouting } from './simulator.js';
|
|
|
21
21
|
import { getSelectionTraces, clearSelectionTraces } from './selection-trace.js';
|
|
22
22
|
import { findSecrets } from './keycheck.js';
|
|
23
23
|
import { mutateSettings, sectionOps } from './settings-write.js';
|
|
24
|
+
import { compute24hAnalytics } from './ops-analytics.js';
|
|
25
|
+
import { getPromptCacheSummary } from './prompt-cache.js';
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* @param {object} ctx cordis context
|
|
@@ -68,7 +70,12 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
68
70
|
if (seen.has(pool.base)) continue;
|
|
69
71
|
seen.add(pool.base);
|
|
70
72
|
if (provider && pool.base !== provider && pool.provider !== provider) continue;
|
|
71
|
-
report.push({
|
|
73
|
+
report.push({
|
|
74
|
+
provider: pool.base,
|
|
75
|
+
rows: usageRows(pool, days, now),
|
|
76
|
+
promptCache: getPromptCacheSummary(pool, now),
|
|
77
|
+
analytics24h: compute24hAnalytics(pool, now),
|
|
78
|
+
});
|
|
72
79
|
}
|
|
73
80
|
if (csv) {
|
|
74
81
|
res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="dsh-key-rotation-usage.csv"' });
|
|
@@ -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
|
+
}
|
package/lib/rotate.js
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
|
|
37
37
|
import { LOCAL_POOL_EXHAUSTED_CODE } from './resolver.js';
|
|
38
38
|
import { extractProviderLimits } from './provider-limits-detector.js';
|
|
39
|
+
import { recordPromptCache } from './prompt-cache.js';
|
|
40
|
+
import { recordRequestOutcome, recordFailureCategory } from './ops-analytics.js';
|
|
39
41
|
|
|
40
42
|
export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
|
|
41
43
|
|
|
@@ -479,6 +481,7 @@ export function createRotate(deps) {
|
|
|
479
481
|
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
480
482
|
if (switchable) {
|
|
481
483
|
penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
|
|
484
|
+
recordFailureCategory(pool, code ?? 'UNKNOWN', message, activeRef, now());
|
|
482
485
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
483
486
|
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
484
487
|
pool.state.lastSwitchAt = now();
|
|
@@ -537,6 +540,14 @@ export function createRotate(deps) {
|
|
|
537
540
|
if (activeRef) {
|
|
538
541
|
recordSuccess(pool, activeRef, now());
|
|
539
542
|
if (sessionId) setSessionSticky(pool, sessionId, activeRef);
|
|
543
|
+
recordPromptCache(pool, activeRef, options.model, chunk?.usage ?? chunk?.metadata?.usage, Date.now());
|
|
544
|
+
recordRequestOutcome(pool, {
|
|
545
|
+
ref: activeRef,
|
|
546
|
+
success: true,
|
|
547
|
+
attempts: attempt + 1,
|
|
548
|
+
failovers: attempt,
|
|
549
|
+
cascaded: Boolean(options.__isCascade),
|
|
550
|
+
}, Date.now());
|
|
540
551
|
}
|
|
541
552
|
if (activeRef) {
|
|
542
553
|
const usedTokens = extractUsageTokens(chunk?.usage ?? chunk?.metadata?.usage);
|
|
@@ -610,6 +621,7 @@ export function createRotate(deps) {
|
|
|
610
621
|
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
611
622
|
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
612
623
|
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
|
|
624
|
+
recordFailureCategory(pool, e?.code ?? 'TRANSPORT', String(e?.message ?? e), activeRef, now());
|
|
613
625
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
614
626
|
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
615
627
|
pool.state.lastSwitchAt = now();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.68",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|