@goodandready/dsh-key-rotation 0.8.36 → 0.8.38
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 +20 -0
- package/lib/bucket.js +61 -0
- package/lib/client.js +15 -0
- package/lib/index.js +25 -5
- package/lib/ops-status.js +3 -1
- package/lib/pool-builder.js +13 -5
- package/lib/pool-state.js +1 -1
- package/lib/resolver.js +12 -1
- package/lib/rotate.js +9 -1
- package/lib/sanitize-snapshot.js +8 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.38 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Sliding-Window TPM (Tokens Per Minute) Rate Limiting (#402, GH #20)**:
|
|
7
|
+
- Added 60-second sliding-window TPM rate-limiting alongside existing RPM (`tpmAllow`, `tpmRecord`, `tpmRetryMs`, `tpmSweep`, `tpmInfo` in `lib/bucket.js`).
|
|
8
|
+
- Added `tpmLimit` configuration at global, provider, and per-model scopes in `lib/index.js` and `lib/pool-builder.js`.
|
|
9
|
+
- Automatically records consumed tokens on streaming and non-streaming responses in `lib/rotate.js`.
|
|
10
|
+
- Enforced fail-over and cooldown in `lib/resolver.js` when candidate key exceeds its configured TPM budget.
|
|
11
|
+
- Exposed live TPM telemetry (`used`, `remaining`, `limit`, `resetMs`) in `GET /dsh-key-rotation/status` with sanitization in `lib/sanitize-snapshot.js`.
|
|
12
|
+
- Added visual UI badge and editable TPM limit input in `lib/client-src/50-card-main.js` with localization (`en`, `zh`).
|
|
13
|
+
- Added automated test suite `test/tpm-rate-limit.test.mjs` verifying bucket math, sweep cleanup, resolver fail-over, and snapshot sanitization.
|
|
14
|
+
|
|
15
|
+
## 0.8.37
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **Peer gate on DSH 0.2.0-rc.1** (#58): the bundle was skipped at profile startup because its `peerDependencies` excluded the running version.
|
|
19
|
+
- **Settings card served no form**: `NS` was the package name rather than the profile entry id, `Config` declared no `.volatile()` field, and `getConfig` handed out `Volatile` boxes. Both 0.1.7-rc.2 and 0.2.0 now serve and read the form.
|
|
20
|
+
- **A saved setting never took effect**: the host applied changes through `settings.register` and `scope.watch`, neither of which exists in either release. Changes are applied on `loader/volatile-update` now.
|
|
21
|
+
- **`settings.plugin.item` registration removed**: retired before DSH 0.1.7-rc.2, it only registered the card a second time on a seat that no longer exists.
|
|
22
|
+
|
|
3
23
|
## 0.8.36
|
|
4
24
|
|
|
5
25
|
### Fixed
|
package/lib/bucket.js
CHANGED
|
@@ -41,3 +41,64 @@ export function bucketInfo(windows, ref, limit, now = Date.now()) {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
|
|
45
|
+
/** Sliding-window check: true if `ref` is under `limit` tokens/min. */
|
|
46
|
+
export function tpmAllow(windows, ref, limit, now = Date.now()) {
|
|
47
|
+
if (!limit || limit <= 0) return true;
|
|
48
|
+
const cut = now - WINDOW_MS;
|
|
49
|
+
const entries = (windows?.get(ref) ?? []).filter((e) => e.time > cut);
|
|
50
|
+
let total = 0;
|
|
51
|
+
for (const e of entries) total += e.tokens;
|
|
52
|
+
return total < limit;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Record actual consumed tokens for `ref` in sliding 60s window. */
|
|
56
|
+
export function tpmRecord(windows, ref, tokens, now = Date.now()) {
|
|
57
|
+
if (!tokens || tokens <= 0 || !windows) return;
|
|
58
|
+
const cut = now - WINDOW_MS;
|
|
59
|
+
const validTokens = Math.max(1, Math.floor(tokens));
|
|
60
|
+
const entries = (windows.get(ref) ?? []).filter((e) => e.time > cut);
|
|
61
|
+
entries.push({ time: now, tokens: validTokens });
|
|
62
|
+
windows.set(ref, entries);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** ms until `ref` may retry again under TPM limit (0 = now). */
|
|
66
|
+
export function tpmRetryMs(windows, ref, limit, now = Date.now()) {
|
|
67
|
+
if (!limit || limit <= 0) return 0;
|
|
68
|
+
const cut = now - WINDOW_MS;
|
|
69
|
+
const entries = (windows?.get(ref) ?? []).filter((e) => e.time > cut);
|
|
70
|
+
let total = 0;
|
|
71
|
+
for (const e of entries) total += e.tokens;
|
|
72
|
+
if (total < limit) return 0;
|
|
73
|
+
let rem = total;
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
rem -= e.tokens;
|
|
76
|
+
if (rem < limit) {
|
|
77
|
+
return Math.max(0, e.time + WINDOW_MS - now);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return WINDOW_MS;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Drop TPM state for refs that no longer exist. */
|
|
84
|
+
export function tpmSweep(windows, liveRefs) {
|
|
85
|
+
if (!windows) return;
|
|
86
|
+
for (const ref of [...windows.keys()]) {
|
|
87
|
+
if (!liveRefs.has(ref)) windows.delete(ref);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Snapshot for /status - used/remaining/limit/resetMs for one ref. */
|
|
92
|
+
export function tpmInfo(windows, ref, limit, now = Date.now()) {
|
|
93
|
+
if (!limit || limit <= 0) return null;
|
|
94
|
+
const cut = now - WINDOW_MS;
|
|
95
|
+
const entries = (windows?.get(ref) ?? []).filter((e) => e.time > cut);
|
|
96
|
+
let used = 0;
|
|
97
|
+
for (const e of entries) used += e.tokens;
|
|
98
|
+
return {
|
|
99
|
+
used,
|
|
100
|
+
remaining: Math.max(0, limit - used),
|
|
101
|
+
limit,
|
|
102
|
+
resetMs: used >= limit ? tpmRetryMs(windows, ref, limit, now) : 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -117,6 +117,9 @@ window.__ModuleLoader__.load({
|
|
|
117
117
|
keyWriteFailed: 'could not store the key: {msg}',
|
|
118
118
|
notSecretShape: 'saved, but the value does not look like an API key - check for a typo',
|
|
119
119
|
rpmTitle: 'requests/min: {u} used, {r} remaining',
|
|
120
|
+
tpmTitle: 'tokens/min: {u} used, {r} remaining',
|
|
121
|
+
rpmLimitLabel: 'Rate Limit (RPM)',
|
|
122
|
+
tpmLimitLabel: 'Rate Limit (TPM)',
|
|
120
123
|
budgetLabel: 'budget:',
|
|
121
124
|
exportCsv: 'Export CSV',
|
|
122
125
|
weightHint: 'round-robin weight: how many times this key joins the cycle (1 = equal share)',
|
|
@@ -299,6 +302,9 @@ window.__ModuleLoader__.load({
|
|
|
299
302
|
keyWriteFailed: '无法存储密钥: {msg}',
|
|
300
303
|
notSecretShape: '已保存,但该值看起来不像 API 密钥 - 请检查是否有拼写错误',
|
|
301
304
|
rpmTitle: '请求/分钟: 已用 {u}, 剩余 {r}',
|
|
305
|
+
tpmTitle: 'Token/分钟: 已用 {u}, 剩余 {r}',
|
|
306
|
+
rpmLimitLabel: '速率限制 (RPM)',
|
|
307
|
+
tpmLimitLabel: '速率限制 (TPM)',
|
|
302
308
|
budgetLabel: '预算:',
|
|
303
309
|
exportCsv: '导出 CSV',
|
|
304
310
|
weightHint: '轮换权重:该密钥参与循环的次数 (1 = 等分)',
|
|
@@ -1918,6 +1924,11 @@ window.__ModuleLoader__.load({
|
|
|
1918
1924
|
title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
|
|
1919
1925
|
style: info.rpm.remaining === 0 ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined },
|
|
1920
1926
|
'⏱' + info.rpm.remaining));
|
|
1927
|
+
// TPM capacity indicator (only when tpmLimit is active)
|
|
1928
|
+
if (info && info.tpm) meta.push(h('span', { key: 'tpm', className: 'krot-tail',
|
|
1929
|
+
title: t('tpmTitle').replace('{u}', String(info.tpm.used)).replace('{r}', String(info.tpm.remaining)),
|
|
1930
|
+
style: info.tpm.remaining === 0 ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined },
|
|
1931
|
+
'⚡' + info.tpm.remaining));
|
|
1921
1932
|
if (info && typeof info.cost === 'number' && info.cost > 0) meta.push(h('span', { key: 'c', className: 'krot-tail', title: 'cost' }, '$' + info.cost.toFixed(2)));
|
|
1922
1933
|
const tr = testResult[key];
|
|
1923
1934
|
if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail',
|
|
@@ -2393,6 +2404,10 @@ window.__ModuleLoader__.load({
|
|
|
2393
2404
|
field(t('cooldown'), textInput(String(val.cooldownMs ?? 60000), (v) => setField((cur) => ({ ...cur, cooldownMs: Number(v) || 0 })))),
|
|
2394
2405
|
field(t('scheduleDays'), textInput(String(val.rotationScheduleDays ?? 0), (v) => setField((cur) => ({ ...cur, rotationScheduleDays: Number(v) || 0 }))))
|
|
2395
2406
|
),
|
|
2407
|
+
h('div', { className: 'krot-grid-2', style: { marginTop: '10px' } },
|
|
2408
|
+
field(t('rpmLimitLabel'), textInput(String(val.rpmLimit ?? 0), (v) => setField((cur) => ({ ...cur, rpmLimit: Number(v) || 0 })))),
|
|
2409
|
+
field(t('tpmLimitLabel'), textInput(String(val.tpmLimit ?? 0), (v) => setField((cur) => ({ ...cur, tpmLimit: Number(v) || 0 }))))
|
|
2410
|
+
),
|
|
2396
2411
|
h('div', { className: 'krot-grid-3', style: { marginTop: '10px' } },
|
|
2397
2412
|
field(t('routingStrategyLabel'), h('select', {
|
|
2398
2413
|
className: 'krot-in',
|
package/lib/index.js
CHANGED
|
@@ -52,6 +52,17 @@ export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTru
|
|
|
52
52
|
export { notifySwitch, notifyExhaustion };
|
|
53
53
|
|
|
54
54
|
const NS = 'dsh-key-rotation';
|
|
55
|
+
|
|
56
|
+
// DSH serves a namespace's settings form from the volatile fields of its profile
|
|
57
|
+
// entry schema, and a volatile field holds a Volatile box rather than its value.
|
|
58
|
+
// Unwrap before any caller reads one, and read lazily: the Loader mutates the
|
|
59
|
+
// boxes in place and re-announces them with loader/volatile-update.
|
|
60
|
+
export function plainConfig(value) {
|
|
61
|
+
if (value === null || typeof value !== 'object') return value
|
|
62
|
+
if (Array.isArray(value)) return value.map(plainConfig)
|
|
63
|
+
if (typeof value.get === 'function') return plainConfig(value.get())
|
|
64
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
|
|
65
|
+
}
|
|
55
66
|
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
56
67
|
const PIAI_NS = 'llm-pi-ai';
|
|
57
68
|
const MARKER = '__dshKeyRotation';
|
|
@@ -107,6 +118,7 @@ const SettingsSchema = Schema.object({
|
|
|
107
118
|
selfHealingIntervalMinutes: Schema.number().default(30),
|
|
108
119
|
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency']).default('round-robin'),
|
|
109
120
|
rpmLimit: Schema.number().default(0),
|
|
121
|
+
tpmLimit: Schema.number().default(0),
|
|
110
122
|
webhookActionToken: Schema.string().role('secret').default(''),
|
|
111
123
|
expiryWarnDays: Schema.number().default(7),
|
|
112
124
|
switchNotify: Schema.boolean().default(false),
|
|
@@ -137,11 +149,15 @@ const SettingsSchema = Schema.object({
|
|
|
137
149
|
quotas: Schema.dict(Schema.object({
|
|
138
150
|
tokenLimit: Schema.number(),
|
|
139
151
|
})).default({}),
|
|
152
|
+
rpmLimit: Schema.number(),
|
|
153
|
+
tpmLimit: Schema.number(),
|
|
140
154
|
})).default({}),
|
|
141
155
|
cooldownMs: Schema.number(),
|
|
142
156
|
maxCooldownMs: Schema.number(),
|
|
143
157
|
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency']),
|
|
144
158
|
proactiveRateLimitGuard: Schema.boolean(),
|
|
159
|
+
rpmLimit: Schema.number(),
|
|
160
|
+
tpmLimit: Schema.number(),
|
|
145
161
|
})).default([...DEFAULT_PROVIDERS]),
|
|
146
162
|
});
|
|
147
163
|
|
|
@@ -233,17 +249,20 @@ export function apply(ctx, config = {}) {
|
|
|
233
249
|
let lastProfilesRef = null;
|
|
234
250
|
|
|
235
251
|
function buildRuntime() {
|
|
236
|
-
const
|
|
252
|
+
const rawConfig = getConfig();
|
|
253
|
+
// Identity check below stays on the raw object: plainConfig allocates a new
|
|
254
|
+
// object each call, which would defeat the runtime memo cache.
|
|
255
|
+
const cfg = plainConfig(rawConfig ?? {});
|
|
237
256
|
let profilesSnapshot = null;
|
|
238
257
|
try {
|
|
239
258
|
profilesSnapshot = readProfiles();
|
|
240
259
|
} catch {
|
|
241
260
|
profilesSnapshot = null;
|
|
242
261
|
}
|
|
243
|
-
if (cachedRuntime &&
|
|
262
|
+
if (cachedRuntime && rawConfig === lastConfigRef && profilesSnapshot === lastProfilesRef) {
|
|
244
263
|
return cachedRuntime;
|
|
245
264
|
}
|
|
246
|
-
lastConfigRef =
|
|
265
|
+
lastConfigRef = rawConfig;
|
|
247
266
|
lastProfilesRef = profilesSnapshot;
|
|
248
267
|
|
|
249
268
|
moduleBreaker.configure({
|
|
@@ -261,6 +280,7 @@ export function apply(ctx, config = {}) {
|
|
|
261
280
|
const quotaResetWindow = cfg.quotaResetWindow ?? { type: 'midnight_utc', hour: 0 };
|
|
262
281
|
const rateLimitThreshold = cfg.rateLimitThreshold ?? 0.1;
|
|
263
282
|
const rpmLimit = cfg.rpmLimit ?? 0;
|
|
283
|
+
const tpmLimit = cfg.tpmLimit ?? 0;
|
|
264
284
|
const webhookActionToken = cfg.webhookActionToken ?? '';
|
|
265
285
|
|
|
266
286
|
const built = buildPools({ cfg, poolState, defaultState: initializePoolState });
|
|
@@ -306,7 +326,7 @@ export function apply(ctx, config = {}) {
|
|
|
306
326
|
|
|
307
327
|
cachedRuntime = {
|
|
308
328
|
switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold,
|
|
309
|
-
concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit,
|
|
329
|
+
concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, tpmLimit,
|
|
310
330
|
webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7,
|
|
311
331
|
switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false,
|
|
312
332
|
proactiveRateLimitGuard: cfg.proactiveRateLimitGuard ?? true,
|
|
@@ -362,7 +382,7 @@ export function apply(ctx, config = {}) {
|
|
|
362
382
|
let _rotateStartMs = Date.now();
|
|
363
383
|
function recordLatency(pool, reqStore) {
|
|
364
384
|
try {
|
|
365
|
-
const cfg = getConfig();
|
|
385
|
+
const cfg = plainConfig(getConfig());
|
|
366
386
|
if (!cfg || cfg.latencyEnabled === false) return;
|
|
367
387
|
const ref = reqStore?.pickedRef ?? pool?.state?.lastUsed;
|
|
368
388
|
if (!ref) return;
|
package/lib/ops-status.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
costForWeek,
|
|
14
14
|
isLoopbackAddress,
|
|
15
15
|
} from './pool.js';
|
|
16
|
-
import { bucketInfo } from './bucket.js';
|
|
16
|
+
import { bucketInfo, tpmInfo } from './bucket.js';
|
|
17
17
|
import { sanitizeSnapshot } from './sanitize-snapshot.js';
|
|
18
18
|
import {
|
|
19
19
|
STATUS_PATH,
|
|
@@ -101,6 +101,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
101
101
|
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
102
102
|
// #210: RPM capacity snapshot (null when rpmLimit is off)
|
|
103
103
|
rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
|
|
104
|
+
// TPM capacity snapshot (null when tpmLimit is off)
|
|
105
|
+
tpm: tpmInfo(pool.state.tpmWindows, ref, pool.tpmLimit, now),
|
|
104
106
|
// #215: effective round-robin weight of this key
|
|
105
107
|
weight: pool.weights?.[pool.refs.indexOf(ref)] ?? 1,
|
|
106
108
|
usage: pool.state.usageCounts?.get(ref) ?? 0,
|
package/lib/pool-builder.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// lib/pool-builder.js — provider & per-model pool assembly and cleanup
|
|
2
|
-
import { bucketSweep } from './bucket.js';
|
|
2
|
+
import { bucketSweep, tpmSweep } from './bucket.js';
|
|
3
3
|
import { isValidRef } from './pool.js';
|
|
4
4
|
import { normalizeModelQuotas } from './model-quota.js';
|
|
5
5
|
import { createPoolIndex, addProviderPools } from './pool-index.js';
|
|
@@ -26,6 +26,7 @@ export function buildPoolItem({
|
|
|
26
26
|
poolStrategy,
|
|
27
27
|
poolGuard,
|
|
28
28
|
rpmLimit,
|
|
29
|
+
tpmLimit = 0,
|
|
29
30
|
makeState,
|
|
30
31
|
provider = null,
|
|
31
32
|
model = null,
|
|
@@ -88,6 +89,7 @@ export function buildPoolItem({
|
|
|
88
89
|
maxCooldownMs: poolMax,
|
|
89
90
|
expiresAt: parsedExpiry,
|
|
90
91
|
rpmLimit,
|
|
92
|
+
tpmLimit: typeof tpmLimit === 'number' && Number.isFinite(tpmLimit) && tpmLimit > 0 ? Math.floor(tpmLimit) : 0,
|
|
91
93
|
routingStrategy: poolStrategy,
|
|
92
94
|
proactiveRateLimitGuard: poolGuard,
|
|
93
95
|
};
|
|
@@ -95,6 +97,7 @@ export function buildPoolItem({
|
|
|
95
97
|
|
|
96
98
|
export function buildPools({ cfg = {}, poolState = new Map(), defaultState = initializePoolState } = {}) {
|
|
97
99
|
const rpmLimit = cfg.rpmLimit ?? 0;
|
|
100
|
+
const tpmLimit = cfg.tpmLimit ?? 0;
|
|
98
101
|
const quotaResetWindow = cfg.quotaResetWindow ?? null;
|
|
99
102
|
const poolByRef = new Map();
|
|
100
103
|
const providerToPool = new Map();
|
|
@@ -111,19 +114,21 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
111
114
|
return initializePoolState(st);
|
|
112
115
|
};
|
|
113
116
|
|
|
114
|
-
const buildPool = (base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, meta) =>
|
|
117
|
+
const buildPool = (base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, meta, pRpm = rpmLimit, pTpm = tpmLimit) =>
|
|
115
118
|
buildPoolItem({
|
|
116
119
|
base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard,
|
|
117
|
-
rpmLimit, makeState, quotaResetWindow, ...meta,
|
|
120
|
+
rpmLimit: pRpm, tpmLimit: pTpm, makeState, quotaResetWindow, ...meta,
|
|
118
121
|
});
|
|
119
122
|
|
|
120
123
|
for (const p of cfg.providers ?? []) {
|
|
121
124
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
122
125
|
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
126
|
+
const provRpm = typeof p.rpmLimit === 'number' ? p.rpmLimit : rpmLimit;
|
|
127
|
+
const provTpm = typeof p.tpmLimit === 'number' ? p.tpmLimit : tpmLimit;
|
|
123
128
|
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
|
|
124
129
|
provider: p.provider,
|
|
125
130
|
model: null,
|
|
126
|
-
});
|
|
131
|
+
}, provRpm, provTpm);
|
|
127
132
|
if (pool) {
|
|
128
133
|
for (const ref of pool.refs) {
|
|
129
134
|
if (!poolByRef.has(ref)) poolByRef.set(ref, pool);
|
|
@@ -133,11 +138,13 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
133
138
|
}
|
|
134
139
|
const byModel = new Map();
|
|
135
140
|
for (const [model, mp] of Object.entries(p.models ?? {})) {
|
|
141
|
+
const modelRpm = typeof mp.rpmLimit === 'number' ? mp.rpmLimit : provRpm;
|
|
142
|
+
const modelTpm = typeof mp.tpmLimit === 'number' ? mp.tpmLimit : provTpm;
|
|
136
143
|
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, mp.revoked, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard, {
|
|
137
144
|
provider: p.provider,
|
|
138
145
|
model,
|
|
139
146
|
quotas: mp.quotas,
|
|
140
|
-
});
|
|
147
|
+
}, modelRpm, modelTpm);
|
|
141
148
|
if (!mpool) continue;
|
|
142
149
|
byModel.set(model, mpool);
|
|
143
150
|
for (const ref of mpool.refs) {
|
|
@@ -187,6 +194,7 @@ export function cleanupRemovedProviders({
|
|
|
187
194
|
}
|
|
188
195
|
for (const st of poolState.values()) {
|
|
189
196
|
if (st.rpmWindows) bucketSweep(st.rpmWindows, liveRefs);
|
|
197
|
+
if (st.tpmWindows) tpmSweep(st.tpmWindows, liveRefs);
|
|
190
198
|
if (st.tokenUsage instanceof Map) {
|
|
191
199
|
for (const ref of [...st.tokenUsage.keys()]) {
|
|
192
200
|
if (!liveRefs.has(ref)) st.tokenUsage.delete(ref);
|
package/lib/pool-state.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const MAP_FIELDS = [
|
|
4
4
|
'failedUntil', 'failCounts', 'authFailCounts', 'brokenUntil', 'costPerKey',
|
|
5
5
|
'lastUsedAt', 'usageCounts', 'byModel', 'usageDays', 'quotaWindows', 'costDays',
|
|
6
|
-
'tokenUsage',
|
|
6
|
+
'tokenUsage', 'rpmWindows', 'tpmWindows',
|
|
7
7
|
];
|
|
8
8
|
|
|
9
9
|
export function initializePoolState(state = {}) {
|
package/lib/resolver.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// credentials service. It never sees or returns a secret value itself; it
|
|
10
10
|
// delegates to `original(ref)`.
|
|
11
11
|
|
|
12
|
-
import { bucketAllow, bucketRetryMs } from './bucket.js';
|
|
12
|
+
import { bucketAllow, bucketRetryMs, tpmAllow, tpmRetryMs } from './bucket.js';
|
|
13
13
|
import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked } from './pool.js';
|
|
14
14
|
import { decryptSecret } from './crypto-storage.js';
|
|
15
15
|
import { resolveRefPool, isQuotaManagedRequest } from './pool-index.js';
|
|
@@ -95,6 +95,17 @@ export function createResolver({
|
|
|
95
95
|
continue;
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
+
const tpmLimit = pool.tpmLimit ?? 0;
|
|
99
|
+
if (tpmLimit > 0) {
|
|
100
|
+
if (!pool.state.tpmWindows) pool.state.tpmWindows = new Map();
|
|
101
|
+
if (!tpmAllow(pool.state.tpmWindows, candidate, tpmLimit, at)) {
|
|
102
|
+
const waitMs = tpmRetryMs(pool.state.tpmWindows, candidate, tpmLimit, at);
|
|
103
|
+
if ((pool.state.failedUntil.get(candidate) ?? 0) < at + waitMs) {
|
|
104
|
+
pool.state.failedUntil.set(candidate, at + waitMs);
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
98
109
|
if (pool.perHour) {
|
|
99
110
|
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
100
111
|
let win = pool.state.quotaWindows.get(candidate);
|
package/lib/rotate.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from './pool.js';
|
|
17
17
|
import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
|
|
18
18
|
import { nextQuotaReset } from './quota-window.js';
|
|
19
|
+
import { tpmRecord } from './bucket.js';
|
|
19
20
|
import {
|
|
20
21
|
isModelQuotaAvailable,
|
|
21
22
|
hasModelQuotaConfig,
|
|
@@ -302,9 +303,16 @@ export function createRotate(deps) {
|
|
|
302
303
|
if (activeRef) {
|
|
303
304
|
recordSuccess(pool, activeRef, now());
|
|
304
305
|
}
|
|
306
|
+
if (activeRef) {
|
|
307
|
+
const usedTokens = extractUsageTokens(chunk?.usage ?? chunk?.metadata?.usage);
|
|
308
|
+
if (usedTokens && usedTokens > 0) {
|
|
309
|
+
if (!pool.state.tpmWindows) pool.state.tpmWindows = new Map();
|
|
310
|
+
tpmRecord(pool.state.tpmWindows, activeRef, usedTokens, Date.now());
|
|
311
|
+
}
|
|
312
|
+
}
|
|
305
313
|
if (activeRef && quotaManaged) {
|
|
306
314
|
const charged = consumeModelTokens(
|
|
307
|
-
pool, activeRef, extractUsageTokens(chunk
|
|
315
|
+
pool, activeRef, extractUsageTokens(chunk?.usage ?? chunk?.metadata?.usage),
|
|
308
316
|
runtime0.quotaResetWindow, Date.now(),
|
|
309
317
|
);
|
|
310
318
|
if (charged) {
|
package/lib/sanitize-snapshot.js
CHANGED
|
@@ -45,6 +45,14 @@ export function sanitizeKeyStatus(key, now = Date.now()) {
|
|
|
45
45
|
limit: clampNonNegative(key.rpm.limit),
|
|
46
46
|
}
|
|
47
47
|
: key.rpm,
|
|
48
|
+
tpm: key.tpm && typeof key.tpm === 'object'
|
|
49
|
+
? {
|
|
50
|
+
...key.tpm,
|
|
51
|
+
used: clampNonNegative(key.tpm.used),
|
|
52
|
+
remaining: clampNonNegative(key.tpm.remaining),
|
|
53
|
+
limit: clampNonNegative(key.tpm.limit),
|
|
54
|
+
}
|
|
55
|
+
: key.tpm,
|
|
48
56
|
};
|
|
49
57
|
}
|
|
50
58
|
|
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.38",
|
|
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": [
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
60
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
60
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
61
61
|
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.1"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|