@goodandready/dsh-key-rotation 0.8.66 → 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 +33 -0
- package/README.md +4 -0
- package/README.ru.md +4 -0
- package/README.zh.md +4 -0
- package/lib/client.js +366 -20
- package/lib/ops-analytics.js +162 -0
- package/lib/ops-config-io.js +325 -0
- package/lib/ops-paths.js +3 -0
- package/lib/ops-quick-action.js +167 -0
- package/lib/ops-status.js +6 -0
- package/lib/ops-telemetry.js +8 -1
- package/lib/prompt-cache.js +252 -0
- package/lib/rotate.js +12 -0
- package/lib/routes-ops.js +7 -3
- package/package.json +1 -1
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/lib/routes-ops.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { registerBalanceRoutes } from './ops-balance.js';
|
|
3
|
-
// lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312).
|
|
1
|
+
// lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312/#504/#499).
|
|
4
2
|
// Thin orchestrator: each route family lives in its own lib/ops-*.js module.
|
|
5
3
|
// Registration is injected with the live apply() dependencies.
|
|
6
4
|
|
|
@@ -9,6 +7,10 @@ import { registerTelemetryRoutes } from './ops-telemetry.js';
|
|
|
9
7
|
import { registerKeyRoutes } from './ops-keys.js';
|
|
10
8
|
import { registerTestRoutes } from './ops-test.js';
|
|
11
9
|
import { registerWebhookActionRoute } from './ops-webhook.js';
|
|
10
|
+
import { registerMetricsRoutes } from './ops-metrics.js';
|
|
11
|
+
import { registerBalanceRoutes } from './ops-balance.js';
|
|
12
|
+
import { registerConfigIoRoutes } from './ops-config-io.js';
|
|
13
|
+
import { registerQuickActionRoutes } from './ops-quick-action.js';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* @param {object} ctx cordis context
|
|
@@ -22,4 +24,6 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
22
24
|
registerWebhookActionRoute(ctx, deps);
|
|
23
25
|
registerMetricsRoutes(ctx, deps);
|
|
24
26
|
registerBalanceRoutes(ctx, deps);
|
|
27
|
+
registerConfigIoRoutes(ctx, deps);
|
|
28
|
+
registerQuickActionRoutes(ctx, deps);
|
|
25
29
|
}
|
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": [
|