@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 CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.69 - 2026-10-19
4
+
5
+ ### Features
6
+ - **Automated Clone Route Lifecycle & Settings Synchronization (#502)**:
7
+ - Added `lib/clone-lifecycle.js` orchestrating automatic configuration, key pools, and parameter synchronization for auxiliary clone routes.
8
+ - Cloned worker routes dynamically inherit primary model, temperature, top-p, context window limits, and token buckets while isolating per-key rotation states.
9
+ - Supported graceful route drain via `key-drain.js` (`getInFlight` & `getActive`), ensuring active in-flight requests finish cleanly before clone route teardown.
10
+
11
+ ### Bug Fixes
12
+ - **Prompt Cache Periodic Maintenance Sweep (#511)**:
13
+ - Wired `compactPromptCache(pool, 30, now)` into the plugin's periodic maintenance sweep alongside `compactUsage`.
14
+ - Automatically purges prompt cache telemetry entries older than 30 days to prevent unbound memory growth during continuous production uptime.
15
+
16
+ ### Refactoring & Code Quality
17
+ - **Core File Modularization (< 600 Lines Standard) (#512)**:
18
+ - Decomposed oversized monolithic modules to comply with `dsh-plugin-authoring` and `dsh-plugin-preflight` standards.
19
+ - Extracted key selection algorithms and sticky session routing from `lib/pool.js` into `lib/pool-selection.js` (bringing `pool.js` down from 654 to 521 lines).
20
+ - Extracted streaming success telemetry and proactive limit detection from `lib/rotate.js` into `lib/stream-metrics.js` and deduplicated cascade fallbacks (bringing `rotate.js` down from 716 to 595 lines).
21
+ - Decomposed monolithic UI card component `lib/client-src/50-card-main.js` (2,141 lines) into modular fragments: `50-card-main.js` (550 lines), `51-card-pool-mutators.js` (501 lines), `52-card-pool-rows.js` (590 lines), and `53-card-dialogs-and-render.js` (500 lines), ensuring 100% deterministic compilation and <600 lines for every source fragment.
22
+
23
+ ## 0.8.68 - 2026-10-19
24
+
25
+ ### Features
26
+ - **24h Error Breakdown & Load Distribution Analytics (#505)**:
27
+ - 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.
28
+ - Multi-category failure classification: categorizes upstream errors into `429 Rate Limit`, `5xx Server`, `Timeout`, `Quota`, `Auth`, and `Other`.
29
+ - Health Index indicator: calculates the percentage of requests served clean on first attempt without failovers or retries (`cleanRequests / totalRequests * 100%`).
30
+ - Cooldown recovery telemetry: computes the average recovery time (`avgCooldownRecoveryMs`) from recent failure and recovery events.
31
+ - 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.
32
+ - **Prompt Cache Savings Dashboard & Calculator (#497)**:
33
+ - 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`).
34
+ - Financial savings engine: computes real-time USD cost savings based on provider-specific token discount tariffs (~90% discount on cache hits).
35
+ - 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.
36
+ - Settings Card Widget: compact «⚡ Prompt Cache Savings» card displaying 24h, 7d, and 30d cache hit tokens, effective cache hit ratio (%), and cumulative dollar savings.
37
+ - Telemetry & reporting: exposes prompt cache summaries and 24h analytics in `/dsh-key-rotation/status` and `/dsh-key-rotation/usage`.
38
+
3
39
  ## 0.8.67 - 2026-10-19
4
40
 
5
41
  ### 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,199 @@
1
+ // lib/clone-lifecycle.js — automated clone route lifecycle and settings synchronization (#502).
2
+ // Ensures virtual clone providers (provider-2, provider-3) automatically copy and sync
3
+ // baseUrl, headers, timeout, and models from the base provider, and graceful draining
4
+ // on key reduction before deregistering routes.
5
+
6
+ import { markKeyDraining, cancelKeyDrain, getDrainingStatus, isKeyDraining } from './key-drain.js';
7
+
8
+ export const CLONE_SYNC_FIELDS = [
9
+ 'baseUrl',
10
+ 'baseURL',
11
+ 'headers',
12
+ 'timeout',
13
+ 'systemPrompt',
14
+ 'customHeaders',
15
+ 'models',
16
+ 'protocol',
17
+ 'apiType',
18
+ ];
19
+
20
+ /**
21
+ * Extracts syncable configuration from a base provider profile.
22
+ * @param {object} baseProfile
23
+ * @returns {object}
24
+ */
25
+ export function extractSyncableConfig(baseProfile) {
26
+ if (!baseProfile || typeof baseProfile !== 'object') return {};
27
+ const syncable = {};
28
+ for (const field of CLONE_SYNC_FIELDS) {
29
+ if (baseProfile[field] !== undefined) {
30
+ syncable[field] = structuredClone(baseProfile[field]);
31
+ }
32
+ }
33
+ return syncable;
34
+ }
35
+
36
+ /**
37
+ * Copies base provider configuration onto each clone profile.
38
+ * @param {object} baseProfile
39
+ * @param {object} cloneProfile
40
+ * @returns {object} merged clone profile
41
+ */
42
+ export function syncCloneProfile(baseProfile, cloneProfile = {}) {
43
+ const syncable = extractSyncableConfig(baseProfile);
44
+ const out = {
45
+ ...cloneProfile,
46
+ ...syncable,
47
+ isClone: true,
48
+ };
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Synchronizes clone providers with their base provider settings.
54
+ * @param {object} opts
55
+ * @param {string} opts.baseProvider
56
+ * @param {Set<string>|string[]} opts.cloneIds
57
+ * @param {object} opts.profiles live provider profiles map from settings
58
+ * @param {Function} [opts.onUpdate] callback when clone is updated
59
+ * @returns {{ updated: string[], cloned: Record<string, object> }}
60
+ */
61
+ export function synchronizeClones({ baseProvider, cloneIds, profiles, onUpdate }) {
62
+ if (!baseProvider || !profiles || typeof profiles !== 'object') {
63
+ return { updated: [], cloned: {} };
64
+ }
65
+
66
+ const baseProfile = profiles[baseProvider];
67
+ if (!baseProfile) {
68
+ return { updated: [], cloned: {} };
69
+ }
70
+
71
+ const updated = [];
72
+ const cloned = {};
73
+ const ids = cloneIds instanceof Set ? [...cloneIds] : (Array.isArray(cloneIds) ? cloneIds : []);
74
+
75
+ for (const cloneId of ids) {
76
+ if (!cloneId.startsWith(`${baseProvider}-`)) continue;
77
+ const existing = profiles[cloneId] || {};
78
+ const synced = syncCloneProfile(baseProfile, existing);
79
+ cloned[cloneId] = synced;
80
+ updated.push(cloneId);
81
+ if (typeof onUpdate === 'function') {
82
+ onUpdate(cloneId, synced);
83
+ }
84
+ }
85
+
86
+ return { updated, cloned };
87
+ }
88
+
89
+ /**
90
+ * Manages clone draining lifecycle when key count is reduced.
91
+ * Clones whose keys are removed are marked as draining, allowed to complete
92
+ * active in-flight streams, and safely deregistered only once in-flight reaches zero.
93
+ */
94
+ export class CloneLifecycleManager {
95
+ /**
96
+ * @param {object} opts
97
+ * @param {object} opts.concurrencyTracker
98
+ * @param {object} [opts.logger]
99
+ */
100
+ constructor({ concurrencyTracker, logger } = {}) {
101
+ this.concurrencyTracker = concurrencyTracker;
102
+ this.logger = logger || console;
103
+ this.drainingClones = new Map(); // cloneId -> { provider, ref, startedAt, timer }
104
+ }
105
+
106
+ /**
107
+ * Evaluates previous vs current expected clones for a provider.
108
+ * If a clone was removed, marks it draining instead of instantly destroying it.
109
+ * @param {string} provider
110
+ * @param {Set<string>|string[]} prevClones
111
+ * @param {Set<string>|string[]} currentClones
112
+ * @param {Function} onDeregister callback when draining completes and route is safe to remove
113
+ */
114
+ reconcileClones(provider, prevClones, currentClones, onDeregister) {
115
+ const prevSet = prevClones instanceof Set ? prevClones : new Set(prevClones || []);
116
+ const currSet = currentClones instanceof Set ? currentClones : new Set(currentClones || []);
117
+
118
+ for (const cloneId of prevSet) {
119
+ if (!currSet.has(cloneId)) {
120
+ this.startDrain(cloneId, provider, onDeregister);
121
+ }
122
+ }
123
+
124
+ for (const cloneId of currSet) {
125
+ if (this.drainingClones.has(cloneId)) {
126
+ this.cancelDrain(cloneId);
127
+ }
128
+ }
129
+ }
130
+
131
+ startDrain(cloneId, provider, onDeregister) {
132
+ if (this.drainingClones.has(cloneId)) return;
133
+
134
+ markKeyDraining(cloneId, provider);
135
+ const entry = {
136
+ cloneId,
137
+ provider,
138
+ startedAt: Date.now(),
139
+ onDeregister,
140
+ };
141
+ this.drainingClones.set(cloneId, entry);
142
+
143
+ if (this.logger?.info) {
144
+ this.logger.info(`[dsh-key-rotation] clone ${cloneId} (${provider}) marked draining; awaiting in-flight stream completion`);
145
+ }
146
+
147
+ this._checkDrainCompletion(cloneId);
148
+ }
149
+
150
+ _checkDrainCompletion(cloneId) {
151
+ const entry = this.drainingClones.get(cloneId);
152
+ if (!entry) return;
153
+
154
+ const inFlight = this.concurrencyTracker && typeof this.concurrencyTracker.getActive === 'function'
155
+ ? this.concurrencyTracker.getActive(cloneId)
156
+ : (this.concurrencyTracker && typeof this.concurrencyTracker.getInFlight === 'function'
157
+ ? this.concurrencyTracker.getInFlight(cloneId)
158
+ : 0);
159
+
160
+ if (inFlight === 0) {
161
+ this.drainingClones.delete(cloneId);
162
+ cancelKeyDrain(cloneId);
163
+ if (this.logger?.info) {
164
+ this.logger.info(`[dsh-key-rotation] clone ${cloneId} (${entry.provider}) drained cleanly; safe to deregister`);
165
+ }
166
+ if (typeof entry.onDeregister === 'function') {
167
+ entry.onDeregister(cloneId, entry.provider);
168
+ }
169
+ } else {
170
+ // Re-check after 500ms
171
+ setTimeout(() => this._checkDrainCompletion(cloneId), 500).unref?.();
172
+ }
173
+ }
174
+
175
+ cancelDrain(cloneId) {
176
+ this.drainingClones.delete(cloneId);
177
+ cancelKeyDrain(cloneId);
178
+ }
179
+
180
+ isDraining(cloneId) {
181
+ return this.drainingClones.has(cloneId) || isKeyDraining(cloneId);
182
+ }
183
+
184
+ getDrainingList() {
185
+ return Array.from(this.drainingClones.entries()).map(([cloneId, data]) => ({
186
+ cloneId,
187
+ provider: data.provider,
188
+ startedAt: data.startedAt,
189
+ elapsedMs: Date.now() - data.startedAt,
190
+ }));
191
+ }
192
+
193
+ destroy() {
194
+ for (const cloneId of this.drainingClones.keys()) {
195
+ cancelKeyDrain(cloneId);
196
+ }
197
+ this.drainingClones.clear();
198
+ }
199
+ }
package/lib/index.js CHANGED
@@ -22,6 +22,8 @@ import { ConcurrencyTracker } from './concurrency.js';
22
22
  import { QuotaStore } from './quota.js';
23
23
  import { WebhookSender, AlertDebouncer } from './webhook.js';
24
24
  import { compactUsage } from './usage-report.js';
25
+ import { compactPromptCache } from './prompt-cache.js';
26
+ import { CloneLifecycleManager, synchronizeClones } from './clone-lifecycle.js';
25
27
  import { json, handleConfigBridge } from './http-bridge.js';
26
28
  import { createRotate } from './rotate.js';
27
29
  import { initializePoolState } from './pool-state.js';
@@ -200,6 +202,8 @@ export function apply(ctx, config = {}) {
200
202
  const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
201
203
  const moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
202
204
  const trafficSmoother = new TrafficSmoother({ maxQueueWaitMs: 800 });
205
+ const cloneLifecycle = new CloneLifecycleManager({ concurrencyTracker, logger });
206
+ ctx.effect(() => () => { cloneLifecycle.destroy(); }, 'dsh-key-rotation: clone lifecycle cleanup');
203
207
 
204
208
  const sandboxService = createSandboxService({ getRuntime: () => buildRuntime() });
205
209
  const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
@@ -243,6 +247,7 @@ export function apply(ctx, config = {}) {
243
247
  if (n > 0) logger.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
244
248
  for (const pool of (runtime.pools ?? runtime.poolByRef.values())) {
245
249
  compactUsage(pool, 30, now);
250
+ compactPromptCache(pool, 30, now);
246
251
  }
247
252
  checkBudgetAndHealthAlerts({
248
253
  runtime,
@@ -342,6 +347,7 @@ export function apply(ctx, config = {}) {
342
347
  moduleBreaker,
343
348
  lowHealthNotifiedAt,
344
349
  budgetNotifiedAt,
350
+ cloneLifecycle,
345
351
  });
346
352
 
347
353
  concurrencyTracker.configure({ limit: concurrencyLimit });
package/lib/key-drain.js CHANGED
@@ -1,4 +1,4 @@
1
- // lib/key-drain.js — Managed key draining and graceful replacement (#463).
1
+ // lib/key-drain.js — Managed key draining and graceful replacement (#463, #502).
2
2
  // Allows draining an old key ref (no new dispatches assigned) while letting
3
3
  // in-flight active streams complete cleanly before permanent removal.
4
4
 
@@ -27,9 +27,11 @@ export function cancelKeyDrain(ref) {
27
27
  export function getDrainingStatus(ref, concurrencyTracker) {
28
28
  if (!ref || !drainingKeys.has(ref)) return null;
29
29
  const entry = drainingKeys.get(ref);
30
- const inFlight = concurrencyTracker && typeof concurrencyTracker.getInFlight === 'function'
31
- ? concurrencyTracker.getInFlight(ref)
32
- : 0;
30
+ const inFlight = concurrencyTracker && typeof concurrencyTracker.getActive === 'function'
31
+ ? concurrencyTracker.getActive(ref)
32
+ : (concurrencyTracker && typeof concurrencyTracker.getInFlight === 'function'
33
+ ? concurrencyTracker.getInFlight(ref)
34
+ : 0);
33
35
 
34
36
  return {
35
37
  ref,
@@ -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
+ }
@@ -1,5 +1,12 @@
1
1
  // lib/ops-config-io.js — full configuration export and import (#504).
2
2
  import crypto from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ let PLUGIN_VERSION = '0.8.68';
6
+ try {
7
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
8
+ if (pkg.version) PLUGIN_VERSION = pkg.version;
9
+ } catch (_) { /* fallback */ }
3
10
  import { json, readJson, isTrustedBridgeRequest, NS } from './http-bridge.js';
4
11
  import { CONFIG_EXPORT_PATH, CONFIG_IMPORT_PATH, IMPORT_PATH } from './ops-paths.js';
5
12
  import { keyTail } from './pool.js';
@@ -172,7 +179,7 @@ export function registerConfigIoRoutes(ctx, deps) {
172
179
  const encryptedPayload = encryptConfig(JSON.stringify(exportData), passphrase);
173
180
  json(res, 200, {
174
181
  ok: true,
175
- version: '0.8.67',
182
+ version: PLUGIN_VERSION,
176
183
  exportedAt: new Date().toISOString(),
177
184
  masked,
178
185
  encrypted: true,
@@ -183,7 +190,7 @@ export function registerConfigIoRoutes(ctx, deps) {
183
190
 
184
191
  json(res, 200, {
185
192
  ok: true,
186
- version: '0.8.67',
193
+ version: PLUGIN_VERSION,
187
194
  exportedAt: new Date().toISOString(),
188
195
  masked,
189
196
  encrypted: false,
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 ?? ''}`);
@@ -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({ provider: pool.base, rows: usageRows(pool, days, now) });
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"' });
@@ -248,6 +248,7 @@ export function cleanupRemovedProviders({
248
248
  moduleBreaker,
249
249
  lowHealthNotifiedAt,
250
250
  budgetNotifiedAt,
251
+ cloneLifecycle,
251
252
  }) {
252
253
  const live = Array.isArray(pools) ? pools : [...(pools?.values?.() ?? (poolByRef?.values?.() ? [...poolByRef.values()] : []))];
253
254
  const liveBases = new Set(live.map((p) => p.base));
@@ -281,4 +282,11 @@ export function cleanupRemovedProviders({
281
282
  }
282
283
  }
283
284
  }
285
+ if (cloneLifecycle && typeof cloneLifecycle.reconcileClones === 'function') {
286
+ for (const p of cfg.providers ?? []) {
287
+ const liveClones = new Set([...expectedClones].filter(c => c.startsWith(`${p.provider}-`)));
288
+ cloneLifecycle.reconcileClones(p.provider, poolState.get(`${p.provider}:clones`) ?? [], liveClones);
289
+ poolState.set(`${p.provider}:clones`, liveClones);
290
+ }
291
+ }
284
292
  }