@goodandready/dsh-key-rotation 0.8.65 → 0.8.67
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 +31 -0
- package/README.md +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/balance-inspector.js +176 -0
- package/lib/client.js +344 -19
- package/lib/ops-balance.js +147 -0
- package/lib/ops-config-io.js +325 -0
- package/lib/ops-paths.js +4 -0
- package/lib/ops-quick-action.js +167 -0
- package/lib/ops-status.js +5 -0
- package/lib/persistence.js +13 -0
- package/lib/pool-state.js +1 -1
- package/lib/rotate.js +46 -11
- package/lib/routes-ops.js +8 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.67 - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- **Full Pool Configuration Export & Import (#504)**:
|
|
7
|
+
- Masked export: exports topology and pools with masked credentials (`sk-...cdef`) and hidden webhook secrets, safe for bug reports and sharing.
|
|
8
|
+
- Full backup export with AES-256-GCM encryption: optional passphrase encrypts configuration with PBKDF2 (100,000 iterations) using standard `node:crypto`.
|
|
9
|
+
- Config import route `POST /dsh-key-rotation/config-import`: supports file upload, raw JSON, or encrypted payload with passphrase decryption.
|
|
10
|
+
- Diff preview mode: `dryRun: true` returns summary of created/updated/removed providers and keys count before applying.
|
|
11
|
+
- Interactive UI in Settings card: modal drawers for Export (masked vs full, optional passphrase) and Import (file upload, merge vs replace modes, diff preview, apply).
|
|
12
|
+
- **Quick Key Actions in Chat Header Popover (#499)**:
|
|
13
|
+
- Extended `KeyRotationHeaderChip` popover: displays active provider and key tail (`...abcd`) for each configured pool.
|
|
14
|
+
- Interactive inline action buttons:
|
|
15
|
+
- ⏭️ **Skip**: advances active key cursor to the next healthy candidate in the pool without leaving chat.
|
|
16
|
+
- 🔄 **Reset**: immediately resets failure counts, cooldowns, and circuit breaker for the active provider.
|
|
17
|
+
- ⏸️ **Pause**: toggles pause state on the currently active key for immediate fault isolation.
|
|
18
|
+
- Endpoint `POST /dsh-key-rotation/quick-action`: secure loopback-validated operational route executing skip, reset, and pause commands with audit telemetry events.
|
|
19
|
+
|
|
20
|
+
## 0.8.66 - 2026-10-19
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
- **Dynamic Reset Scheduler Based on Rate-Limit Headers (#498)**:
|
|
24
|
+
- 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'`.
|
|
25
|
+
- 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.
|
|
26
|
+
- Exposes `resetReason` and `headerResetMs` in `/dsh-key-rotation/status` and WebUI countdown badge (`⏳ reset in Xs (header)`).
|
|
27
|
+
- **Upstream Balance Inspector & Credit Tracking (#495)**:
|
|
28
|
+
- 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`).
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
- Persistent balance state: balances are cached in `pool.state.balances` and persisted across harness restarts via `lib/persistence.js`.
|
|
32
|
+
- WebUI integration: interactive `Check Balances` action button in provider footer and real-time balance badge pills (`💰 15.50 CNY`, `💰 $4.20`) on key cards.
|
|
33
|
+
|
|
3
34
|
## 0.8.65 - 2026-10-18
|
|
4
35
|
|
|
5
36
|
### Features
|
package/README.md
CHANGED
|
@@ -67,6 +67,14 @@
|
|
|
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.67
|
|
71
|
+
- **📥 Full Pool Config Export & Import (#504)**: Masked export for safe sharing, full backup export with AES-256-GCM encryption (PBKDF2), diff preview mode, and import with merge/replace strategies.
|
|
72
|
+
- **⚡ Quick Actions in Chat Header Popover (#499)**: Instant key management directly from the conversation toolbar — skip to next key, reset cooldowns, or pause problematic keys via `/dsh-key-rotation/quick-action`.
|
|
73
|
+
|
|
74
|
+
### 🚀 What's New in v0.8.66
|
|
75
|
+
- **⏳ 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.
|
|
76
|
+
- **💰 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.
|
|
77
|
+
|
|
70
78
|
### 🚀 What's New in v0.8.65
|
|
71
79
|
- **⚡ 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
80
|
- **📊 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,14 @@
|
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
|
+
### 🚀 Что нового в v0.8.67
|
|
40
|
+
- **📥 Экспорт и импорт полной конфигурации пулов (#504)**: Безопасный маскированный экспорт для баг-репортов, полный бэкап с AES-256-GCM шифрованием (PBKDF2), интерактивный diff-предпросмотр и режимы объединения/замены.
|
|
41
|
+
- **⚡ Быстрые действия в поповере шапки чата (#499)**: Управление ключами прямо из тулбара диалога — принудительный пропуск (Skip) на следующий ключ, мгновенный сброс кулдаунов (Reset) и изоляция сбойного ключа (Pause).
|
|
42
|
+
|
|
43
|
+
### 🚀 Что нового в v0.8.66
|
|
44
|
+
- **⏳ Динамический планировщик сброса кулдауна (#498)**: Точное вычисление времени восстановления ключей по заголовкам `retry-after` и `x-ratelimit-reset-*` (+250мс джиттер) с запуском точного unref-таймера восстановления сразу по истечении окна, не дожидаясь 30-секундного периодического свипа.
|
|
45
|
+
- **💰 Инспектор баланса и отслеживание кредитов (#495)**: Запрос реального баланса провайдеров (DeepSeek, SiliconFlow, OpenRouter, OpenAI) через маршрут `/dsh-key-rotation/balance`, автоматическая мягкая пауза при нулевом балансе (`EMPTY_BALANCE_PAUSED`), персистентное сохранение и плашки баланса в WebUI карточках ключей.
|
|
46
|
+
|
|
39
47
|
### 🚀 Что нового в v0.8.65
|
|
40
48
|
- **⚡ Сглаживание трафика (Traffic Smoothing Micro-Queue) (#496)**: Микро-очередь leaky bucket сглаживает кратковременные пики нагрузки (<=1с) от мультиагентных вызовов без лишних ошибок 429 и лишних кулдаунов. При превышении таймаута ожидания микро-очереди запрос автоматически переключается на следующий ключ в пуле.
|
|
41
49
|
- **📊 Динамическое определение лимитов провайдеров и пресеты (#503)**: Автоматический парсинг заголовков `x-ratelimit-*`, `anthropic-ratelimit-*`, `retry-after` с отображением актуальных лимитов прямо на карточке ключа, а также кнопка в 1 клик `⚡ Preset Limits` с каноническими пресетами для DeepSeek, Groq, Anthropic, OpenAI, Gemini и др.
|
package/README.zh.md
CHANGED
|
@@ -38,6 +38,14 @@
|
|
|
38
38
|
|
|
39
39
|
## ⚡ 概述与核心痛点
|
|
40
40
|
|
|
41
|
+
### 🚀 v0.8.67 新特性
|
|
42
|
+
- **📥 密钥池完整配置导入与导出 (#504)**: 支持脱敏导出安全分享、AES-256-GCM 密码加密完整备份、导入差异预览 (Diff Preview) 以及合并/全量替换模式。
|
|
43
|
+
- **⚡ 聊天头部快捷键操作 (#499)**: 直接在对话工具栏气泡中管理密钥 — 快速跳过当前密钥 (Skip)、一键重置冷却与断路器 (Reset)、暂停故障密钥 (Pause)。
|
|
44
|
+
|
|
45
|
+
### 🚀 v0.8.66 新特性
|
|
46
|
+
- **⏳ 基于限流响应头的动态重置调度器 (#498)**: 解析上游 `retry-after` 与 `x-ratelimit-reset-*` 响应头,设定精确冷却时间(+250ms抖动保护),并通过独立 unref 定时器在窗口重置瞬间立刻唤醒可用密钥,无需等待30秒周期性轮询。
|
|
47
|
+
- **💰 上游余额与信用额度巡检 (#495)**: 支持 DeepSeek、SiliconFlow、OpenRouter、OpenAI 实时余额查询 (`/dsh-key-rotation/balance`),零余额前置软暂停 (`EMPTY_BALANCE_PAUSED`),持久化余额状态以及 WebUI 密钥卡片实时余额标签 (`💰 15.50 CNY`)。
|
|
48
|
+
|
|
41
49
|
### 🚀 v0.8.65 新特性
|
|
42
50
|
- **⚡ 流量平滑微队列 (Traffic Smoothing Micro-Queue) (#496)**: 漏桶微队列平滑多智能体调用中的毫秒级突发并发,避免触发不必要的429限流与冷却流转。队列等待超限时自动轮转至池中下一个候选密钥。
|
|
43
51
|
- **📊 动态上游限制检测与预设规则 (#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
|
+
}
|