@goodandready/dsh-key-rotation 0.8.37 → 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 CHANGED
@@ -1,12 +1,24 @@
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
+
3
15
  ## 0.8.37
4
16
 
5
17
  ### Fixed
6
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.
7
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.
8
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.
9
- - **`settings.plugin.item` registration removed**: retired before DSH 0.1.7-rc.2, so it only registered the card a second time on a seat that no longer exists.
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.
10
22
 
11
23
  ## 0.8.36
12
24
 
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 cfg = getConfig() ?? {};
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 && cfg === lastConfigRef && profilesSnapshot === lastProfilesRef) {
262
+ if (cachedRuntime && rawConfig === lastConfigRef && profilesSnapshot === lastProfilesRef) {
244
263
  return cachedRuntime;
245
264
  }
246
- lastConfigRef = cfg;
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,
@@ -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.usage),
315
+ pool, activeRef, extractUsageTokens(chunk?.usage ?? chunk?.metadata?.usage),
308
316
  runtime0.quotaResetWindow, Date.now(),
309
317
  );
310
318
  if (charged) {
@@ -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.37",
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.1",
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": {