@goodandready/dsh-key-rotation 0.8.65 → 0.8.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.66 - 2026-10-19
4
+
5
+ ### Features
6
+ - **Dynamic Reset Scheduler Based on Rate-Limit Headers (#498)**:
7
+ - Exact cooldown alignment: parses upstream `retry-after`, `x-ratelimit-reset-*`, and `anthropic-ratelimit-*-reset` headers or message duration patterns upon rate-limit failures, setting precise cooldown `failedUntil = now + dynamicCooldownMs` (+250ms safety jitter, bounded by `maxCooldownMs`) with `resetReason = 'header'`.
8
+ - Immediate unref recovery timer: schedules an exact unref `setTimeout` wake-up for the penalised key right when the upstream window rolls over, rather than waiting up to 30s for the periodic sweep.
9
+ - Exposes `resetReason` and `headerResetMs` in `/dsh-key-rotation/status` and WebUI countdown badge (`⏳ reset in Xs (header)`).
10
+ - **Upstream Balance Inspector & Credit Tracking (#495)**:
11
+ - Multi-provider balance checker (`lib/balance-inspector.js`): queries live account balances and remaining credits for DeepSeek (`/user/balance`), SiliconFlow (`/v1/user/info`), OpenRouter (`/api/v1/auth/key`), and OpenAI (`/v1/dashboard/billing/credit_grants`).
12
+ - Balance ops route `GET/POST /dsh-key-rotation/balance` (`lib/ops-balance.js`): queries cached balances or triggers immediate live balance inspection across provider keys.
13
+ - Pre-emptive empty balance protection: keys with confirmed zero balance (`isZero: true`) enter a soft pause (`EMPTY_BALANCE_PAUSED`), avoiding upstream dispatch failures, and auto-resume immediately when refreshed with positive balance.
14
+ - Persistent balance state: balances are cached in `pool.state.balances` and persisted across harness restarts via `lib/persistence.js`.
15
+ - WebUI integration: interactive `Check Balances` action button in provider footer and real-time balance badge pills (`💰 15.50 CNY`, `💰 $4.20`) on key cards.
16
+
3
17
  ## 0.8.65 - 2026-10-18
4
18
 
5
19
  ### Features
package/README.md CHANGED
@@ -67,6 +67,10 @@
67
67
  - **📡 Status extras**: per-provider `circuit` plus `meta.expectedClones` / `meta.notifyQueue`.
68
68
  - **🧪 Smoke harness**: scripted 429 → next-key → success path in `test/smoke-rotation-080.test.mjs`.
69
69
 
70
+ ### 🚀 What's New in v0.8.66
71
+ - **⏳ Dynamic Reset Scheduler (#498)**: Reads upstream `retry-after` and `x-ratelimit-reset-*` headers to set exact cooldowns (+250ms safety jitter) and schedules immediate unref wake-up timers when the upstream reset window rolls over.
72
+ - **💰 Upstream Balance Inspector & Credit Tracking (#495)**: Live balance checks for DeepSeek, SiliconFlow, OpenRouter, and OpenAI via `/dsh-key-rotation/balance`, pre-emptive empty balance protection (`EMPTY_BALANCE_PAUSED`), persistent balance cache, and WebUI `💰 Balance` pills.
73
+
70
74
  ### 🚀 What's New in v0.8.65
71
75
  - **⚡ Leaky Bucket Traffic Smoothing (#496)**: Micro-queue smoothing absorbs rapid multi-agent burst spikes ($\le 1$s) without triggering unnecessary 429 errors or cooldown churning, with zero-delay burst capacity and early failover rotation on queue overflow.
72
76
  - **📊 Dynamic Upstream Rate Limits & Presets (#503)**: Dynamic parsing of provider rate limit headers (`x-ratelimit-*`, `anthropic-ratelimit-*`, `retry-after`), live badge display on keys, and 1-click `⚡ Preset Limits` button applying canonical RPM/TPM presets for DeepSeek, Groq, Anthropic, OpenAI, Gemini, and more.
package/README.ru.md CHANGED
@@ -36,6 +36,10 @@
36
36
 
37
37
  ---
38
38
 
39
+ ### 🚀 Что нового в v0.8.66
40
+ - **⏳ Динамический планировщик сброса кулдауна (#498)**: Точное вычисление времени восстановления ключей по заголовкам `retry-after` и `x-ratelimit-reset-*` (+250мс джиттер) с запуском точного unref-таймера восстановления сразу по истечении окна, не дожидаясь 30-секундного периодического свипа.
41
+ - **💰 Инспектор баланса и отслеживание кредитов (#495)**: Запрос реального баланса провайдеров (DeepSeek, SiliconFlow, OpenRouter, OpenAI) через маршрут `/dsh-key-rotation/balance`, автоматическая мягкая пауза при нулевом балансе (`EMPTY_BALANCE_PAUSED`), персистентное сохранение и плашки баланса в WebUI карточках ключей.
42
+
39
43
  ### 🚀 Что нового в v0.8.65
40
44
  - **⚡ Сглаживание трафика (Traffic Smoothing Micro-Queue) (#496)**: Микро-очередь leaky bucket сглаживает кратковременные пики нагрузки (<=1с) от мультиагентных вызовов без лишних ошибок 429 и лишних кулдаунов. При превышении таймаута ожидания микро-очереди запрос автоматически переключается на следующий ключ в пуле.
41
45
  - **📊 Динамическое определение лимитов провайдеров и пресеты (#503)**: Автоматический парсинг заголовков `x-ratelimit-*`, `anthropic-ratelimit-*`, `retry-after` с отображением актуальных лимитов прямо на карточке ключа, а также кнопка в 1 клик `⚡ Preset Limits` с каноническими пресетами для DeepSeek, Groq, Anthropic, OpenAI, Gemini и др.
package/README.zh.md CHANGED
@@ -38,6 +38,10 @@
38
38
 
39
39
  ## ⚡ 概述与核心痛点
40
40
 
41
+ ### 🚀 v0.8.66 新特性
42
+ - **⏳ 基于限流响应头的动态重置调度器 (#498)**: 解析上游 `retry-after` 与 `x-ratelimit-reset-*` 响应头,设定精确冷却时间(+250ms抖动保护),并通过独立 unref 定时器在窗口重置瞬间立刻唤醒可用密钥,无需等待30秒周期性轮询。
43
+ - **💰 上游余额与信用额度巡检 (#495)**: 支持 DeepSeek、SiliconFlow、OpenRouter、OpenAI 实时余额查询 (`/dsh-key-rotation/balance`),零余额前置软暂停 (`EMPTY_BALANCE_PAUSED`),持久化余额状态以及 WebUI 密钥卡片实时余额标签 (`💰 15.50 CNY`)。
44
+
41
45
  ### 🚀 v0.8.65 新特性
42
46
  - **⚡ 流量平滑微队列 (Traffic Smoothing Micro-Queue) (#496)**: 漏桶微队列平滑多智能体调用中的毫秒级突发并发,避免触发不必要的429限流与冷却流转。队列等待超限时自动轮转至池中下一个候选密钥。
43
47
  - **📊 动态上游限制检测与预设规则 (#503)**: 自动提取上游响应头中的限流数据 (`x-ratelimit-*`, `anthropic-ratelimit-*`, `retry-after`),并在前端展示实时配额状态;支持一键应用 DeepSeek、Groq、Anthropic、OpenAI、Gemini 等官方规范预设限制。
@@ -0,0 +1,176 @@
1
+ // lib/balance-inspector.js — upstream balance inspector and credit tracking (#495).
2
+
3
+ import { decryptSecret } from './crypto-storage.js';
4
+
5
+ /**
6
+ * Inspect upstream balance and credits for a provider API key.
7
+ * @param {string} provider Provider identifier (e.g., 'deepseek', 'siliconflow', 'openrouter', 'openai')
8
+ * @param {string} apiKey Raw API key
9
+ * @param {object} [options]
10
+ * @returns {Promise<{ ok: boolean, total?: number, currency?: string, isAvailable?: boolean, error?: string }>}
11
+ */
12
+ export async function checkProviderBalance(provider, apiKey, options = {}) {
13
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
14
+ const timeoutMs = typeof options.timeoutMs === 'number' && options.timeoutMs > 0 ? options.timeoutMs : 6000;
15
+ const signal = options.signal;
16
+ const prov = String(provider || '').toLowerCase().trim();
17
+
18
+ if (!apiKey || typeof apiKey !== 'string' || !apiKey.trim()) {
19
+ return { ok: false, error: 'missing api key' };
20
+ }
21
+
22
+ // 1. DeepSeek Official
23
+ if (prov.includes('deepseek')) {
24
+ try {
25
+ const url = 'https://api.deepseek.com/user/balance';
26
+ const ac = new AbortController();
27
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
28
+ const res = await fetchImpl(url, {
29
+ method: 'GET',
30
+ headers: {
31
+ 'Authorization': `Bearer ${apiKey.trim()}`,
32
+ 'Accept': 'application/json',
33
+ },
34
+ signal: signal ?? ac.signal,
35
+ });
36
+ clearTimeout(timer);
37
+ if (!res.ok) {
38
+ return { ok: false, status: res.status, error: `HTTP ${res.status}` };
39
+ }
40
+ const data = await res.json();
41
+ const info = data?.balance_infos?.[0];
42
+ const total = info ? parseFloat(info.total_balance) : 0;
43
+ const currency = info?.currency || 'CNY';
44
+ const isAvailable = Boolean(data?.is_available && (Number.isNaN(total) || total > 0));
45
+ return {
46
+ ok: true,
47
+ provider: 'deepseek',
48
+ total: Number.isFinite(total) ? total : 0,
49
+ currency,
50
+ isAvailable,
51
+ granted: parseFloat(info?.granted_balance || 0),
52
+ toppedUp: parseFloat(info?.topped_up_balance || 0),
53
+ };
54
+ } catch (err) {
55
+ return { ok: false, error: String(err?.message || err) };
56
+ }
57
+ }
58
+
59
+ // 2. SiliconFlow
60
+ if (prov.includes('siliconflow')) {
61
+ try {
62
+ const url = 'https://api.siliconflow.cn/v1/user/info';
63
+ const ac = new AbortController();
64
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
65
+ const res = await fetchImpl(url, {
66
+ method: 'GET',
67
+ headers: {
68
+ 'Authorization': `Bearer ${apiKey.trim()}`,
69
+ 'Accept': 'application/json',
70
+ },
71
+ signal: signal ?? ac.signal,
72
+ });
73
+ clearTimeout(timer);
74
+ if (!res.ok) {
75
+ return { ok: false, status: res.status, error: `HTTP ${res.status}` };
76
+ }
77
+ const json = await res.json();
78
+ const total = json?.data?.totalBalance !== undefined
79
+ ? parseFloat(json.data.totalBalance)
80
+ : (json?.data?.balance !== undefined ? parseFloat(json.data.balance) : 0);
81
+ const isAvailable = Number.isFinite(total) ? total > 0 : true;
82
+ return {
83
+ ok: true,
84
+ provider: 'siliconflow',
85
+ total: Number.isFinite(total) ? total : 0,
86
+ currency: 'CNY',
87
+ isAvailable,
88
+ };
89
+ } catch (err) {
90
+ return { ok: false, error: String(err?.message || err) };
91
+ }
92
+ }
93
+
94
+ // 3. OpenRouter
95
+ if (prov.includes('openrouter')) {
96
+ try {
97
+ const url = 'https://openrouter.ai/api/v1/auth/key';
98
+ const ac = new AbortController();
99
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
100
+ const res = await fetchImpl(url, {
101
+ method: 'GET',
102
+ headers: {
103
+ 'Authorization': `Bearer ${apiKey.trim()}`,
104
+ 'Accept': 'application/json',
105
+ },
106
+ signal: signal ?? ac.signal,
107
+ });
108
+ clearTimeout(timer);
109
+ if (!res.ok) {
110
+ return { ok: false, status: res.status, error: `HTTP ${res.status}` };
111
+ }
112
+ const json = await res.json();
113
+ const limit = json?.data?.limit;
114
+ const usage = json?.data?.usage ?? 0;
115
+ const total = limit != null ? Math.max(0, limit - usage) : null;
116
+ return {
117
+ ok: true,
118
+ provider: 'openrouter',
119
+ total: total != null ? total : 999,
120
+ currency: 'USD',
121
+ isAvailable: total == null || total > 0,
122
+ };
123
+ } catch (err) {
124
+ return { ok: false, error: String(err?.message || err) };
125
+ }
126
+ }
127
+
128
+ // 4. OpenAI Credit Grants
129
+ if (prov.includes('openai')) {
130
+ try {
131
+ const url = 'https://api.openai.com/v1/dashboard/billing/credit_grants';
132
+ const ac = new AbortController();
133
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
134
+ const res = await fetchImpl(url, {
135
+ method: 'GET',
136
+ headers: {
137
+ 'Authorization': `Bearer ${apiKey.trim()}`,
138
+ 'Accept': 'application/json',
139
+ },
140
+ signal: signal ?? ac.signal,
141
+ });
142
+ clearTimeout(timer);
143
+ if (!res.ok) {
144
+ return { ok: false, status: res.status, error: `HTTP ${res.status}` };
145
+ }
146
+ const data = await res.json();
147
+ const total = parseFloat(data?.total_available ?? 0);
148
+ return {
149
+ ok: true,
150
+ provider: 'openai',
151
+ total: Number.isFinite(total) ? total : 0,
152
+ currency: 'USD',
153
+ isAvailable: total > 0,
154
+ };
155
+ } catch (err) {
156
+ return { ok: false, error: String(err?.message || err) };
157
+ }
158
+ }
159
+
160
+ return { ok: false, error: `Provider '${provider}' does not support balance inspection` };
161
+ }
162
+
163
+ /**
164
+ * Resolve credential and inspect balance for a given ref.
165
+ */
166
+ export async function inspectBalanceForRef(ctx, ref, provider, { testValue, fetchImpl, timeoutMs } = {}) {
167
+ const base = ctx.get('credentials');
168
+ let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
169
+ let rawValue = testValue || hit?.value;
170
+ if (!rawValue && process.env[ref]) rawValue = process.env[ref];
171
+ if (typeof rawValue === 'string' && rawValue.startsWith('enc:v1:')) {
172
+ rawValue = decryptSecret(rawValue);
173
+ }
174
+ if (!rawValue) return { ok: false, error: 'no credential' };
175
+ return checkProviderBalance(provider, rawValue, { fetchImpl, timeoutMs });
176
+ }
package/lib/client.js CHANGED
@@ -104,6 +104,11 @@ window.__ModuleLoader__.load({
104
104
  presetNone: 'No canonical preset for this provider',
105
105
  detectedLimitsLabel: 'Upstream: {rpm} RPM · {tpm} TPM',
106
106
  trafficSmoothingLabel: 'Traffic Smoothing (Burst)',
107
+ balanceLabel: 'Balance: {amt} {curr}',
108
+ balanceEmpty: 'Balance Empty (Paused)',
109
+ balanceCheckBtn: '💰 Check Balance',
110
+ checkingBalance: 'Checking…',
111
+ upstreamResetIn: '⚡ Reset in {time} (header)',
107
112
  pauseKey: 'Pause key from rotation',
108
113
  resumeKey: 'Resume key rotation',
109
114
  sparklineTraffic: 'Traffic Trend',
@@ -311,6 +316,11 @@ window.__ModuleLoader__.load({
311
316
  presetNone: '未找到该提供商预设规则',
312
317
  detectedLimitsLabel: '上游限制:{rpm} RPM · {tpm} TPM',
313
318
  trafficSmoothingLabel: '流量平滑 (突发并发)',
319
+ balanceLabel: '余额:{amt} {curr}',
320
+ balanceEmpty: '余额不足已暂停',
321
+ balanceCheckBtn: '💰 检查余额',
322
+ checkingBalance: '查询中…',
323
+ upstreamResetIn: '⚡ {time}后重置 (上游头信息)',
314
324
  pauseKey: '暂停此密钥轮换',
315
325
  resumeKey: '恢复此密钥轮换',
316
326
  sparklineTraffic: '流量趋势',
@@ -1567,6 +1577,26 @@ window.__ModuleLoader__.load({
1567
1577
 
1568
1578
  const [validating, setValidating] = React.useState('');
1569
1579
  const [presetNotice, setPresetNotice] = React.useState('');
1580
+ const [checkingBalance, setCheckingBalance] = React.useState('');
1581
+ const checkBalances = async (pIndex, provName) => {
1582
+ setCheckingBalance(provName);
1583
+ try {
1584
+ const r = await fetch('/dsh-key-rotation/balance', {
1585
+ method: 'POST',
1586
+ headers: { 'Content-Type': 'application/json' },
1587
+ body: JSON.stringify({ provider: provName }),
1588
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined,
1589
+ });
1590
+ const data = await r.json();
1591
+ if (data && data.ok) {
1592
+ load();
1593
+ }
1594
+ } catch (err) {
1595
+ setSecretError(String(err?.message || err));
1596
+ } finally {
1597
+ setCheckingBalance('');
1598
+ }
1599
+ };
1570
1600
  const applyPreset = async (pIndex, provName) => {
1571
1601
  try {
1572
1602
  const r = await fetch('/dsh-key-rotation/preset-limits?provider=' + encodeURIComponent(provName));
@@ -2162,6 +2192,18 @@ window.__ModuleLoader__.load({
2162
2192
  if (info && info.broken) {
2163
2193
  meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
2164
2194
  }
2195
+ if (info && info.balance) {
2196
+ const b = info.balance;
2197
+ const isZero = b.isAvailable === false || (typeof b.total === 'number' && b.total <= 0);
2198
+ const cur = b.currency || '';
2199
+ const amtStr = typeof b.total === 'number' ? b.total.toFixed(2) : String(b.total || 0);
2200
+ meta.push(h('span', {
2201
+ key: 'bal',
2202
+ className: 'krot-tail' + (isZero ? ' krot-badge-bad' : ''),
2203
+ title: isZero ? t('balanceEmpty') : ('Upstream balance: ' + amtStr + ' ' + cur),
2204
+ style: isZero ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined,
2205
+ }, '💰 ' + amtStr + (cur ? (' ' + cur) : '')));
2206
+ }
2165
2207
  if (info && info.detectedLimits) {
2166
2208
  const dl = info.detectedLimits;
2167
2209
  const parts = [];
@@ -2509,6 +2551,10 @@ window.__ModuleLoader__.load({
2509
2551
  danger: false,
2510
2552
  onConfirm: () => doReset(entry.provider),
2511
2553
  }), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
2554
+ btn(checkingBalance === entry.provider ? t('checkingBalance') : ('💰 ' + t('balanceCheckBtn')), () => checkBalances(pIndex, entry.provider), {
2555
+ disabled: Boolean(checkingBalance) || !entry.provider.trim(),
2556
+ title: t('balanceCheckBtn'),
2557
+ }),
2512
2558
  btn(testAllProvider === entry.provider ? (testingProgress[entry.provider] ? t('testingAllProgress').replace('{n}', testingProgress[entry.provider].split('/')[0]).replace('{total}', testingProgress[entry.provider].split('/')[1]) : t('testing')) : t('testAll'), () => doTestAll(entry.provider), { disabled: Boolean(testing || testAllProvider), title: t('testAll') }),
2513
2559
  exportCsv,
2514
2560
  ),
@@ -0,0 +1,147 @@
1
+ // lib/ops-balance.js — operational HTTP routes for balance inspection (#495).
2
+
3
+ import {
4
+ json,
5
+ readJson,
6
+ } from './http-bridge.js';
7
+ import {
8
+ isTrustedBridgeRequest,
9
+ isValidRef,
10
+ } from './pool.js';
11
+ import { BALANCE_PATH } from './ops-paths.js';
12
+ import { inspectBalanceForRef } from './balance-inspector.js';
13
+ import { pushEvent } from './notify-events.js';
14
+
15
+ export function registerBalanceRoutes(ctx, deps) {
16
+ const {
17
+ poolState,
18
+ buildRuntime,
19
+ } = deps;
20
+
21
+ ctx.effect(() => ctx.webServer.register({
22
+ kind: 'exact',
23
+ path: BALANCE_PATH,
24
+ handler: async (req, res) => {
25
+ if (!isTrustedBridgeRequest(req)) {
26
+ json(res, 403, { ok: false, error: 'Forbidden' });
27
+ return;
28
+ }
29
+
30
+ const urlObj = new URL(req.url, 'http://127.0.0.1');
31
+
32
+ // GET: Query single key or provider cached balances
33
+ if (req.method === 'GET') {
34
+ const ref = urlObj.searchParams.get('ref');
35
+ const provider = urlObj.searchParams.get('provider');
36
+
37
+ if (ref) {
38
+ const runtime = buildRuntime();
39
+ const targetPool = provider ? runtime.pools?.find((p) => p.provider === provider) : null;
40
+ const pool = targetPool ?? runtime.poolByRef?.get(ref);
41
+ const cached = pool?.state?.balances?.get(ref) ?? null;
42
+
43
+ if (urlObj.searchParams.get('refresh') === 'true' && provider) {
44
+ const inspectRes = await inspectBalanceForRef(ctx, ref, provider);
45
+ if (inspectRes.ok) {
46
+ if (pool && pool.state) {
47
+ if (!pool.state.balances) pool.state.balances = new Map();
48
+ pool.state.balances.set(ref, { ...inspectRes, at: Date.now() });
49
+
50
+ // Check soft pause
51
+ applyBalanceState(pool, ref, inspectRes);
52
+ }
53
+ json(res, 200, { ok: true, ref, balance: inspectRes });
54
+ return;
55
+ }
56
+ json(res, 200, { ok: false, ref, error: inspectRes.error, cached });
57
+ return;
58
+ }
59
+
60
+ json(res, 200, { ok: true, ref, balance: cached });
61
+ return;
62
+ }
63
+
64
+ // Return all cached balances
65
+ const out = {};
66
+ if (poolState && typeof poolState.forEach === 'function') {
67
+ poolState.forEach((st, provName) => {
68
+ if (st && st.balances instanceof Map) {
69
+ for (const [k, v] of st.balances) {
70
+ out[k] = v;
71
+ }
72
+ }
73
+ });
74
+ }
75
+ json(res, 200, { ok: true, balances: out });
76
+ return;
77
+ }
78
+
79
+ // POST: Trigger refresh of balances for a provider or key list
80
+ if (req.method === 'POST') {
81
+ const body = await readJson(req);
82
+ const provider = body?.provider;
83
+ const ref = body?.ref;
84
+ const testValue = body?.value;
85
+
86
+ if (ref && provider) {
87
+ const inspectRes = await inspectBalanceForRef(ctx, ref, provider, { testValue });
88
+ const runtime = buildRuntime();
89
+ const pool = runtime.pools?.find((p) => p.provider === provider) ?? runtime.poolByRef?.get(ref);
90
+
91
+ if (inspectRes.ok && pool && pool.state) {
92
+ if (!pool.state.balances) pool.state.balances = new Map();
93
+ pool.state.balances.set(ref, { ...inspectRes, at: Date.now() });
94
+ applyBalanceState(pool, ref, inspectRes);
95
+ }
96
+
97
+ json(res, inspectRes.ok ? 200 : 400, { ok: inspectRes.ok, ref, balance: inspectRes });
98
+ return;
99
+ }
100
+
101
+ if (provider) {
102
+ const runtime = buildRuntime();
103
+ const pool = runtime.pools?.find((p) => p.provider === provider);
104
+ if (!pool || !Array.isArray(pool.refs) || pool.refs.length === 0) {
105
+ json(res, 404, { ok: false, error: `Provider '${provider}' not found or has no keys` });
106
+ return;
107
+ }
108
+
109
+ const results = {};
110
+ for (const kRef of pool.refs) {
111
+ const r = await inspectBalanceForRef(ctx, kRef, provider);
112
+ results[kRef] = r;
113
+ if (r.ok && pool.state) {
114
+ if (!pool.state.balances) pool.state.balances = new Map();
115
+ pool.state.balances.set(kRef, { ...r, at: Date.now() });
116
+ applyBalanceState(pool, kRef, r);
117
+ }
118
+ }
119
+ json(res, 200, { ok: true, provider, balances: results });
120
+ return;
121
+ }
122
+
123
+ json(res, 400, { ok: false, error: 'ref or provider required' });
124
+ return;
125
+ }
126
+
127
+ json(res, 405, { ok: false, error: 'Method Not Allowed' });
128
+ },
129
+ }), 'dsh-key-rotation: balance routes');
130
+ }
131
+
132
+ function applyBalanceState(pool, ref, balanceRes) {
133
+ if (!pool || !pool.state) return;
134
+ const isZero = balanceRes.isAvailable === false || (typeof balanceRes.total === 'number' && balanceRes.total <= 0);
135
+
136
+ if (isZero) {
137
+ if (!pool.state.pausedRefs) pool.state.pausedRefs = new Set();
138
+ if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.add(ref);
139
+ if (!pool.state.pauseReasons) pool.state.pauseReasons = new Map();
140
+ pool.state.pauseReasons.set(ref, 'EMPTY_BALANCE_PAUSED');
141
+ pushEvent(pool, ref, 'BALANCE_EMPTY', 0);
142
+ } else if (pool.state.pauseReasons?.get?.(ref) === 'EMPTY_BALANCE_PAUSED') {
143
+ if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.delete(ref);
144
+ pool.state.pauseReasons.delete(ref);
145
+ pushEvent(pool, ref, 'BALANCE_RESTORED', 0);
146
+ }
147
+ }
package/lib/ops-paths.js CHANGED
@@ -18,3 +18,4 @@ export const TEST_MATRIX_PATH = '/dsh-key-rotation/test-matrix';
18
18
  export const DISCOVER_PATH = '/dsh-key-rotation/discover';
19
19
  export const AUTO_IMPORT_PATH = '/dsh-key-rotation/auto-import';
20
20
  export const PRESET_LIMITS_PATH = '/dsh-key-rotation/preset-limits';
21
+ export const BALANCE_PATH = '/dsh-key-rotation/balance';
package/lib/ops-status.js CHANGED
@@ -124,6 +124,9 @@ ctx.effect(() => ctx.webServer.register({
124
124
  revoked: isKeyRevoked(pool, ref),
125
125
  modelCapabilities: pool.state.modelCapabilities?.get?.(ref) ? [...pool.state.modelCapabilities.get(ref)] : null,
126
126
  detectedLimits: pool.state.detectedLimits?.get?.(ref) ?? null,
127
+ balance: pool.state.balances?.get?.(ref) ?? null,
128
+ resetReason: pool.state.resetReasons?.get?.(ref) ?? null,
129
+ headerResetMs: pool.state.headerResetTimes?.get?.(ref) ?? null,
127
130
  modelQuota,
128
131
  modelPools: activeModelPools.map(mp => mp.base),
129
132
  });
@@ -138,6 +138,13 @@ export class StatePersistence {
138
138
  if (pausedRefs.length > 0) outPool.pausedRefs = pausedRefs;
139
139
  if (Object.keys(pauseReasons).length > 0) outPool.pauseReasons = pauseReasons;
140
140
  if (Object.keys(modelCapabilities).length > 0) outPool.modelCapabilities = modelCapabilities;
141
+ const balances = {};
142
+ if (st && st.balances && typeof st.balances.forEach === 'function') {
143
+ st.balances.forEach((bal, ref) => {
144
+ if (bal && typeof bal === 'object') balances[ref] = bal;
145
+ });
146
+ }
147
+ if (Object.keys(balances).length > 0) outPool.balances = balances;
141
148
  if (Object.keys(tokenUsage).length > 0) outPool.tokenUsage = tokenUsage;
142
149
  if (Object.keys(costDays).length > 0) outPool.costDays = costDays;
143
150
  if (Object.keys(costPerKey).length > 0) outPool.costPerKey = costPerKey;
@@ -241,6 +248,12 @@ export class StatePersistence {
241
248
  if (typeof reason === 'string') state.pauseReasons.set(ref, reason);
242
249
  }
243
250
  }
251
+ if (saved.balances && typeof saved.balances === 'object' && !Array.isArray(saved.balances)) {
252
+ if (!state.balances) state.balances = new Map();
253
+ for (const [ref, bal] of Object.entries(saved.balances)) {
254
+ if (bal && typeof bal === 'object') state.balances.set(ref, bal);
255
+ }
256
+ }
244
257
  if (saved.modelCapabilities && typeof saved.modelCapabilities === 'object' && !Array.isArray(saved.modelCapabilities)) {
245
258
  if (!state.modelCapabilities) state.modelCapabilities = new Map();
246
259
  for (const [ref, list] of Object.entries(saved.modelCapabilities)) {
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', 'rpmWindows', 'tpmWindows', 'sessionSticky', 'modelCapabilities', 'pauseReasons', 'detectedLimits',
6
+ 'tokenUsage', 'rpmWindows', 'tpmWindows', 'sessionSticky', 'modelCapabilities', 'pauseReasons', 'detectedLimits', 'balances', 'resetReasons', 'headerResetTimes',
7
7
  ];
8
8
 
9
9
  const SET_FIELDS = ['pausedRefs', 'revokedRefs'];
package/lib/rotate.js CHANGED
@@ -197,16 +197,51 @@ export function createRotate(deps) {
197
197
  }
198
198
  }
199
199
 
200
- const penalizeRef = (targetRef, errCode, errMsg) => {
200
+ const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
201
201
  if (!targetRef) return;
202
202
  if (sessionId) clearSessionSticky(pool, sessionId);
203
203
  if (errCode === LOCAL_QUOTA_CODE) return;
204
- const _retry = parseRetryAfter(errMsg);
204
+ let headerResetMs = null;
205
+ if (rawHeaders) {
206
+ const limits = (typeof rawHeaders === 'object' && rawHeaders.rpmResetMs !== undefined)
207
+ ? rawHeaders
208
+ : extractProviderLimits(rawHeaders);
209
+ headerResetMs = limits?.retryAfterMs ?? limits?.rpmResetMs ?? null;
210
+ }
211
+ if (!headerResetMs) {
212
+ const pRetry = parseRetryAfter(errMsg);
213
+ if (pRetry) headerResetMs = pRetry;
214
+ }
215
+
205
216
  const _base = pool.cooldownMs ?? cooldownMs;
206
- const _max = pool.maxCooldownMs ?? maxCooldownMs;
207
- const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
217
+ const _max = pool.maxCooldownMs ?? maxCooldownMs ?? 3600000;
218
+ let _b = 0;
208
219
  const cls = classifyFailure({ status: Number(errCode) || 0, code: errCode, message: errMsg });
209
- const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
220
+
221
+ if (headerResetMs && headerResetMs > 0) {
222
+ const dynamicCooldownMs = Math.min(_max, Math.max(1000, Math.floor(headerResetMs) + 250));
223
+ pool.state.failedUntil.set(targetRef, now() + dynamicCooldownMs);
224
+ if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
225
+ pool.state.resetReasons.set(targetRef, 'header');
226
+ if (!pool.state.headerResetTimes) pool.state.headerResetTimes = new Map();
227
+ pool.state.headerResetTimes.set(targetRef, dynamicCooldownMs);
228
+ _b = dynamicCooldownMs;
229
+ pushEvent(pool, targetRef, 'HEADER_RESET', dynamicCooldownMs);
230
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} dynamic cooldown set to ${dynamicCooldownMs}ms via upstream headers`);
231
+
232
+ const recTimer = setTimeout(() => {
233
+ if ((pool.state.failedUntil?.get?.(targetRef) ?? 0) <= now()) {
234
+ pool.state.failedUntil.delete(targetRef);
235
+ pushEvent(pool, targetRef, 'DYNAMIC_UNBREAK', 0);
236
+ }
237
+ }, dynamicCooldownMs + 50);
238
+ if (typeof recTimer.unref === 'function') recTimer.unref();
239
+ } else {
240
+ const _effBase = _base;
241
+ _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
242
+ if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
243
+ pool.state.resetReasons.set(targetRef, 'backoff');
244
+ }
210
245
 
211
246
  // #337 & #486 (GitHub #24): connect quota-window calendar reset for quota exhaustion
212
247
  // Transient rate limiting (RPM/TPM, requests/tokens per minute, concurrency)
@@ -409,7 +444,7 @@ export function createRotate(deps) {
409
444
  break;
410
445
  }
411
446
  const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
412
- penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
447
+ penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(curRef));
413
448
  lastFailure = finishError(e?.code ?? 'TRANSPORT',
414
449
  `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
415
450
  logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
@@ -434,6 +469,7 @@ export function createRotate(deps) {
434
469
  continue;
435
470
  }
436
471
  if (chunk && chunk.type === 'finish') {
472
+ const rawHdrs = chunk?.metadata?.headers ?? chunk?.headers ?? chunk?.reason?.failure?.headers;
437
473
  const kind = chunk.reason?.kind;
438
474
  const failure = chunk.reason?.failure;
439
475
  const code = failure?.code;
@@ -442,7 +478,7 @@ export function createRotate(deps) {
442
478
  const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
443
479
  const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
444
480
  if (switchable) {
445
- penalizeRef(activeRef, code ?? 'UNKNOWN', message);
481
+ penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
446
482
  pool.state.switches = (pool.state.switches ?? 0) + 1;
447
483
  pool.state.lastReason = String(code ?? 'UNKNOWN');
448
484
  pool.state.lastSwitchAt = now();
@@ -464,7 +500,7 @@ export function createRotate(deps) {
464
500
  // content delta we may penalize, but must never replay the request.
465
501
  if (kind === 'error' || kind === 'aborted') {
466
502
  if (kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes)) {
467
- penalizeRef(activeRef, code ?? 'UNKNOWN', message);
503
+ penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
468
504
  }
469
505
  yield chunk;
470
506
  return;
@@ -528,7 +564,6 @@ export function createRotate(deps) {
528
564
  // its quota or has retry-after, cool it down so the NEXT request starts on a different key.
529
565
  // We do NOT re-run this (already successful) request — that would double-send.
530
566
  // #503: Extract provider limits and reset headers
531
- const rawHdrs = chunk?.metadata?.headers ?? chunk?.headers;
532
567
  if (rawHdrs && activeRef) {
533
568
  const detected = extractProviderLimits(rawHdrs);
534
569
  if (detected) {
@@ -574,7 +609,7 @@ export function createRotate(deps) {
574
609
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
575
610
  const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
576
611
  if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
577
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
612
+ penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
578
613
  pool.state.switches = (pool.state.switches ?? 0) + 1;
579
614
  pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
580
615
  pool.state.lastSwitchAt = now();
@@ -591,7 +626,7 @@ export function createRotate(deps) {
591
626
  continue; // Failover to next key!
592
627
  }
593
628
  if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
594
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
629
+ penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
595
630
  }
596
631
  yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
597
632
  return;
package/lib/routes-ops.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { registerMetricsRoutes } from './ops-metrics.js';
2
+ import { registerBalanceRoutes } from './ops-balance.js';
2
3
  // lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312).
3
4
  // Thin orchestrator: each route family lives in its own lib/ops-*.js module.
4
5
  // Registration is injected with the live apply() dependencies.
@@ -20,4 +21,5 @@ export function registerOpsRoutes(ctx, deps) {
20
21
  registerTestRoutes(ctx, deps);
21
22
  registerWebhookActionRoute(ctx, deps);
22
23
  registerMetricsRoutes(ctx, deps);
24
+ registerBalanceRoutes(ctx, deps);
23
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.65",
3
+ "version": "0.8.66",
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": [