@goodandready/dsh-key-rotation 0.8.40 → 0.8.42
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 +41 -0
- package/lib/budget-monitor.js +2 -1
- package/lib/cascade.js +6 -1
- package/lib/client.js +20 -20
- package/lib/index.js +2 -0
- package/lib/lifecycle.js +3 -1
- package/lib/ops-keys.js +35 -13
- package/lib/ops-metrics.js +8 -1
- package/lib/ops-status.js +6 -5
- package/lib/ops-telemetry.js +3 -2
- package/lib/ops-test.js +5 -2
- package/lib/ops-webhook.js +38 -12
- package/lib/resolver.js +0 -4
- package/lib/rotate.js +25 -2
- package/lib/webhook.js +13 -4
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.42 - 2026-10-01
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Telemetry and budget monitor model pool coverage (#417)**:
|
|
7
|
+
- Fixed `/dsh-key-rotation/usage` export and background alert monitoring (`budget-monitor.js`) to iterate over `runtime.pools` instead of `poolByRef.values()`.
|
|
8
|
+
- Ensures model pools sharing physical credential refs (`provider::model`) are included in usage reports, CSV exports, and low-health/SLO alerts without double-counting aggregate credential totals.
|
|
9
|
+
- **Exposed circuit breaker state and notifyQueue status (#418)**:
|
|
10
|
+
- Passed `notifyQueue` to operational routes and attached `breaker` and `notifyQueue` to `buildRuntime()`.
|
|
11
|
+
- `/dsh-key-rotation/status` now reports `circuit` state (`open`, `half-open`, `closed`) per provider and includes `notifyQueue` pending/sent metrics in `meta`.
|
|
12
|
+
- **Bounded Telegram callback_data payload size (#419)**:
|
|
13
|
+
- Updated `formatInteractive` in `lib/webhook.js` so Telegram inline button `callback_data` payloads are strictly <= 64 bytes and omit raw master `actionToken`.
|
|
14
|
+
- Prevents Telegram API rejection (`BUTTON_DATA_INVALID`) while retaining Telegram `secret_token` header authentication and callback acknowledgement.
|
|
15
|
+
- **Structured key eligibility in Settings UI (#420)**:
|
|
16
|
+
- Updated `keyStatus` in `lib/client-src/50-card-main.js` to return structured eligibility flags (`ready`, `paused`, `revoked`, `expired`, `missing`, `cooling`).
|
|
17
|
+
- Settings card ready counters, header summaries, and ready filter pills no longer miscount paused, revoked, expired, or missing keys as ready. `optimisticReset` state is cleared upon receiving server status updates.
|
|
18
|
+
- **Single root UpdaterSection component (#421)**:
|
|
19
|
+
- Relocated `UpdaterSection` out of per-provider card row loops to the main card root container in `lib/client-src/50-card-main.js`.
|
|
20
|
+
- Ensures exactly one updater section is rendered regardless of provider count (0, 1, or N), enabling update checks even with 0 pools configured while eliminating duplicate GET requests.
|
|
21
|
+
- **Quota-aware Prometheus active_keys metric (#423)**:
|
|
22
|
+
- Updated Prometheus metrics exporter in `lib/ops-metrics.js` to verify model token quota availability when calculating `dsh_key_rotation_active_keys`.
|
|
23
|
+
- Added new `dsh_key_rotation_quota_exhausted_keys` gauge metric, aligning exporter readiness metrics with `/dsh-key-rotation/health`.
|
|
24
|
+
|
|
25
|
+
## 0.8.41 - 2026-10-01
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **Reproducible CI and core compatibility matrix (#424)**:
|
|
29
|
+
- Added explicit test `devDependencies` (`@deepseek-ai/schemastery`, `@deepseek-ai/cordis`, `@deepseek-ai/dsh-llm`, `semver`) to `package.json` to ensure clean checkouts (`npm ci`) reproduce all unit tests without depending on global or ambient node_modules.
|
|
30
|
+
- Updated modern core detection in `test/support/real-core.mjs` to properly identify DSH `0.2.0-rc.1`, `0.2.0-rc.2`, and future releases as modern loaders rather than misclassifying them as legacy.
|
|
31
|
+
- **Preserved exponential backoff across credential selection (#410)**:
|
|
32
|
+
- Removed premature deletion of `failCounts`, `authFailCounts`, and `brokenUntil` during resolver selection in `lib/resolver.js` (`settle()`).
|
|
33
|
+
- Quarantine backoff state is now strictly retained until upstream requests finish successfully via `recordSuccess()`, preventing flapping keys from resetting to first-level retry backoff on every dispatch.
|
|
34
|
+
- **Cascade failover on open circuit breaker (#413)**:
|
|
35
|
+
- When a primary provider's circuit breaker is open (`breaker.acquire()` returns null), `lib/rotate.js` now evaluates `pickCascadeFallback` before terminating, seamlessly routing traffic to healthy fallback providers.
|
|
36
|
+
- `pickCascadeFallback` in `lib/cascade.js` now validates circuit breaker state, skipping fallback providers whose circuits are open and failing closed with `CIRCUIT_OPEN` only when all cascade options are exhausted.
|
|
37
|
+
- **Plaintext secret decryption for diagnostic probes and self-healing (#415)**:
|
|
38
|
+
- Added `decryptSecret` decryption to `lib/ops-test.js` (`/dsh-key-rotation/test` route) and `lib/lifecycle.js` (`autoUnbreakBrokenKeys`), preventing encrypted ciphertext credentials (`enc:v1:...`) from being sent to upstream LLM probe endpoints.
|
|
39
|
+
- Probe success now cleanses `revokedRefs` alongside `failedUntil` and `failCounts`.
|
|
40
|
+
- **Fan-out reset across all model pools and shared credentials (#416)**:
|
|
41
|
+
- Key reset (`POST /dsh-key-rotation/reset` with `ref`) now fans out across ALL pools in `poolState` containing the ref rather than halting after the first match, clearing `failedUntil`, `failCounts`, `authFailCounts`, `brokenUntil`, `tokenUsage`, and `revokedRefs`.
|
|
42
|
+
- Provider reset (`provider`) and webhook actions (`pause-*`, `reset-*`) now fan out across base pools and all model sub-pools (`provider::*`), resetting circuit breakers and all associated quotas.
|
|
43
|
+
|
|
3
44
|
## 0.8.40 - 2026-09-30
|
|
4
45
|
|
|
5
46
|
### Fixed
|
package/lib/budget-monitor.js
CHANGED
|
@@ -55,7 +55,8 @@ export function checkBudgetAndHealthAlerts({
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const seen = new Set();
|
|
58
|
-
|
|
58
|
+
const poolsToScan = runtime.pools ?? runtime.poolByRef.values();
|
|
59
|
+
for (const pool of poolsToScan) {
|
|
59
60
|
if (seen.has(pool.base)) continue;
|
|
60
61
|
seen.add(pool.base);
|
|
61
62
|
// #207: keys expiring within expiryWarnDays -> one webhook per key/day
|
package/lib/cascade.js
CHANGED
|
@@ -5,7 +5,7 @@ import { isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
|
|
|
5
5
|
|
|
6
6
|
export const CASCADE_MAX_DEPTH = 3;
|
|
7
7
|
|
|
8
|
-
export function pickCascadeFallback(provider, cfg, pools, currentModel, modelPoolByProvider) {
|
|
8
|
+
export function pickCascadeFallback(provider, cfg, pools, currentModel, modelPoolByProvider, circuitBreaker) {
|
|
9
9
|
const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
|
|
10
10
|
const modelPools = modelPoolByProvider || cfg?.modelPoolByProvider || cfg?.index?.modelPoolByProvider || null;
|
|
11
11
|
const provPools = pools || cfg?.providerToPool || cfg?.index?.providerToPool || null;
|
|
@@ -14,6 +14,11 @@ export function pickCascadeFallback(provider, cfg, pools, currentModel, modelPoo
|
|
|
14
14
|
const fb = typeof entry === 'string' ? { provider: entry } : entry;
|
|
15
15
|
if (!fb || !fb.provider || fb.provider === provider) continue;
|
|
16
16
|
|
|
17
|
+
const breaker = circuitBreaker || cfg?.circuitBreaker || null;
|
|
18
|
+
if (breaker && typeof breaker.canRequest === 'function' && !breaker.canRequest(fb.provider)) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
const mappedModel = (fb.modelMapping && currentModel && fb.modelMapping[currentModel]) || fb.model || currentModel || null;
|
|
18
23
|
let pool = null;
|
|
19
24
|
if (modelPools && mappedModel) {
|
package/lib/client.js
CHANGED
|
@@ -1789,26 +1789,27 @@ window.__ModuleLoader__.load({
|
|
|
1789
1789
|
// Key state dot: color and label read at a glance; "key not found"
|
|
1790
1790
|
// catches an env-name typo that would otherwise stay silent.
|
|
1791
1791
|
const keyStatus = (providerId, ref, isPaused = false) => {
|
|
1792
|
-
if (isPaused) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyPaused') };
|
|
1792
|
+
if (isPaused) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyPaused'), ready: false, paused: true };
|
|
1793
1793
|
const hit = keyInfo(providerId, ref);
|
|
1794
1794
|
if (!hit) return null;
|
|
1795
|
-
if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
|
|
1796
|
-
if (hit.
|
|
1797
|
-
|
|
1798
|
-
const warnDays = Number(val?.expiryWarnDays) || 7; // #207: configurable horizon
|
|
1799
|
-
if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
|
|
1800
|
-
}
|
|
1801
|
-
if (hit.broken || hit.revoked || hit.status === 'revoked') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyRevoked') };
|
|
1802
|
-
if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
|
|
1803
|
-
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
|
|
1795
|
+
if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired'), ready: false, expired: true };
|
|
1796
|
+
if (hit.broken || hit.revoked || hit.status === 'revoked') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyRevoked'), ready: false, revoked: true };
|
|
1797
|
+
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing'), ready: false, missing: true };
|
|
1804
1798
|
if (hit.cooldownMsLeft > 0 && !optimisticReset[ref]) {
|
|
1805
1799
|
return {
|
|
1806
1800
|
color: 'var(--dsw-alias-state-warning-primary)',
|
|
1807
1801
|
text: t('keyCooling').replace('{s}', String(Math.ceil(hit.cooldownMsLeft / 1000))),
|
|
1802
|
+
ready: false,
|
|
1803
|
+
cooling: true,
|
|
1808
1804
|
};
|
|
1809
1805
|
}
|
|
1810
|
-
if (hit.
|
|
1811
|
-
|
|
1806
|
+
if (hit.expiresAt && !hit.expired) {
|
|
1807
|
+
const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
|
|
1808
|
+
const warnDays = Number(val?.expiryWarnDays) || 7;
|
|
1809
|
+
if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)), ready: true, expiringSoon: true };
|
|
1810
|
+
}
|
|
1811
|
+
if (hit.active) return { color: 'var(--dsw-alias-state-success-primary)', text: t('keyActive'), ready: true, active: true };
|
|
1812
|
+
return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady'), ready: true };
|
|
1812
1813
|
};
|
|
1813
1814
|
|
|
1814
1815
|
const searchInput = h('input', { className: 'krot-in', placeholder: 'Search providers…', value: search, onChange: (e) => setSearch(e.target.value), style: { marginBottom: '8px' } });
|
|
@@ -1829,19 +1830,19 @@ window.__ModuleLoader__.load({
|
|
|
1829
1830
|
for (let idx = 0; idx < keys.length; idx++) {
|
|
1830
1831
|
const k = keys[idx];
|
|
1831
1832
|
const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
|
|
1832
|
-
const isCool = (st &&
|
|
1833
|
+
const isCool = (st && st.cooling) && !optimisticReset[k];
|
|
1833
1834
|
const tr = testResult[k];
|
|
1834
1835
|
if (tr && !tr.ok) errorCount++;
|
|
1835
1836
|
if (isCool) cooldownCount++;
|
|
1836
|
-
|
|
1837
|
+
if (st && st.ready) readyCount++;
|
|
1837
1838
|
}
|
|
1838
1839
|
|
|
1839
1840
|
const filteredIndices = keys.map((k, idx) => ({ key: k, kIndex: idx })).filter(({ key: k, kIndex: idx }) => {
|
|
1840
1841
|
if (statusFilter === 'all') return true;
|
|
1841
1842
|
const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
|
|
1842
|
-
const isCool = (st &&
|
|
1843
|
+
const isCool = (st && st.cooling) && !optimisticReset[k];
|
|
1843
1844
|
const tr = testResult[k];
|
|
1844
|
-
if (statusFilter === 'ready') return
|
|
1845
|
+
if (statusFilter === 'ready') return Boolean(st && st.ready);
|
|
1845
1846
|
if (statusFilter === 'cooldown') return isCool;
|
|
1846
1847
|
if (statusFilter === 'error') return tr && !tr.ok;
|
|
1847
1848
|
return true;
|
|
@@ -2224,7 +2225,6 @@ window.__ModuleLoader__.load({
|
|
|
2224
2225
|
loadChart,
|
|
2225
2226
|
h('div', { className: 'krot-keys' }, keyRows),
|
|
2226
2227
|
modelPools,
|
|
2227
|
-
h(UpdaterSection, { t }),
|
|
2228
2228
|
h('div', { className: 'krot-foot' },
|
|
2229
2229
|
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle'), disabled: !entry.provider.trim() }),
|
|
2230
2230
|
btn('📋 ' + t('bulkImport'), () => setBulkOpen(cur => ({ ...cur, [pIndex]: !cur[pIndex] })), { disabled: !entry.provider.trim(), title: t('bulkImport') }),
|
|
@@ -2303,8 +2303,7 @@ window.__ModuleLoader__.load({
|
|
|
2303
2303
|
totalKeysCount += pKeys.length;
|
|
2304
2304
|
for (const k of pKeys) {
|
|
2305
2305
|
const st = keyStatus(prov.provider, k);
|
|
2306
|
-
|
|
2307
|
-
if (!isDown) healthyKeysCount++;
|
|
2306
|
+
if (st && st.ready) healthyKeysCount++;
|
|
2308
2307
|
}
|
|
2309
2308
|
}
|
|
2310
2309
|
|
|
@@ -2377,7 +2376,8 @@ window.__ModuleLoader__.load({
|
|
|
2377
2376
|
h('div', { className: 'krot-foot', style: { marginTop: '10px' } },
|
|
2378
2377
|
btn(t('addProvider'), addProvider, { primary: false }),
|
|
2379
2378
|
noProviders
|
|
2380
|
-
)
|
|
2379
|
+
),
|
|
2380
|
+
h(UpdaterSection, { t })
|
|
2381
2381
|
);
|
|
2382
2382
|
|
|
2383
2383
|
const failoverSection = h('div', { className: 'krot-section-card' },
|
package/lib/index.js
CHANGED
|
@@ -336,6 +336,7 @@ export function apply(ctx, config = {}) {
|
|
|
336
336
|
switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
|
|
337
337
|
warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
|
|
338
338
|
index, pools,
|
|
339
|
+
breaker: moduleBreaker, notifyQueue: moduleNotifyQueue,
|
|
339
340
|
providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
|
|
340
341
|
cloneIds, expectedClones,
|
|
341
342
|
circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
|
|
@@ -426,6 +427,7 @@ export function apply(ctx, config = {}) {
|
|
|
426
427
|
getRotationDisabled: () => rotationDisabled,
|
|
427
428
|
setRotationDisabled: (v) => { rotationDisabled = v; },
|
|
428
429
|
circuitBreaker: moduleBreaker,
|
|
430
|
+
notifyQueue: moduleNotifyQueue,
|
|
429
431
|
quotaStore,
|
|
430
432
|
});
|
|
431
433
|
|
package/lib/lifecycle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// lib/lifecycle.js — background timers and lifecycle effects
|
|
2
2
|
import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
|
|
3
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
3
4
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { envValue } from './pool.js';
|
|
@@ -45,8 +46,9 @@ export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandb
|
|
|
45
46
|
const credentials = ctx.get?.('credentials') ?? ctx.credentials;
|
|
46
47
|
const resolve = credentials?.__dshKeyRotationOriginalResolve ?? credentials?.resolve;
|
|
47
48
|
const hit = await resolve?.call(credentials, ref);
|
|
48
|
-
const
|
|
49
|
+
const raw = typeof hit?.value === 'string' && hit.value.length > 0
|
|
49
50
|
? hit.value : envValue(ref);
|
|
51
|
+
const value = raw ? decryptSecret(raw) : raw;
|
|
50
52
|
if (!value || !isActive()) return { ok: false };
|
|
51
53
|
return runner.probeModels(ref, value);
|
|
52
54
|
}, Date.now(), { isActive });
|
package/lib/ops-keys.js
CHANGED
|
@@ -124,16 +124,27 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
124
124
|
const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
|
|
125
125
|
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
126
126
|
if (provider) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
127
|
+
let found = false;
|
|
128
|
+
let cleared = 0;
|
|
129
|
+
for (const [key, st] of poolState.entries()) {
|
|
130
|
+
if (key === provider || key.startsWith(provider + '::')) {
|
|
131
|
+
found = true;
|
|
132
|
+
cleared += st.failedUntil ? st.failedUntil.size : 0;
|
|
133
|
+
st.failedUntil?.clear();
|
|
134
|
+
st.failCounts?.clear();
|
|
135
|
+
st.authFailCounts?.clear();
|
|
136
|
+
st.brokenUntil?.clear();
|
|
137
|
+
st.revokedRefs?.clear();
|
|
138
|
+
st.tokenUsage?.clear();
|
|
139
|
+
st.switches = 0;
|
|
140
|
+
st.lastReason = undefined;
|
|
141
|
+
st.lastSwitchAt = undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!found) {
|
|
145
|
+
json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
137
148
|
let circuitReset = false;
|
|
138
149
|
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
139
150
|
if (br) {
|
|
@@ -146,7 +157,15 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
146
157
|
if (ref) {
|
|
147
158
|
let found = false;
|
|
148
159
|
for (const st of poolState.values()) {
|
|
149
|
-
if (
|
|
160
|
+
if (
|
|
161
|
+
st.failedUntil?.has(ref) ||
|
|
162
|
+
st.failCounts?.has(ref) ||
|
|
163
|
+
st.authFailCounts?.has(ref) ||
|
|
164
|
+
st.brokenUntil?.has(ref) ||
|
|
165
|
+
st.revokedRefs?.has(ref) ||
|
|
166
|
+
st.tokenUsage?.has(ref) ||
|
|
167
|
+
st.lastUsed === ref
|
|
168
|
+
) {
|
|
150
169
|
st.failedUntil?.delete(ref);
|
|
151
170
|
st.failCounts?.delete(ref);
|
|
152
171
|
st.authFailCounts?.delete(ref);
|
|
@@ -154,11 +173,14 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
154
173
|
st.revokedRefs?.delete(ref);
|
|
155
174
|
st.tokenUsage?.delete(ref);
|
|
156
175
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
157
|
-
found = true;
|
|
176
|
+
found = true;
|
|
158
177
|
}
|
|
159
178
|
}
|
|
160
179
|
// idempotent: even if ref was not cooling, report ok if it looks like a valid ref name
|
|
161
|
-
if (!found && !isValidRef(ref)) {
|
|
180
|
+
if (!found && !isValidRef(ref)) {
|
|
181
|
+
json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
162
184
|
json(res, 200, { ok: true, ref });
|
|
163
185
|
return;
|
|
164
186
|
}
|
package/lib/ops-metrics.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// lib/ops-metrics.js — Prometheus / OpenMetrics export route (#373).
|
|
2
2
|
import { METRICS_PATH } from './ops-paths.js';
|
|
3
3
|
import { isTrustedBridgeRequest, isKeyPaused, isKeyRevoked } from './pool.js';
|
|
4
|
+
import { hasModelQuotaConfig, isModelQuotaAvailable } from './model-quota.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @param {object} ctx cordis context
|
|
@@ -50,6 +51,9 @@ export function registerMetricsRoutes(ctx, deps) {
|
|
|
50
51
|
lines.push('# HELP dsh_key_rotation_expired_keys Number of expired keys');
|
|
51
52
|
lines.push('# TYPE dsh_key_rotation_expired_keys gauge');
|
|
52
53
|
|
|
54
|
+
lines.push('# HELP dsh_key_rotation_quota_exhausted_keys Number of keys blocked by model token quota');
|
|
55
|
+
lines.push('# TYPE dsh_key_rotation_quota_exhausted_keys gauge');
|
|
56
|
+
|
|
53
57
|
lines.push('# HELP dsh_key_rotation_proactive_switches_total Number of proactive rate-limit switches');
|
|
54
58
|
lines.push('# TYPE dsh_key_rotation_proactive_switches_total counter');
|
|
55
59
|
|
|
@@ -59,11 +63,13 @@ export function registerMetricsRoutes(ctx, deps) {
|
|
|
59
63
|
for (const pool of pools) {
|
|
60
64
|
const p = pool.base;
|
|
61
65
|
const st = poolState.get(p);
|
|
62
|
-
|
|
66
|
+
const quotaAware = hasModelQuotaConfig(pool);
|
|
67
|
+
let active = 0, cooling = 0, paused = 0, revoked = 0, expired = 0, quotaExhausted = 0;
|
|
63
68
|
for (const r of (pool.refs ?? [])) {
|
|
64
69
|
if (isKeyRevoked(pool, r)) { revoked++; continue; }
|
|
65
70
|
if (isKeyPaused(pool, r)) { paused++; continue; }
|
|
66
71
|
if (pool.expiresAt?.[r] !== undefined && now >= pool.expiresAt[r]) { expired++; continue; }
|
|
72
|
+
if (quotaAware && !isModelQuotaAvailable(pool, r, now)) { quotaExhausted++; continue; }
|
|
67
73
|
const fu = st?.failedUntil?.get(r) ?? 0;
|
|
68
74
|
if (fu > now) cooling++;
|
|
69
75
|
else active++;
|
|
@@ -73,6 +79,7 @@ export function registerMetricsRoutes(ctx, deps) {
|
|
|
73
79
|
lines.push(`dsh_key_rotation_paused_keys{provider="${p}"} ${paused}`);
|
|
74
80
|
lines.push(`dsh_key_rotation_revoked_keys{provider="${p}"} ${revoked}`);
|
|
75
81
|
lines.push(`dsh_key_rotation_expired_keys{provider="${p}"} ${expired}`);
|
|
82
|
+
lines.push(`dsh_key_rotation_quota_exhausted_keys{provider="${p}"} ${quotaExhausted}`);
|
|
76
83
|
lines.push(`dsh_key_rotation_proactive_switches_total{provider="${p}"} ${st?.proactiveSwitches ?? 0}`);
|
|
77
84
|
lines.push(`dsh_key_rotation_cascade_switches_total{provider="${p}"} ${st?.cascadeCount ?? 0}`);
|
|
78
85
|
}
|
package/lib/ops-status.js
CHANGED
|
@@ -29,6 +29,7 @@ export function registerStatusRoutes(ctx, deps) {
|
|
|
29
29
|
buildRuntime,
|
|
30
30
|
latencyHistogram,
|
|
31
31
|
circuitBreaker,
|
|
32
|
+
notifyQueue,
|
|
32
33
|
quotaStore,
|
|
33
34
|
} = deps;
|
|
34
35
|
|
|
@@ -89,7 +90,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
89
90
|
} catch {
|
|
90
91
|
/* describe is optional — the card falls back to editable */
|
|
91
92
|
}
|
|
92
|
-
const until = pool.state.failedUntil
|
|
93
|
+
const until = pool.state.failedUntil?.get(ref);
|
|
93
94
|
const modelQuota = getModelQuotaStatus(pool, ref, now);
|
|
94
95
|
keys.push({
|
|
95
96
|
ref,
|
|
@@ -123,10 +124,10 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
123
124
|
model: pool.model ?? null,
|
|
124
125
|
quotasConfigured: hasModelQuotaConfig(pool),
|
|
125
126
|
keys,
|
|
126
|
-
tags: providerTags
|
|
127
|
+
tags: providerTags?.get?.(pool.base) ?? [],
|
|
127
128
|
// #260 circuit breaker state (may be null if not yet tripped)
|
|
128
129
|
circuit: (() => {
|
|
129
|
-
const br = runtime.breaker;
|
|
130
|
+
const br = circuitBreaker ?? runtime.breaker;
|
|
130
131
|
if (!br) return null;
|
|
131
132
|
const st = br.state(pool.base);
|
|
132
133
|
return { state: st, threshold: br.threshold, openMs: br.openMs };
|
|
@@ -164,7 +165,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
164
165
|
// #266/#263 operational extras (additive)
|
|
165
166
|
meta: {
|
|
166
167
|
expectedClones: [...(runtime.expectedClones ?? [])],
|
|
167
|
-
notifyQueue: runtime.notifyQueue?.stats?.() ?? null,
|
|
168
|
+
notifyQueue: (notifyQueue ?? runtime.notifyQueue)?.stats?.() ?? (notifyQueue ?? runtime.notifyQueue)?.snapshot?.() ?? null,
|
|
168
169
|
breakerEnabled: runtime.circuitBreakerEnabled !== false,
|
|
169
170
|
at: now,
|
|
170
171
|
},
|
|
@@ -197,7 +198,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
197
198
|
let quotaExhausted = 0;
|
|
198
199
|
for (const ref of pool.refs) {
|
|
199
200
|
if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
|
|
200
|
-
const until = pool.state.failedUntil
|
|
201
|
+
const until = pool.state.failedUntil?.get(ref);
|
|
201
202
|
if (until !== undefined && until > now) continue;
|
|
202
203
|
const exp = pool.expiresAt?.[ref];
|
|
203
204
|
if (exp !== undefined && now >= exp) continue;
|
package/lib/ops-telemetry.js
CHANGED
|
@@ -40,10 +40,11 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
40
40
|
const now = Date.now();
|
|
41
41
|
const seen = new Set();
|
|
42
42
|
const report = [];
|
|
43
|
-
|
|
43
|
+
const poolsToScan = runtime.pools ?? runtime.poolByRef.values();
|
|
44
|
+
for (const pool of poolsToScan) {
|
|
44
45
|
if (seen.has(pool.base)) continue;
|
|
45
46
|
seen.add(pool.base);
|
|
46
|
-
if (provider && pool.base !== provider) continue;
|
|
47
|
+
if (provider && pool.base !== provider && pool.provider !== provider) continue;
|
|
47
48
|
report.push({ provider: pool.base, rows: usageRows(pool, days, now) });
|
|
48
49
|
}
|
|
49
50
|
if (csv) {
|
package/lib/ops-test.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
TEST_PATH,
|
|
15
15
|
SANDBOX_CACHE_PATH,
|
|
16
16
|
} from './ops-paths.js';
|
|
17
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* @param {object} ctx cordis context
|
|
@@ -43,7 +44,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
43
44
|
try {
|
|
44
45
|
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
45
46
|
let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
46
|
-
const
|
|
47
|
+
const rawValue = testValue || hit?.value;
|
|
48
|
+
const effectiveValue = rawValue ? decryptSecret(rawValue) : rawValue;
|
|
47
49
|
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
48
50
|
const tail = valid ? keyTail(effectiveValue) : '';
|
|
49
51
|
let source = null;
|
|
@@ -63,11 +65,12 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
63
65
|
lastTestCache.set(ref, cached);
|
|
64
66
|
if (cached.ok) {
|
|
65
67
|
for (const st of poolState.values()) {
|
|
66
|
-
if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref)) {
|
|
68
|
+
if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref) || st.revokedRefs?.has(ref)) {
|
|
67
69
|
st.failedUntil?.delete(ref);
|
|
68
70
|
st.failCounts?.delete(ref);
|
|
69
71
|
st.authFailCounts?.delete(ref);
|
|
70
72
|
st.brokenUntil?.delete(ref);
|
|
73
|
+
st.revokedRefs?.delete(ref);
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
}
|
package/lib/ops-webhook.js
CHANGED
|
@@ -153,24 +153,50 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
153
153
|
}
|
|
154
154
|
if (action.startsWith('pause-')) {
|
|
155
155
|
const until = Date.now() + 3600000; // 1h pause
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
let found = false;
|
|
157
|
+
for (const [key, s] of poolState.entries()) {
|
|
158
|
+
if (key === provider || key.startsWith(provider + '::')) {
|
|
159
|
+
found = true;
|
|
160
|
+
for (const ref of (s.failedUntil ? [...s.failedUntil.keys()] : [])) {
|
|
161
|
+
s.failedUntil.set(ref, Math.max(s.failedUntil.get(ref) ?? 0, until));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
158
165
|
for (const p of buildRuntime().poolByRef.values()) {
|
|
159
|
-
if (p.base
|
|
160
|
-
|
|
166
|
+
if (p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) {
|
|
167
|
+
found = true;
|
|
168
|
+
for (const ref of p.refs) p.state?.failedUntil?.set(ref, Math.max(p.state?.failedUntil?.get(ref) ?? 0, until));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!found) {
|
|
172
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
173
|
+
return;
|
|
161
174
|
}
|
|
162
175
|
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
|
|
163
176
|
await respond(200, { ok: true, action, provider, until }, `Pool ${provider} paused 1h`);
|
|
164
177
|
return;
|
|
165
178
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
let found = false;
|
|
180
|
+
let cleared = 0;
|
|
181
|
+
for (const [key, s] of poolState.entries()) {
|
|
182
|
+
if (key === provider || key.startsWith(provider + '::')) {
|
|
183
|
+
found = true;
|
|
184
|
+
cleared += s.failedUntil ? s.failedUntil.size : 0;
|
|
185
|
+
s.failedUntil?.clear();
|
|
186
|
+
s.failCounts?.clear();
|
|
187
|
+
s.authFailCounts?.clear();
|
|
188
|
+
s.brokenUntil?.clear();
|
|
189
|
+
s.revokedRefs?.clear();
|
|
190
|
+
s.tokenUsage?.clear();
|
|
191
|
+
s.switches = 0;
|
|
192
|
+
s.lastReason = undefined;
|
|
193
|
+
s.lastSwitchAt = undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!found) {
|
|
197
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
174
200
|
let circuitReset = false;
|
|
175
201
|
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
176
202
|
if (br) {
|
package/lib/resolver.js
CHANGED
|
@@ -237,10 +237,6 @@ function settle(pool, candidate, now, onPicked, concurrencyRef) {
|
|
|
237
237
|
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
238
238
|
pool.state.lastUsedAt.set(candidate, now);
|
|
239
239
|
onPicked?.(pool, candidate, { concurrencyRef });
|
|
240
|
-
pool.state.failCounts?.delete(candidate);
|
|
241
|
-
pool.state.failedUntil.delete(candidate);
|
|
242
|
-
pool.state.authFailCounts?.delete(candidate);
|
|
243
|
-
pool.state.brokenUntil?.delete(candidate);
|
|
244
240
|
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
245
241
|
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
246
242
|
if (pool.perHour) {
|
package/lib/rotate.js
CHANGED
|
@@ -192,7 +192,30 @@ export function createRotate(deps) {
|
|
|
192
192
|
for (let attempt = 0; attempt < attemptList.length; attempt++) {
|
|
193
193
|
activePermit = breaker ? breaker.acquire(options.provider) : null;
|
|
194
194
|
if (breaker && !activePermit) {
|
|
195
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: circuit open —
|
|
195
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — trying cascade fallback`);
|
|
196
|
+
const runtimeQ = buildRuntime();
|
|
197
|
+
const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
|
|
198
|
+
const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
|
|
199
|
+
if (currentDepth < maxDepth && Array.isArray(runtimeQ.cascade) && runtimeQ.cascade.length > 0) {
|
|
200
|
+
const fb = pickCascadeFallback(options.provider, runtimeQ, runtimeQ.providerToPool, options.model, runtimeQ.modelPoolByProvider, breaker);
|
|
201
|
+
if (fb && fb.pool && fb.pool !== pool) {
|
|
202
|
+
pool.state.lastReason = 'CASCADE';
|
|
203
|
+
pool.state.lastSwitchAt = now();
|
|
204
|
+
pool.state.cascadeCount = (pool.state.cascadeCount ?? 0) + 1;
|
|
205
|
+
pushEvent(pool, reqStore.pickedRef ?? 'pool', 'CASCADE_TRIGGERED', 0);
|
|
206
|
+
const innerCascade = rotate({
|
|
207
|
+
...options,
|
|
208
|
+
provider: fb.provider,
|
|
209
|
+
model: fb.model || options.model,
|
|
210
|
+
__isCascade: true,
|
|
211
|
+
__cascadeDepth: currentDepth + 1,
|
|
212
|
+
}, fb.pool);
|
|
213
|
+
for await (const chunk of innerCascade) {
|
|
214
|
+
yield chunk;
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
196
219
|
yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
|
|
197
220
|
return;
|
|
198
221
|
}
|
|
@@ -425,7 +448,7 @@ export function createRotate(deps) {
|
|
|
425
448
|
const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
|
|
426
449
|
if (currentDepth < maxDepth && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
|
|
427
450
|
const pools = runtime.providerToPool;
|
|
428
|
-
const fb = pickCascadeFallback(options.provider, runtime, pools, options.model, runtime.modelPoolByProvider);
|
|
451
|
+
const fb = pickCascadeFallback(options.provider, runtime, pools, options.model, runtime.modelPoolByProvider, circuitBreaker);
|
|
429
452
|
if (fb && fb.pool && fb.pool !== pool) {
|
|
430
453
|
logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
|
|
431
454
|
pool.state.lastReason = 'CASCADE';
|
package/lib/webhook.js
CHANGED
|
@@ -31,10 +31,19 @@ export function formatInteractive(url, payload, actionToken) {
|
|
|
31
31
|
text: `*${title}*\n${text}`,
|
|
32
32
|
parse_mode: 'Markdown',
|
|
33
33
|
reply_markup: {
|
|
34
|
-
inline_keyboard: [actions.map((a) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
inline_keyboard: [actions.map((a) => {
|
|
35
|
+
let cbData = actionToken ? JSON.stringify({ id: a.id, token: actionToken }) : JSON.stringify({ id: a.id });
|
|
36
|
+
if (cbData.length > 64) {
|
|
37
|
+
cbData = JSON.stringify({ id: a.id });
|
|
38
|
+
}
|
|
39
|
+
if (cbData.length > 64) {
|
|
40
|
+
cbData = String(a.id).slice(0, 64);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
text: a.label,
|
|
44
|
+
callback_data: cbData,
|
|
45
|
+
};
|
|
46
|
+
})],
|
|
38
47
|
},
|
|
39
48
|
};
|
|
40
49
|
}
|
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.42",
|
|
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": [
|
|
@@ -69,7 +69,11 @@
|
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"react": "18.3.1",
|
|
72
|
-
"react-test-renderer": "18.3.1"
|
|
72
|
+
"react-test-renderer": "18.3.1",
|
|
73
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
74
|
+
"@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
|
|
75
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
76
|
+
"semver": "^7.6.3"
|
|
73
77
|
},
|
|
74
78
|
"dependencies": {
|
|
75
79
|
"undici": "^6.29.0"
|