@goodandready/dsh-key-rotation 0.8.77 → 0.8.79
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 +14 -0
- package/lib/client-src/00-header.js +40 -0
- package/lib/client-src/10-locales.js +585 -0
- package/lib/client-src/20-hooks.js +55 -0
- package/lib/client-src/30-utils.js +185 -0
- package/lib/client-src/40-components.js +346 -0
- package/lib/client-src/50-card-main.js +647 -0
- package/lib/client-src/51-card-pool-mutators.js +501 -0
- package/lib/client-src/52-card-pool-rows.js +590 -0
- package/lib/client-src/53-card-dialogs-and-render.js +568 -0
- package/lib/client-src/60-header-chip.js +137 -0
- package/lib/client-src/90-apply.js +69 -0
- package/lib/config-compat.js +18 -1
- package/lib/http-bridge.js +2 -0
- package/lib/index.js +7 -1
- package/lib/key-state-machine.js +267 -0
- package/lib/model-catalog.js +296 -0
- package/lib/ops-balance.js +2 -147
- package/lib/ops-config-io.js +2 -456
- package/lib/ops-keys.js +2 -567
- package/lib/ops-metrics.js +2 -92
- package/lib/ops-paths.js +2 -24
- package/lib/ops-quick-action.js +2 -168
- package/lib/ops-status.js +2 -269
- package/lib/ops-telemetry.js +2 -276
- package/lib/ops-test.js +2 -255
- package/lib/ops-webhook.js +2 -218
- package/lib/pool-selection.js +36 -13
- package/lib/pool-state.js +3 -0
- package/lib/pool.js +10 -15
- package/lib/quota-runway.js +222 -0
- package/lib/routes/balance.js +147 -0
- package/lib/routes/config.js +456 -0
- package/lib/routes/guard.js +13 -0
- package/lib/routes/index.js +43 -0
- package/lib/routes/keys.js +567 -0
- package/lib/routes/metrics.js +92 -0
- package/lib/routes/paths.js +24 -0
- package/lib/routes/quick-action.js +168 -0
- package/lib/routes/status.js +269 -0
- package/lib/routes/telemetry.js +276 -0
- package/lib/routes/testing.js +255 -0
- package/lib/routes/webhook.js +218 -0
- package/lib/routes-ops.js +2 -29
- package/lib/stream-metrics.js +13 -5
- package/lib/transports/anthropic.js +125 -0
- package/lib/transports/index.js +23 -0
- package/lib/transports/openai.js +93 -0
- package/lib/transports/sse.js +160 -0
- package/lib/transports/tokens.js +124 -0
- package/package.json +15 -10
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// (issue 201, #499) header chip: one dot + counts for all pools. Green = all healthy,
|
|
2
|
+
// amber = some keys cooling, red = a pool fully exhausted. Click opens quick actions popover.
|
|
3
|
+
function KeyRotationHeaderChip(props) {
|
|
4
|
+
const t = resolveT(props);
|
|
5
|
+
const [snap, setSnap] = React.useState(null);
|
|
6
|
+
const [open, setOpen] = React.useState(false);
|
|
7
|
+
const [, setTick] = React.useState(0);
|
|
8
|
+
const [busy, setBusy] = React.useState(false);
|
|
9
|
+
const ref = React.useRef(null);
|
|
10
|
+
|
|
11
|
+
const load = () => {
|
|
12
|
+
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
13
|
+
fetch('/dsh-key-rotation/health', {
|
|
14
|
+
headers: { accept: 'application/json' },
|
|
15
|
+
credentials: 'same-origin',
|
|
16
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined
|
|
17
|
+
})
|
|
18
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
19
|
+
.then((d) => { if (d) setSnap(d); })
|
|
20
|
+
.catch((e) => { void e; });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
React.useEffect(() => {
|
|
24
|
+
load();
|
|
25
|
+
const id = setInterval(load, 5000);
|
|
26
|
+
const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') load(); };
|
|
27
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
|
|
28
|
+
return () => {
|
|
29
|
+
clearInterval(id);
|
|
30
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
|
|
31
|
+
};
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
if (!props?.settingsSource?.subscribe) return;
|
|
36
|
+
return props.settingsSource.subscribe(() => {
|
|
37
|
+
setTick((c) => c + 1);
|
|
38
|
+
});
|
|
39
|
+
}, [props?.settingsSource]);
|
|
40
|
+
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (!open) return;
|
|
43
|
+
const onDocClick = (e) => {
|
|
44
|
+
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
|
45
|
+
};
|
|
46
|
+
document.addEventListener('click', onDocClick);
|
|
47
|
+
return () => document.removeEventListener('click', onDocClick);
|
|
48
|
+
}, [open]);
|
|
49
|
+
|
|
50
|
+
const runAction = (action, provider, keyRef) => {
|
|
51
|
+
setBusy(true);
|
|
52
|
+
fetch('/dsh-key-rotation/quick-action', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
55
|
+
body: JSON.stringify({ action, provider, ref: keyRef }),
|
|
56
|
+
credentials: 'same-origin',
|
|
57
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(5000) : undefined,
|
|
58
|
+
})
|
|
59
|
+
.then((r) => r.json())
|
|
60
|
+
.then(() => { load(); })
|
|
61
|
+
.catch((err) => { void err; })
|
|
62
|
+
.finally(() => { setBusy(false); });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
let settingsVal = null;
|
|
66
|
+
try {
|
|
67
|
+
const sSnap = props?.settingsSource?.getSnapshot?.()?.service?.getSnapshot?.();
|
|
68
|
+
settingsVal = sSnap?.value;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
void e;
|
|
71
|
+
}
|
|
72
|
+
if (settingsVal && settingsVal.showChatHeaderChip === false) return null;
|
|
73
|
+
if (snap && snap.showChatHeaderChip === false) return null;
|
|
74
|
+
|
|
75
|
+
const poolsObj = (snap && snap.pools) || {};
|
|
76
|
+
const poolEntries = Object.entries(poolsObj);
|
|
77
|
+
const total = poolEntries.reduce((a, [, p]) => a + (p.total || 0), 0);
|
|
78
|
+
const healthy = poolEntries.reduce((a, [, p]) => a + (p.healthy || 0), 0);
|
|
79
|
+
const anyExhausted = poolEntries.some(([, p]) => p.exhausted);
|
|
80
|
+
const color = !poolEntries.length ? 'var(--dsw-alias-label-tertiary)' : anyExhausted ? 'var(--dsw-alias-state-error-primary)' : healthy < total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)';
|
|
81
|
+
const label = poolEntries.length ? `${healthy}/${total} rot` : 'rot';
|
|
82
|
+
|
|
83
|
+
return h('div', { ref, style: { position: 'relative', display: 'inline-flex' } },
|
|
84
|
+
h('button', {
|
|
85
|
+
type: 'button',
|
|
86
|
+
className: 'krot-header-chip',
|
|
87
|
+
title: t('title'),
|
|
88
|
+
onClick: () => setOpen((v) => !v),
|
|
89
|
+
},
|
|
90
|
+
h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: color, flex: 'none', boxShadow: `0 0 6px ${color}` } }),
|
|
91
|
+
label,
|
|
92
|
+
h('span', { style: { fontSize: '9px', opacity: 0.6 } }, open ? '▲' : '▼')
|
|
93
|
+
),
|
|
94
|
+
open ? h('div', { className: 'krot-popover', style: { minWidth: '220px' } },
|
|
95
|
+
h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
|
|
96
|
+
!poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
|
|
97
|
+
poolEntries.map(([name, p]) => {
|
|
98
|
+
const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)');
|
|
99
|
+
const provName = p.providerName || name;
|
|
100
|
+
const tail = p.lastUsedTail;
|
|
101
|
+
return h('div', { key: name, className: 'krot-pop-row', style: { display: 'flex', flexDirection: 'column', gap: '4px', padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-border-subtle)' } },
|
|
102
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
103
|
+
h('div', { className: 'krot-pop-name' },
|
|
104
|
+
h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
|
|
105
|
+
name,
|
|
106
|
+
tail ? h('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', fontFeatureSettings: '"tnum"', fontFamily: 'monospace' } }, `...${tail}`) : null
|
|
107
|
+
),
|
|
108
|
+
h('div', { className: 'krot-pop-count', style: { color: c, fontWeight: 600 } }, `${p.healthy}/${p.total}`)
|
|
109
|
+
),
|
|
110
|
+
h('div', { style: { display: 'flex', gap: '4px', marginTop: '2px' } },
|
|
111
|
+
h('button', {
|
|
112
|
+
type: 'button',
|
|
113
|
+
disabled: busy,
|
|
114
|
+
onClick: (e) => { e.stopPropagation(); runAction('skip', provName, p.lastUsed); },
|
|
115
|
+
title: t('quickSkipTitle'),
|
|
116
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
117
|
+
}, `⏭️ ${t('quickSkip')}`),
|
|
118
|
+
h('button', {
|
|
119
|
+
type: 'button',
|
|
120
|
+
disabled: busy,
|
|
121
|
+
onClick: (e) => { e.stopPropagation(); runAction('reset', provName, p.lastUsed); },
|
|
122
|
+
title: t('quickResetTitle'),
|
|
123
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
124
|
+
}, `🔄 ${t('quickReset')}`),
|
|
125
|
+
h('button', {
|
|
126
|
+
type: 'button',
|
|
127
|
+
disabled: busy || !p.lastUsed,
|
|
128
|
+
onClick: (e) => { e.stopPropagation(); runAction('pause', provName, p.lastUsed); },
|
|
129
|
+
title: t('quickPauseTitle'),
|
|
130
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
131
|
+
}, `⏸️ ${t('quickPause')}`)
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
})
|
|
135
|
+
) : null
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function apply(ctx) {
|
|
2
|
+
const settingsSource = createSettingsSource(ctx);
|
|
3
|
+
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-key-rotation: dictionaries');
|
|
4
|
+
// Header chip (#201): status dot in the session header utilities slot,
|
|
5
|
+
// same slot dsh-gitea / dsh-subscriptions use for their header widgets.
|
|
6
|
+
ctx.effect(() => {
|
|
7
|
+
if (!ctx.slots) return;
|
|
8
|
+
try {
|
|
9
|
+
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
10
|
+
ctx.slots.register(
|
|
11
|
+
{ name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6, locale: NS, inject: () => ({ ctx, settingsSource }) },
|
|
12
|
+
KeyRotationHeaderChip,
|
|
13
|
+
));
|
|
14
|
+
} catch { /* slot not available in this build */ }
|
|
15
|
+
}, 'dsh-key-rotation: header chip');
|
|
16
|
+
|
|
17
|
+
function useLocale() {
|
|
18
|
+
return useActiveLocale(ctx);
|
|
19
|
+
}
|
|
20
|
+
// Collapsible card in Settings -> Plugins -> Plugin settings
|
|
21
|
+
// (settings.plugin.item), matching Model Sync / Spendmeter / Vision Bridge.
|
|
22
|
+
// key MUST equal the settings namespace (NS), else the tab silently skips it.
|
|
23
|
+
|
|
24
|
+
function KeyRotationCard(props) {
|
|
25
|
+
const locale = useLocale();
|
|
26
|
+
const t = resolveT(props);
|
|
27
|
+
const page = !!(props && props.view === 'page');
|
|
28
|
+
const [open, setOpen] = React.useState(!!page);
|
|
29
|
+
// Row seat (plugins.row.config): the host page draws title/icon/crumb and the
|
|
30
|
+
// padding, so the summary is a one-liner and the page drops our card chrome.
|
|
31
|
+
if (props && props.view === 'summary') {
|
|
32
|
+
return h('span', { className: 'krot-card-description' }, t('subtitle'));
|
|
33
|
+
}
|
|
34
|
+
return h(page ? 'div' : 'div', { className: page ? 'krot-page' : 'krot-card' + (open ? ' krot-card-open' : '') },
|
|
35
|
+
h('button', { type: 'button', className: 'krot-card-header', style: page ? { display: 'none' } : undefined, 'aria-expanded': page ? true : open, onClick: () => setOpen((v) => !v) },
|
|
36
|
+
h('span', { className: 'krot-card-head-text' },
|
|
37
|
+
h('span', { className: 'krot-card-name' }, t('title')),
|
|
38
|
+
h('span', { className: 'krot-card-description' }, t('subtitle'))),
|
|
39
|
+
h('span', { className: 'krot-card-chevron' + (open ? ' krot-card-chevron-open' : ''), 'aria-hidden': 'true' },
|
|
40
|
+
h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, style: { display: 'block' } },
|
|
41
|
+
h('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' })))),
|
|
42
|
+
(page || open) ? h('div', { className: 'krot-card-body' }, h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale }))) : null);
|
|
43
|
+
}
|
|
44
|
+
// Register each available seat independently. A missing legacy seat must
|
|
45
|
+
// not prevent the current bundle or row seat from registering.
|
|
46
|
+
for (const [name, key] of [
|
|
47
|
+
['plugins.bundle.config', PKG],
|
|
48
|
+
['plugins.row.config', ROW_CONFIG_KEY],
|
|
49
|
+
['settings.plugin.item', NS],
|
|
50
|
+
]) {
|
|
51
|
+
try {
|
|
52
|
+
ctx.slots.inject(name, () => ctx.slots.register(
|
|
53
|
+
{ name, key, locale: NS, inject: () => ({ ctx, settingsSource }) }, KeyRotationCard,
|
|
54
|
+
));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings seat unavailable', name, error); });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { apply, inject: ['slots', 'locale'] };
|
|
62
|
+
if (typeof globalThis !== 'undefined') {
|
|
63
|
+
globalThis.__dshKeyRotationClientInternals = {
|
|
64
|
+
quotaMapOf, withQuotas, modelEntriesOf, isValidModelId, formatCountdown, validatePoolDraft,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return module.exports;
|
|
68
|
+
},
|
|
69
|
+
});
|
package/lib/config-compat.js
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
|
+
// lib/config-compat.js — Schemastery volatile decoding and config resolution (#551).
|
|
1
2
|
// Decode modern volatile Config references while retaining stable identities for
|
|
2
3
|
// the request hot path. Legacy cores pass ordinary resolved configuration.
|
|
3
4
|
// If ctx is provided, invalidation listeners cache the unwrapped tree so the hot
|
|
4
5
|
// path never recursively walks volatile nodes on unchanged configuration.
|
|
6
|
+
|
|
7
|
+
/** Read each volatile field through its live reference, if the loader installed one (#551). */
|
|
8
|
+
export function unwrapVolatileConfig(config) {
|
|
9
|
+
if (!config || typeof config !== 'object') return {};
|
|
10
|
+
const source = config;
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const key of Object.keys(source)) {
|
|
13
|
+
const value = source[key];
|
|
14
|
+
out[key] = (value !== null && typeof value === 'object' && typeof value.get === 'function')
|
|
15
|
+
? value.get()
|
|
16
|
+
: value;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
export function createConfigReader(config, ctx) {
|
|
6
22
|
let previous;
|
|
7
23
|
let dirty = true;
|
|
@@ -34,7 +50,8 @@ export function createConfigReader(config, ctx) {
|
|
|
34
50
|
if (ctx && !dirty && previous !== undefined) {
|
|
35
51
|
return previous;
|
|
36
52
|
}
|
|
37
|
-
|
|
53
|
+
const unwrapped = unwrapVolatileConfig(config);
|
|
54
|
+
previous = read(unwrapped, previous);
|
|
38
55
|
dirty = false;
|
|
39
56
|
return previous;
|
|
40
57
|
};
|
package/lib/http-bridge.js
CHANGED
|
@@ -183,6 +183,8 @@ export function scanForLiveSecrets(section) {
|
|
|
183
183
|
|
|
184
184
|
/** Config bridge GET/PUT/DELETE — extracted from lib/index.js for size (#253). */
|
|
185
185
|
export async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
186
|
+
res.setHeader?.('X-DSH-Deprecated', 'true');
|
|
187
|
+
res.setHeader?.('Deprecation', '@goodandready/dsh-key-rotation v0.8.79: use Schemastery volatile schema instead of direct config HTTP bridge');
|
|
186
188
|
if (!isTrustedBridgeRequest(request)) {
|
|
187
189
|
json(res, 403, { error: { code: 'forbidden', message: 'Settings writes require a trusted loopback origin' } });
|
|
188
190
|
return;
|
package/lib/index.js
CHANGED
|
@@ -11,7 +11,8 @@ import { decryptSecret } from './crypto-storage.js';
|
|
|
11
11
|
|
|
12
12
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
13
|
import Schema from '@deepseek-ai/schemastery';
|
|
14
|
-
import { createConfigReader, createProviderProfilesReader } from './config-compat.js';
|
|
14
|
+
import { createConfigReader, createProviderProfilesReader, unwrapVolatileConfig } from './config-compat.js';
|
|
15
|
+
import { KeyStateMachine, KEY_STATES, VALID_TRANSITIONS } from './key-state-machine.js';
|
|
15
16
|
import {
|
|
16
17
|
keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, isDesktopShellRequest, SWITCHABLE_MESSAGE_PATTERN,
|
|
17
18
|
DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure,
|
|
@@ -60,6 +61,11 @@ export { notifySwitch, notifyExhaustion, notifyEarlyWarning };
|
|
|
60
61
|
export { classifyResponseError } from './errors.js';
|
|
61
62
|
export { SessionRouter, defaultSessionRouter, unstickFailedKey } from './session-router.js';
|
|
62
63
|
export { isContentChunk } from './rotate.js';
|
|
64
|
+
export { ModelCatalog, defaultModelCatalog } from './model-catalog.js';
|
|
65
|
+
export { KeyStateMachine, KEY_STATES, VALID_TRANSITIONS } from './key-state-machine.js';
|
|
66
|
+
export { unwrapVolatileConfig } from './config-compat.js';
|
|
67
|
+
export { QuotaRunway, defaultQuotaRunway, estimateRunway } from './quota-runway.js';
|
|
68
|
+
export * as transports from './transports/index.js';
|
|
63
69
|
|
|
64
70
|
const NS = 'dsh-key-rotation';
|
|
65
71
|
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// lib/key-state-machine.js — Encapsulated Key State Machine (#529).
|
|
2
|
+
// Manages credential lifecycle across deterministic states and valid transitions.
|
|
3
|
+
|
|
4
|
+
export const KEY_STATES = Object.freeze({
|
|
5
|
+
ACTIVE: 'ACTIVE',
|
|
6
|
+
COOLDOWN: 'COOLDOWN',
|
|
7
|
+
PROBING: 'PROBING',
|
|
8
|
+
EXHAUSTED: 'EXHAUSTED',
|
|
9
|
+
PAUSED: 'PAUSED',
|
|
10
|
+
REVOKED: 'REVOKED',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const VALID_TRANSITIONS = Object.freeze({
|
|
14
|
+
[KEY_STATES.ACTIVE]: Object.freeze([
|
|
15
|
+
KEY_STATES.COOLDOWN,
|
|
16
|
+
KEY_STATES.EXHAUSTED,
|
|
17
|
+
KEY_STATES.PAUSED,
|
|
18
|
+
KEY_STATES.REVOKED,
|
|
19
|
+
]),
|
|
20
|
+
[KEY_STATES.COOLDOWN]: Object.freeze([
|
|
21
|
+
KEY_STATES.PROBING,
|
|
22
|
+
KEY_STATES.ACTIVE,
|
|
23
|
+
KEY_STATES.PAUSED,
|
|
24
|
+
KEY_STATES.REVOKED,
|
|
25
|
+
]),
|
|
26
|
+
[KEY_STATES.PROBING]: Object.freeze([
|
|
27
|
+
KEY_STATES.ACTIVE,
|
|
28
|
+
KEY_STATES.COOLDOWN,
|
|
29
|
+
KEY_STATES.PAUSED,
|
|
30
|
+
KEY_STATES.REVOKED,
|
|
31
|
+
]),
|
|
32
|
+
[KEY_STATES.EXHAUSTED]: Object.freeze([
|
|
33
|
+
KEY_STATES.ACTIVE,
|
|
34
|
+
KEY_STATES.COOLDOWN,
|
|
35
|
+
KEY_STATES.PAUSED,
|
|
36
|
+
KEY_STATES.REVOKED,
|
|
37
|
+
]),
|
|
38
|
+
[KEY_STATES.PAUSED]: Object.freeze([
|
|
39
|
+
KEY_STATES.ACTIVE,
|
|
40
|
+
KEY_STATES.COOLDOWN,
|
|
41
|
+
KEY_STATES.REVOKED,
|
|
42
|
+
]),
|
|
43
|
+
[KEY_STATES.REVOKED]: Object.freeze([
|
|
44
|
+
KEY_STATES.ACTIVE,
|
|
45
|
+
]),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export class KeyStateMachine {
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} [options]
|
|
51
|
+
* @param {number} [options.maxHistory] Maximum transition history entries per pool
|
|
52
|
+
*/
|
|
53
|
+
constructor(options = {}) {
|
|
54
|
+
/** @type {Map<string, { state: string, updatedAt: number, previousState?: string, reason?: string, failedUntil?: number }>} */
|
|
55
|
+
this.keyRecords = new Map();
|
|
56
|
+
/** @type {Array<{ ref: string, from: string, to: string, at: number, reason: string|null }>} */
|
|
57
|
+
this.history = [];
|
|
58
|
+
this.maxHistory = options.maxHistory ?? 100;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Computes the current deterministic state of a key reference.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} keyRef
|
|
65
|
+
* @param {object} [context] Optional pool state context for backward compatibility
|
|
66
|
+
* @param {Set<string>} [context.revokedRefs]
|
|
67
|
+
* @param {Set<string>} [context.pausedRefs]
|
|
68
|
+
* @param {Map<string, number>} [context.failedUntil]
|
|
69
|
+
* @param {number} [now]
|
|
70
|
+
* @returns {string} Current KEY_STATES value
|
|
71
|
+
*/
|
|
72
|
+
getState(keyRef, context = {}, now = Date.now()) {
|
|
73
|
+
if (!keyRef) return KEY_STATES.ACTIVE;
|
|
74
|
+
|
|
75
|
+
// 1. Revocation is absolute
|
|
76
|
+
if (context.revokedRefs && context.revokedRefs.has(keyRef)) {
|
|
77
|
+
return KEY_STATES.REVOKED;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Manual pause overrides operational state
|
|
81
|
+
if (context.pausedRefs && context.pausedRefs.has(keyRef)) {
|
|
82
|
+
return KEY_STATES.PAUSED;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const record = this.keyRecords.get(keyRef);
|
|
86
|
+
if (!record) {
|
|
87
|
+
const failedUntil = context.failedUntil?.get?.(keyRef);
|
|
88
|
+
if (typeof failedUntil === 'number' && failedUntil > now) {
|
|
89
|
+
return KEY_STATES.COOLDOWN;
|
|
90
|
+
}
|
|
91
|
+
return KEY_STATES.ACTIVE;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Cooldown transition to probing when timer elapses
|
|
95
|
+
if (record.state === KEY_STATES.COOLDOWN) {
|
|
96
|
+
const until = record.failedUntil ?? context.failedUntil?.get?.(keyRef);
|
|
97
|
+
if (typeof until === 'number' && until <= now) {
|
|
98
|
+
return KEY_STATES.PROBING;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return record.state;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Transitions a key reference to a new state if valid.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} keyRef
|
|
109
|
+
* @param {string} targetState Target KEY_STATES value
|
|
110
|
+
* @param {object} [metadata]
|
|
111
|
+
* @param {string} [metadata.reason]
|
|
112
|
+
* @param {number} [metadata.failedUntil]
|
|
113
|
+
* @param {object} [metadata.context]
|
|
114
|
+
* @param {number} [now]
|
|
115
|
+
* @returns {string|null} Resulting state or null on failure
|
|
116
|
+
*/
|
|
117
|
+
transition(keyRef, targetState, metadata = {}, now = Date.now()) {
|
|
118
|
+
if (!keyRef || !targetState) return null;
|
|
119
|
+
|
|
120
|
+
const currentState = this.getState(keyRef, metadata.context, now);
|
|
121
|
+
if (currentState === targetState) return currentState;
|
|
122
|
+
|
|
123
|
+
const allowed = VALID_TRANSITIONS[currentState] || [];
|
|
124
|
+
if (!allowed.includes(targetState)) {
|
|
125
|
+
// In relaxed mode, force transition with logged anomaly
|
|
126
|
+
targetState = targetState in KEY_STATES ? targetState : KEY_STATES.ACTIVE;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const record = this.keyRecords.get(keyRef) ?? {
|
|
130
|
+
state: currentState,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
record.previousState = currentState;
|
|
135
|
+
record.state = targetState;
|
|
136
|
+
record.updatedAt = now;
|
|
137
|
+
if (metadata.reason !== undefined) record.reason = metadata.reason;
|
|
138
|
+
if (metadata.failedUntil !== undefined) record.failedUntil = metadata.failedUntil;
|
|
139
|
+
|
|
140
|
+
this.keyRecords.set(keyRef, record);
|
|
141
|
+
|
|
142
|
+
const event = {
|
|
143
|
+
ref: keyRef,
|
|
144
|
+
from: currentState,
|
|
145
|
+
to: targetState,
|
|
146
|
+
at: now,
|
|
147
|
+
reason: metadata.reason ?? null,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
this.history.push(event);
|
|
151
|
+
if (this.history.length > this.maxHistory) {
|
|
152
|
+
this.history.shift();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Synchronize external pool state if provided
|
|
156
|
+
if (metadata.context) {
|
|
157
|
+
if (targetState === KEY_STATES.REVOKED) {
|
|
158
|
+
metadata.context.revokedRefs?.add?.(keyRef);
|
|
159
|
+
metadata.context.pausedRefs?.delete?.(keyRef);
|
|
160
|
+
} else if (targetState === KEY_STATES.PAUSED) {
|
|
161
|
+
metadata.context.pausedRefs?.add?.(keyRef);
|
|
162
|
+
} else if (currentState === KEY_STATES.PAUSED && targetState !== KEY_STATES.PAUSED) {
|
|
163
|
+
metadata.context.pausedRefs?.delete?.(keyRef);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return targetState;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Puts key into COOLDOWN state until specified duration elapses.
|
|
172
|
+
*/
|
|
173
|
+
transitionToCooldown(keyRef, options = {}, now = Date.now()) {
|
|
174
|
+
const cooldownMs = Number(options.cooldownMs) > 0 ? Number(options.cooldownMs) : 60000;
|
|
175
|
+
const failedUntil = now + cooldownMs;
|
|
176
|
+
return this.transition(keyRef, KEY_STATES.COOLDOWN, {
|
|
177
|
+
failedUntil,
|
|
178
|
+
reason: options.reason || 'COOLDOWN',
|
|
179
|
+
context: options.context,
|
|
180
|
+
}, now);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Marks key as ready for canary probe dispatch. */
|
|
184
|
+
markProbing(keyRef, metadata = {}, now = Date.now()) {
|
|
185
|
+
return this.transition(keyRef, KEY_STATES.PROBING, {
|
|
186
|
+
reason: metadata.reason || 'CANARY_PROBE',
|
|
187
|
+
context: metadata.context,
|
|
188
|
+
}, now);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Activates key following successful request or probe. */
|
|
192
|
+
activate(keyRef, metadata = {}, now = Date.now()) {
|
|
193
|
+
return this.transition(keyRef, KEY_STATES.ACTIVE, {
|
|
194
|
+
reason: metadata.reason || 'SUCCESS',
|
|
195
|
+
context: metadata.context,
|
|
196
|
+
}, now);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Marks key as exhausted due to local quota or TPM/RPM window. */
|
|
200
|
+
markExhausted(keyRef, metadata = {}, now = Date.now()) {
|
|
201
|
+
return this.transition(keyRef, KEY_STATES.EXHAUSTED, {
|
|
202
|
+
reason: metadata.reason || 'QUOTA_EXHAUSTED',
|
|
203
|
+
context: metadata.context,
|
|
204
|
+
}, now);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Manually pauses key from rotation. */
|
|
208
|
+
pause(keyRef, reason = 'USER_PAUSE', metadata = {}, now = Date.now()) {
|
|
209
|
+
return this.transition(keyRef, KEY_STATES.PAUSED, {
|
|
210
|
+
reason,
|
|
211
|
+
context: metadata.context,
|
|
212
|
+
}, now);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Unpauses key, returning it to active rotation. */
|
|
216
|
+
unpause(keyRef, metadata = {}, now = Date.now()) {
|
|
217
|
+
return this.transition(keyRef, KEY_STATES.ACTIVE, {
|
|
218
|
+
reason: 'USER_RESUME',
|
|
219
|
+
context: metadata.context,
|
|
220
|
+
}, now);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Revokes key permanently until credentials change. */
|
|
224
|
+
revoke(keyRef, reason = 'INVALID_CREDENTIAL', metadata = {}, now = Date.now()) {
|
|
225
|
+
return this.transition(keyRef, KEY_STATES.REVOKED, {
|
|
226
|
+
reason,
|
|
227
|
+
context: metadata.context,
|
|
228
|
+
}, now);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Checks if key is usable for dispatch (ACTIVE or PROBING). */
|
|
232
|
+
isUsable(keyRef, context = {}, now = Date.now()) {
|
|
233
|
+
const state = this.getState(keyRef, context, now);
|
|
234
|
+
return state === KEY_STATES.ACTIVE || state === KEY_STATES.PROBING;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Returns transition history for a specific key or all keys. */
|
|
238
|
+
getHistory(keyRef) {
|
|
239
|
+
if (!keyRef) return this.history.slice();
|
|
240
|
+
return this.history.filter((e) => e.ref === keyRef);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Returns state distribution statistics across a set of keys. */
|
|
244
|
+
getStats(refs = [], context = {}, now = Date.now()) {
|
|
245
|
+
const stats = {
|
|
246
|
+
total: refs.length,
|
|
247
|
+
[KEY_STATES.ACTIVE]: 0,
|
|
248
|
+
[KEY_STATES.COOLDOWN]: 0,
|
|
249
|
+
[KEY_STATES.PROBING]: 0,
|
|
250
|
+
[KEY_STATES.EXHAUSTED]: 0,
|
|
251
|
+
[KEY_STATES.PAUSED]: 0,
|
|
252
|
+
[KEY_STATES.REVOKED]: 0,
|
|
253
|
+
};
|
|
254
|
+
for (const ref of refs) {
|
|
255
|
+
const state = this.getState(ref, context, now);
|
|
256
|
+
if (state in stats) stats[state]++;
|
|
257
|
+
}
|
|
258
|
+
return stats;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
clear() {
|
|
262
|
+
this.keyRecords.clear();
|
|
263
|
+
this.history = [];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const defaultKeyStateMachine = new KeyStateMachine();
|