@goodandready/dsh-key-rotation 0.8.64 → 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 +28 -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 +97 -0
- package/lib/index.js +4 -1
- package/lib/ops-balance.js +147 -0
- package/lib/ops-keys.js +38 -0
- package/lib/ops-paths.js +2 -0
- package/lib/ops-status.js +5 -0
- package/lib/persistence.js +13 -0
- package/lib/pool-builder.js +12 -0
- package/lib/pool-state.js +1 -1
- package/lib/provider-limits-detector.js +173 -0
- package/lib/rotate.js +75 -10
- package/lib/routes-ops.js +2 -0
- package/lib/traffic-smoother.js +96 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.8.65 - 2026-10-18
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
- **Traffic Smoothing Micro-Queue (#496)**:
|
|
21
|
+
- Leaky bucket traffic smoother (`lib/traffic-smoother.js`) absorbs short micro-bursts ($\le 1$s) across multi-agent workflows without tripping upstream per-second or per-minute rate limits or triggering cooldown churning.
|
|
22
|
+
- Configurable `trafficSmoothingBurst` (default 5 or `ceil(rpmLimit/12)`) and `maxQueueWaitMs` (default 800ms).
|
|
23
|
+
- If scheduled micro-queue delay exceeds `maxQueueWaitMs`, the smoother flags burst overflow, immediately rotating to the next candidate key in the pool.
|
|
24
|
+
- Live metric `smoothedRequests` exposed on provider status, with `TRAFFIC_SMOOTHED` audit events.
|
|
25
|
+
- **Accurate Dynamic Provider Limits & Canonical Presets (#503)**:
|
|
26
|
+
- Header limits detector (`lib/provider-limits-detector.js`): extracts dynamic rate limit headers (`x-ratelimit-limit-*`, `x-ratelimit-remaining-*`, `x-ratelimit-reset-*`, `anthropic-ratelimit-*`, `retry-after`) from upstream provider HTTP responses into `pool.state.detectedLimits`.
|
|
27
|
+
- Canonical provider presets catalog for DeepSeek, Groq, OpenCode, Anthropic, OpenAI, SiliconFlow, Mistral, Gemini, and OpenRouter with standard RPM, TPM, burst capacity, and calendar reset window specifications.
|
|
28
|
+
- Endpoint `GET/POST /dsh-key-rotation/preset-limits` to query and auto-apply canonical limits to configured providers.
|
|
29
|
+
- WebUI button `⚡ Preset Limits` for 1-click rate limit configuration and live upstream limits indicator badge (`⚡ 60 RPM · 100k TPM`).
|
|
30
|
+
|
|
3
31
|
## 0.8.64 - 2026-10-18
|
|
4
32
|
|
|
5
33
|
### 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.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
|
+
|
|
74
|
+
### 🚀 What's New in v0.8.65
|
|
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.
|
|
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.
|
|
77
|
+
|
|
70
78
|
### 🛠️ What's New in v0.7.33 (Stability & Bugfix Release)
|
|
71
79
|
- **🔍 Resolved Key Probing BaseURL**: Fixed `resolveBaseUrl` to map key credential refs to owning provider pools, restoring live `probeModels` testing.
|
|
72
80
|
- **🛡️ Guarded Cascade Recursion**: Prevented call stack overflow in cross-provider failover when circular cascade chains occur.
|
package/README.ru.md
CHANGED
|
@@ -36,6 +36,14 @@
|
|
|
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
|
+
|
|
43
|
+
### 🚀 Что нового в v0.8.65
|
|
44
|
+
- **⚡ Сглаживание трафика (Traffic Smoothing Micro-Queue) (#496)**: Микро-очередь leaky bucket сглаживает кратковременные пики нагрузки (<=1с) от мультиагентных вызовов без лишних ошибок 429 и лишних кулдаунов. При превышении таймаута ожидания микро-очереди запрос автоматически переключается на следующий ключ в пуле.
|
|
45
|
+
- **📊 Динамическое определение лимитов провайдеров и пресеты (#503)**: Автоматический парсинг заголовков `x-ratelimit-*`, `anthropic-ratelimit-*`, `retry-after` с отображением актуальных лимитов прямо на карточке ключа, а также кнопка в 1 клик `⚡ Preset Limits` с каноническими пресетами для DeepSeek, Groq, Anthropic, OpenAI, Gemini и др.
|
|
46
|
+
|
|
39
47
|
### 🚀 Что нового в v0.8.11 (обновление в один клик и quality gate)
|
|
40
48
|
|
|
41
49
|
- **Обновление плагина в карточке настроек**: текущая/последняя версия и кнопка установки без выхода из DSH (#307).
|
package/README.zh.md
CHANGED
|
@@ -38,6 +38,14 @@
|
|
|
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
|
+
|
|
45
|
+
### 🚀 v0.8.65 新特性
|
|
46
|
+
- **⚡ 流量平滑微队列 (Traffic Smoothing Micro-Queue) (#496)**: 漏桶微队列平滑多智能体调用中的毫秒级突发并发,避免触发不必要的429限流与冷却流转。队列等待超限时自动轮转至池中下一个候选密钥。
|
|
47
|
+
- **📊 动态上游限制检测与预设规则 (#503)**: 自动提取上游响应头中的限流数据 (`x-ratelimit-*`, `anthropic-ratelimit-*`, `retry-after`),并在前端展示实时配额状态;支持一键应用 DeepSeek、Groq、Anthropic、OpenAI、Gemini 等官方规范预设限制。
|
|
48
|
+
|
|
41
49
|
### 🚀 v0.8.11 新特性(一键更新与质量门禁)
|
|
42
50
|
|
|
43
51
|
- **设置卡片中的插件更新器**:查看当前/最新版本并一键安装(#307)。
|
|
@@ -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
|
@@ -99,6 +99,16 @@ window.__ModuleLoader__.load({
|
|
|
99
99
|
keyPaused: 'Paused',
|
|
100
100
|
keyRevoked: 'Revoked (401)',
|
|
101
101
|
keyAuthPaused: 'Auth Paused (401/403)',
|
|
102
|
+
presetLimitsBtn: '⚡ Preset Limits',
|
|
103
|
+
presetApplied: 'Applied {prov} preset limits ({rpm} RPM / {tpm} TPM)',
|
|
104
|
+
presetNone: 'No canonical preset for this provider',
|
|
105
|
+
detectedLimitsLabel: 'Upstream: {rpm} RPM · {tpm} TPM',
|
|
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)',
|
|
102
112
|
pauseKey: 'Pause key from rotation',
|
|
103
113
|
resumeKey: 'Resume key rotation',
|
|
104
114
|
sparklineTraffic: 'Traffic Trend',
|
|
@@ -301,6 +311,16 @@ window.__ModuleLoader__.load({
|
|
|
301
311
|
keyPaused: '已暂停',
|
|
302
312
|
keyRevoked: '已撤销 (401)',
|
|
303
313
|
keyAuthPaused: '身份验证失败已暂停 (401/403)',
|
|
314
|
+
presetLimitsBtn: '⚡ 预设限制',
|
|
315
|
+
presetApplied: '已应用 {prov} 预设限制 ({rpm} RPM / {tpm} TPM)',
|
|
316
|
+
presetNone: '未找到该提供商预设规则',
|
|
317
|
+
detectedLimitsLabel: '上游限制:{rpm} RPM · {tpm} TPM',
|
|
318
|
+
trafficSmoothingLabel: '流量平滑 (突发并发)',
|
|
319
|
+
balanceLabel: '余额:{amt} {curr}',
|
|
320
|
+
balanceEmpty: '余额不足已暂停',
|
|
321
|
+
balanceCheckBtn: '💰 检查余额',
|
|
322
|
+
checkingBalance: '查询中…',
|
|
323
|
+
upstreamResetIn: '⚡ {time}后重置 (上游头信息)',
|
|
304
324
|
pauseKey: '暂停此密钥轮换',
|
|
305
325
|
resumeKey: '恢复此密钥轮换',
|
|
306
326
|
sparklineTraffic: '流量趋势',
|
|
@@ -1556,6 +1576,51 @@ window.__ModuleLoader__.load({
|
|
|
1556
1576
|
};
|
|
1557
1577
|
|
|
1558
1578
|
const [validating, setValidating] = React.useState('');
|
|
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
|
+
};
|
|
1600
|
+
const applyPreset = async (pIndex, provName) => {
|
|
1601
|
+
try {
|
|
1602
|
+
const r = await fetch('/dsh-key-rotation/preset-limits?provider=' + encodeURIComponent(provName));
|
|
1603
|
+
const data = await r.json();
|
|
1604
|
+
if (data && data.preset) {
|
|
1605
|
+
setField((cur) => {
|
|
1606
|
+
const providers = [...(cur.providers ?? [])];
|
|
1607
|
+
const ent = { ...providers[pIndex] };
|
|
1608
|
+
if (data.preset.rpmLimit) ent.rpmLimit = data.preset.rpmLimit;
|
|
1609
|
+
if (data.preset.tpmLimit) ent.tpmLimit = data.preset.tpmLimit;
|
|
1610
|
+
providers[pIndex] = ent;
|
|
1611
|
+
return { ...cur, providers };
|
|
1612
|
+
});
|
|
1613
|
+
setPresetNotice(t('presetApplied').replace('{prov}', provName).replace('{rpm}', String(data.preset.rpmLimit)).replace('{tpm}', String(data.preset.tpmLimit)));
|
|
1614
|
+
setTimeout(() => setPresetNotice(''), 4000);
|
|
1615
|
+
} else {
|
|
1616
|
+
setPresetNotice(t('presetNone'));
|
|
1617
|
+
setTimeout(() => setPresetNotice(''), 3000);
|
|
1618
|
+
}
|
|
1619
|
+
} catch (err) {
|
|
1620
|
+
setPresetNotice(String(err?.message || err));
|
|
1621
|
+
setTimeout(() => setPresetNotice(''), 3000);
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1559
1624
|
const [validationResult, setValidationResult] = React.useState({});
|
|
1560
1625
|
const validateBeforeSave = (ref, value) => {
|
|
1561
1626
|
setValidating(ref);
|
|
@@ -2127,6 +2192,32 @@ window.__ModuleLoader__.load({
|
|
|
2127
2192
|
if (info && info.broken) {
|
|
2128
2193
|
meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
|
|
2129
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
|
+
}
|
|
2207
|
+
if (info && info.detectedLimits) {
|
|
2208
|
+
const dl = info.detectedLimits;
|
|
2209
|
+
const parts = [];
|
|
2210
|
+
if (dl.rpmLimit) parts.push(dl.rpmLimit + ' RPM');
|
|
2211
|
+
if (dl.tpmLimit) parts.push(dl.tpmLimit + ' TPM');
|
|
2212
|
+
if (parts.length > 0) {
|
|
2213
|
+
meta.push(h('span', {
|
|
2214
|
+
key: 'dl',
|
|
2215
|
+
className: 'krot-tail',
|
|
2216
|
+
title: 'Live upstream limits:\n' + JSON.stringify(dl, null, 2),
|
|
2217
|
+
style: { fontSize: '11px', opacity: 0.8, color: 'var(--dsw-alias-brand-primary)' }
|
|
2218
|
+
}, '⚡ ' + parts.join(' · ')));
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2130
2221
|
if (info && info.modelCapabilities && info.modelCapabilities.length > 0) {
|
|
2131
2222
|
meta.push(h('span', {
|
|
2132
2223
|
key: 'caps',
|
|
@@ -2368,6 +2459,8 @@ window.__ModuleLoader__.load({
|
|
|
2368
2459
|
});
|
|
2369
2460
|
}, { title: t('sortUsage') }),
|
|
2370
2461
|
h('select', { className: 'krot-in', value: entry.provider, onChange: (e) => setProvider(pIndex, e.target.value) }, options),
|
|
2462
|
+
btn(t('presetLimitsBtn') || '⚡ Preset Limits', () => applyPreset(pIndex, entry.provider), { title: t('presetLimitsBtn') || '⚡ Preset Limits', disabled: !entry.provider.trim() || !stateRef.current.writable }),
|
|
2463
|
+
presetNotice ? h('span', { className: 'krot-badge krot-badge-ok', style: { marginLeft: '4px' } }, presetNotice) : null,
|
|
2371
2464
|
(() => {
|
|
2372
2465
|
const ps = status[entry.provider];
|
|
2373
2466
|
const score = ps && typeof ps.healthScore === 'number' ? ps.healthScore : null;
|
|
@@ -2458,6 +2551,10 @@ window.__ModuleLoader__.load({
|
|
|
2458
2551
|
danger: false,
|
|
2459
2552
|
onConfirm: () => doReset(entry.provider),
|
|
2460
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
|
+
}),
|
|
2461
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') }),
|
|
2462
2559
|
exportCsv,
|
|
2463
2560
|
),
|
package/lib/index.js
CHANGED
|
@@ -33,6 +33,7 @@ import { registerOpsRoutes } from './routes-ops.js';
|
|
|
33
33
|
import { registerPluginUpdater } from './plugin-updater.js';
|
|
34
34
|
import { notifySwitch, notifyExhaustion, notifyEarlyWarning, pushEvent } from './notify-events.js';
|
|
35
35
|
import { QUOTA_WINDOW_TYPES } from './quota-window.js';
|
|
36
|
+
import { TrafficSmoother } from './traffic-smoother.js';
|
|
36
37
|
import { getLogger } from './logger.js';
|
|
37
38
|
import { createSandboxService } from './sandbox-service.js';
|
|
38
39
|
import { checkBudgetAndHealthAlerts } from './budget-monitor.js';
|
|
@@ -198,6 +199,7 @@ export function apply(ctx, config = {}) {
|
|
|
198
199
|
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
199
200
|
const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
|
|
200
201
|
const moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
|
|
202
|
+
const trafficSmoother = new TrafficSmoother({ maxQueueWaitMs: 800 });
|
|
201
203
|
|
|
202
204
|
const sandboxService = createSandboxService({ getRuntime: () => buildRuntime() });
|
|
203
205
|
const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
|
|
@@ -355,7 +357,7 @@ export function apply(ctx, config = {}) {
|
|
|
355
357
|
switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
|
|
356
358
|
warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
|
|
357
359
|
index, pools,
|
|
358
|
-
breaker: moduleBreaker, notifyQueue: moduleNotifyQueue, concurrencyTracker, quotaStore, latencyHistogram,
|
|
360
|
+
breaker: moduleBreaker, notifyQueue: moduleNotifyQueue, concurrencyTracker, quotaStore, latencyHistogram, trafficSmoother,
|
|
359
361
|
providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
|
|
360
362
|
cloneIds, expectedClones,
|
|
361
363
|
circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
|
|
@@ -434,6 +436,7 @@ export function apply(ctx, config = {}) {
|
|
|
434
436
|
MARKER,
|
|
435
437
|
finishError,
|
|
436
438
|
setRotateStartMs: (v) => { _rotateStartMs = v; },
|
|
439
|
+
trafficSmoother,
|
|
437
440
|
quotaStore,
|
|
438
441
|
circuitBreaker: moduleBreaker,
|
|
439
442
|
now: nowMono,
|
|
@@ -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-keys.js
CHANGED
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
DRAIN_PATH,
|
|
23
23
|
DISCOVER_PATH,
|
|
24
24
|
AUTO_IMPORT_PATH,
|
|
25
|
+
PRESET_LIMITS_PATH,
|
|
25
26
|
} from './ops-paths.js';
|
|
27
|
+
import { lookupPreset, CANONICAL_PROVIDER_PRESETS } from './provider-limits-detector.js';
|
|
26
28
|
import fs from 'node:fs';
|
|
27
29
|
import os from 'node:os';
|
|
28
30
|
import path from 'node:path';
|
|
@@ -539,4 +541,40 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
539
541
|
},
|
|
540
542
|
}), 'dsh-key-rotation: auto-import route');
|
|
541
543
|
|
|
544
|
+
ctx.effect(() => ctx.webServer.register({
|
|
545
|
+
kind: 'exact',
|
|
546
|
+
path: PRESET_LIMITS_PATH,
|
|
547
|
+
handler: async (req, res) => {
|
|
548
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
549
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
550
|
+
res.end(JSON.stringify({ ok: false, error: 'Forbidden' }));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (req.method === 'GET') {
|
|
554
|
+
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
|
555
|
+
const prov = urlObj.searchParams.get('provider');
|
|
556
|
+
if (prov) {
|
|
557
|
+
const preset = lookupPreset(prov);
|
|
558
|
+
json(res, 200, { ok: true, provider: prov, preset });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
json(res, 200, { ok: true, presets: CANONICAL_PROVIDER_PRESETS });
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (req.method === 'POST') {
|
|
565
|
+
const body = await readJson(req);
|
|
566
|
+
const prov = body?.provider;
|
|
567
|
+
const preset = lookupPreset(prov);
|
|
568
|
+
if (!preset) {
|
|
569
|
+
json(res, 404, { ok: false, error: `No canonical preset found for provider '${prov}'` });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
json(res, 200, { ok: true, provider: prov, preset });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
576
|
+
res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
|
|
577
|
+
},
|
|
578
|
+
}), 'dsh-key-rotation: preset limits route');
|
|
579
|
+
|
|
542
580
|
}
|
package/lib/ops-paths.js
CHANGED
|
@@ -17,3 +17,5 @@ export const DRAIN_PATH = '/dsh-key-rotation/drain';
|
|
|
17
17
|
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
|
+
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
|
@@ -123,6 +123,10 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
123
123
|
pauseReason: pool.state.pauseReasons?.get?.(ref) ?? null,
|
|
124
124
|
revoked: isKeyRevoked(pool, ref),
|
|
125
125
|
modelCapabilities: pool.state.modelCapabilities?.get?.(ref) ? [...pool.state.modelCapabilities.get(ref)] : null,
|
|
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,
|
|
126
130
|
modelQuota,
|
|
127
131
|
modelPools: activeModelPools.map(mp => mp.base),
|
|
128
132
|
});
|
|
@@ -172,6 +176,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
172
176
|
pauseOnBudget: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.pauseOnBudget ?? false,
|
|
173
177
|
routingStrategy: pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin',
|
|
174
178
|
proactiveRateLimitGuard: pool.proactiveRateLimitGuard ?? runtime.proactiveRateLimitGuard ?? true,
|
|
179
|
+
smoothedRequests: pool.state.smoothedRequests ?? 0,
|
|
175
180
|
});
|
|
176
181
|
} catch (e) {
|
|
177
182
|
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
|
package/lib/persistence.js
CHANGED
|
@@ -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-builder.js
CHANGED
|
@@ -37,6 +37,8 @@ export function buildPoolItem({
|
|
|
37
37
|
labels = null,
|
|
38
38
|
pinnedRef = null,
|
|
39
39
|
useKeyRef = null,
|
|
40
|
+
trafficSmoothingBurst = null,
|
|
41
|
+
maxQueueWaitMs = null,
|
|
40
42
|
}) {
|
|
41
43
|
const seen = new Set();
|
|
42
44
|
const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
|
|
@@ -135,6 +137,12 @@ const weightsMap = {};
|
|
|
135
137
|
proactiveRateLimitGuard: poolGuard,
|
|
136
138
|
labels: labelsMap,
|
|
137
139
|
pinnedRef: cleanPinned,
|
|
140
|
+
trafficSmoothingBurst: typeof trafficSmoothingBurst === 'number' && Number.isFinite(trafficSmoothingBurst) && trafficSmoothingBurst > 0
|
|
141
|
+
? Math.floor(trafficSmoothingBurst)
|
|
142
|
+
: (typeof rpmLimit === 'number' && rpmLimit > 0 ? Math.max(2, Math.ceil(rpmLimit / 12)) : 5),
|
|
143
|
+
maxQueueWaitMs: typeof maxQueueWaitMs === 'number' && Number.isFinite(maxQueueWaitMs) && maxQueueWaitMs >= 0
|
|
144
|
+
? Math.floor(maxQueueWaitMs)
|
|
145
|
+
: 800,
|
|
138
146
|
};
|
|
139
147
|
}
|
|
140
148
|
|
|
@@ -175,6 +183,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
175
183
|
model: null,
|
|
176
184
|
labels: p.labels,
|
|
177
185
|
pinnedRef: p.pinnedRef ?? p.useKeyRef,
|
|
186
|
+
trafficSmoothingBurst: p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
|
|
187
|
+
maxQueueWaitMs: p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
|
|
178
188
|
}, provRpm, provTpm, provConcurrency);
|
|
179
189
|
if (pool) {
|
|
180
190
|
for (const ref of pool.refs) {
|
|
@@ -203,6 +213,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
203
213
|
quotas: mp.quotas,
|
|
204
214
|
labels: mp.labels ?? p.labels,
|
|
205
215
|
pinnedRef: mp.pinnedRef ?? mp.useKeyRef ?? p.pinnedRef ?? p.useKeyRef,
|
|
216
|
+
trafficSmoothingBurst: mp.trafficSmoothingBurst ?? p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
|
|
217
|
+
maxQueueWaitMs: mp.maxQueueWaitMs ?? p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
|
|
206
218
|
}, modelRpm, modelTpm, modelConcurrency);
|
|
207
219
|
if (!mpool) continue;
|
|
208
220
|
mpool.basePool = pool;
|
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',
|
|
6
|
+
'tokenUsage', 'rpmWindows', 'tpmWindows', 'sessionSticky', 'modelCapabilities', 'pauseReasons', 'detectedLimits', 'balances', 'resetReasons', 'headerResetTimes',
|
|
7
7
|
];
|
|
8
8
|
|
|
9
9
|
const SET_FIELDS = ['pausedRefs', 'revokedRefs'];
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// lib/provider-limits-detector.js — accurate dynamic provider limits and presets (#503).
|
|
2
|
+
|
|
3
|
+
export const CANONICAL_PROVIDER_PRESETS = {
|
|
4
|
+
deepseek: {
|
|
5
|
+
rpmLimit: 60,
|
|
6
|
+
tpmLimit: 100000,
|
|
7
|
+
trafficSmoothingBurst: 5,
|
|
8
|
+
quotaResetWindow: 'midnight_utc',
|
|
9
|
+
displayName: 'DeepSeek Official',
|
|
10
|
+
},
|
|
11
|
+
groq: {
|
|
12
|
+
rpmLimit: 30,
|
|
13
|
+
tpmLimit: 30000,
|
|
14
|
+
trafficSmoothingBurst: 5,
|
|
15
|
+
quotaResetWindow: 'per-minute',
|
|
16
|
+
displayName: 'Groq Cloud',
|
|
17
|
+
},
|
|
18
|
+
opencode: {
|
|
19
|
+
rpmLimit: 120,
|
|
20
|
+
tpmLimit: 200000,
|
|
21
|
+
trafficSmoothingBurst: 10,
|
|
22
|
+
quotaResetWindow: 'midnight_utc',
|
|
23
|
+
displayName: 'OpenCode Zen',
|
|
24
|
+
},
|
|
25
|
+
anthropic: {
|
|
26
|
+
rpmLimit: 50,
|
|
27
|
+
tpmLimit: 100000,
|
|
28
|
+
trafficSmoothingBurst: 5,
|
|
29
|
+
quotaResetWindow: 'per-minute',
|
|
30
|
+
displayName: 'Anthropic Claude',
|
|
31
|
+
},
|
|
32
|
+
openai: {
|
|
33
|
+
rpmLimit: 60,
|
|
34
|
+
tpmLimit: 150000,
|
|
35
|
+
trafficSmoothingBurst: 10,
|
|
36
|
+
quotaResetWindow: 'per-minute',
|
|
37
|
+
displayName: 'OpenAI API',
|
|
38
|
+
},
|
|
39
|
+
siliconflow: {
|
|
40
|
+
rpmLimit: 60,
|
|
41
|
+
tpmLimit: 100000,
|
|
42
|
+
trafficSmoothingBurst: 5,
|
|
43
|
+
quotaResetWindow: 'per-minute',
|
|
44
|
+
displayName: 'SiliconFlow',
|
|
45
|
+
},
|
|
46
|
+
mistral: {
|
|
47
|
+
rpmLimit: 60,
|
|
48
|
+
tpmLimit: 100000,
|
|
49
|
+
trafficSmoothingBurst: 5,
|
|
50
|
+
quotaResetWindow: 'per-minute',
|
|
51
|
+
displayName: 'Mistral AI',
|
|
52
|
+
},
|
|
53
|
+
gemini: {
|
|
54
|
+
rpmLimit: 60,
|
|
55
|
+
tpmLimit: 1000000,
|
|
56
|
+
trafficSmoothingBurst: 10,
|
|
57
|
+
quotaResetWindow: 'midnight_pst',
|
|
58
|
+
displayName: 'Google Gemini',
|
|
59
|
+
},
|
|
60
|
+
openrouter: {
|
|
61
|
+
rpmLimit: 200,
|
|
62
|
+
tpmLimit: 500000,
|
|
63
|
+
trafficSmoothingBurst: 15,
|
|
64
|
+
quotaResetWindow: 'per-minute',
|
|
65
|
+
displayName: 'OpenRouter',
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract rate limit and quota information from HTTP response headers.
|
|
71
|
+
* @param {Headers|Record<string, string>} rawHeaders
|
|
72
|
+
* @returns {object|null}
|
|
73
|
+
*/
|
|
74
|
+
export function extractProviderLimits(rawHeaders) {
|
|
75
|
+
if (!rawHeaders) return null;
|
|
76
|
+
const headers = {};
|
|
77
|
+
if (typeof rawHeaders.forEach === 'function') {
|
|
78
|
+
rawHeaders.forEach((val, key) => { headers[key.toLowerCase()] = String(val); });
|
|
79
|
+
} else if (typeof rawHeaders === 'object') {
|
|
80
|
+
for (const [k, v] of Object.entries(rawHeaders)) {
|
|
81
|
+
if (typeof k === 'string') headers[k.toLowerCase()] = String(v ?? '');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const getNum = (...names) => {
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const val = headers[name];
|
|
88
|
+
if (val !== undefined && val !== null && val !== '') {
|
|
89
|
+
const n = Number(val);
|
|
90
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const getSeconds = (...names) => {
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
const val = headers[name];
|
|
99
|
+
if (!val) continue;
|
|
100
|
+
const str = String(val).trim();
|
|
101
|
+
// Duration string format "6m0s", "1s", "250ms"
|
|
102
|
+
const match = /^(\d+(?:\.\d+)?)(s|ms|m|h)?$/i.exec(str);
|
|
103
|
+
if (match) {
|
|
104
|
+
const num = parseFloat(match[1]);
|
|
105
|
+
const unit = (match[2] || 's').toLowerCase();
|
|
106
|
+
if (unit === 'ms') return Math.max(0.001, num / 1000);
|
|
107
|
+
if (unit === 's') return num;
|
|
108
|
+
if (unit === 'm') return num * 60;
|
|
109
|
+
if (unit === 'h') return num * 3600;
|
|
110
|
+
}
|
|
111
|
+
// Check for ISO Date string (e.g., Anthropic reset timestamp)
|
|
112
|
+
const parsedDate = Date.parse(str);
|
|
113
|
+
if (Number.isFinite(parsedDate)) {
|
|
114
|
+
const diffSec = (parsedDate - Date.now()) / 1000;
|
|
115
|
+
return Math.max(0, diffSec);
|
|
116
|
+
}
|
|
117
|
+
const n = Number(str);
|
|
118
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const rpmLimit = getNum('x-ratelimit-limit-requests', 'x-ratelimit-limit-rpm', 'ratelimit-limit', 'anthropic-ratelimit-requests-limit');
|
|
124
|
+
const tpmLimit = getNum('x-ratelimit-limit-tokens', 'x-ratelimit-limit-tpm', 'anthropic-ratelimit-tokens-limit');
|
|
125
|
+
const remainingRequests = getNum('x-ratelimit-remaining-requests', 'x-ratelimit-remaining', 'anthropic-ratelimit-requests-remaining');
|
|
126
|
+
const remainingTokens = getNum('x-ratelimit-remaining-tokens', 'anthropic-ratelimit-tokens-remaining');
|
|
127
|
+
const resetRequests = getSeconds('x-ratelimit-reset-requests', 'x-ratelimit-reset', 'anthropic-ratelimit-requests-reset');
|
|
128
|
+
const resetTokens = getSeconds('x-ratelimit-reset-tokens', 'anthropic-ratelimit-tokens-reset');
|
|
129
|
+
const retryAfter = getSeconds('retry-after');
|
|
130
|
+
|
|
131
|
+
if (rpmLimit === undefined && tpmLimit === undefined && remainingRequests === undefined && remainingTokens === undefined && resetRequests === undefined && retryAfter === undefined) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const out = {};
|
|
136
|
+
if (rpmLimit !== undefined) out.rpmLimit = Math.floor(rpmLimit);
|
|
137
|
+
if (tpmLimit !== undefined) out.tpmLimit = Math.floor(tpmLimit);
|
|
138
|
+
if (remainingRequests !== undefined) {
|
|
139
|
+
out.remainingRequests = Math.floor(remainingRequests);
|
|
140
|
+
out.rpmRemaining = out.remainingRequests;
|
|
141
|
+
}
|
|
142
|
+
if (remainingTokens !== undefined) {
|
|
143
|
+
out.remainingTokens = Math.floor(remainingTokens);
|
|
144
|
+
out.tpmRemaining = out.remainingTokens;
|
|
145
|
+
}
|
|
146
|
+
if (resetRequests !== undefined) {
|
|
147
|
+
out.resetRequests = resetRequests;
|
|
148
|
+
out.rpmResetMs = Math.round(resetRequests * 1000);
|
|
149
|
+
}
|
|
150
|
+
if (resetTokens !== undefined) {
|
|
151
|
+
out.resetTokens = resetTokens;
|
|
152
|
+
out.tpmResetMs = Math.round(resetTokens * 1000);
|
|
153
|
+
}
|
|
154
|
+
if (retryAfter !== undefined) {
|
|
155
|
+
out.retryAfterMs = Math.round(retryAfter * 1000);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Match a configured provider name to canonical preset.
|
|
163
|
+
* @param {string} providerName
|
|
164
|
+
* @returns {object|null}
|
|
165
|
+
*/
|
|
166
|
+
export function lookupPreset(providerName) {
|
|
167
|
+
if (!providerName || typeof providerName !== 'string') return null;
|
|
168
|
+
const p = providerName.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
169
|
+
for (const [key, val] of Object.entries(CANONICAL_PROVIDER_PRESETS)) {
|
|
170
|
+
if (p.includes(key)) return { ...val, id: key };
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
package/lib/rotate.js
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from './model-quota.js';
|
|
36
36
|
|
|
37
37
|
import { LOCAL_POOL_EXHAUSTED_CODE } from './resolver.js';
|
|
38
|
+
import { extractProviderLimits } from './provider-limits-detector.js';
|
|
38
39
|
|
|
39
40
|
export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
|
|
40
41
|
|
|
@@ -79,6 +80,7 @@ export function createRotate(deps) {
|
|
|
79
80
|
circuitBreaker,
|
|
80
81
|
logger,
|
|
81
82
|
schedulePersist,
|
|
83
|
+
trafficSmoother,
|
|
82
84
|
now = nowMono,
|
|
83
85
|
} = deps;
|
|
84
86
|
|
|
@@ -195,16 +197,51 @@ export function createRotate(deps) {
|
|
|
195
197
|
}
|
|
196
198
|
}
|
|
197
199
|
|
|
198
|
-
const penalizeRef = (targetRef, errCode, errMsg) => {
|
|
200
|
+
const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
|
|
199
201
|
if (!targetRef) return;
|
|
200
202
|
if (sessionId) clearSessionSticky(pool, sessionId);
|
|
201
203
|
if (errCode === LOCAL_QUOTA_CODE) return;
|
|
202
|
-
|
|
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
|
+
|
|
203
216
|
const _base = pool.cooldownMs ?? cooldownMs;
|
|
204
|
-
const _max = pool.maxCooldownMs ?? maxCooldownMs;
|
|
205
|
-
|
|
217
|
+
const _max = pool.maxCooldownMs ?? maxCooldownMs ?? 3600000;
|
|
218
|
+
let _b = 0;
|
|
206
219
|
const cls = classifyFailure({ status: Number(errCode) || 0, code: errCode, message: errMsg });
|
|
207
|
-
|
|
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
|
+
}
|
|
208
245
|
|
|
209
246
|
// #337 & #486 (GitHub #24): connect quota-window calendar reset for quota exhaustion
|
|
210
247
|
// Transient rate limiting (RPM/TPM, requests/tokens per minute, concurrency)
|
|
@@ -318,6 +355,25 @@ export function createRotate(deps) {
|
|
|
318
355
|
};
|
|
319
356
|
|
|
320
357
|
for (let attempt = 0; attempt < attemptList.length; attempt++) {
|
|
358
|
+
const candidateRef = attemptList[attempt];
|
|
359
|
+
// #496: Traffic smoothing micro-queue
|
|
360
|
+
const smoother = pool.trafficSmoother ?? runtime0.trafficSmoother ?? trafficSmoother;
|
|
361
|
+
if (smoother && candidateRef) {
|
|
362
|
+
const smoothRes = await smoother.acquire(candidateRef, {
|
|
363
|
+
rpmLimit: pool.rpmLimit,
|
|
364
|
+
burstLimit: pool.trafficSmoothingBurst,
|
|
365
|
+
maxQueueWaitMs: pool.maxQueueWaitMs,
|
|
366
|
+
signal: options.signal,
|
|
367
|
+
});
|
|
368
|
+
if (smoothRes.overflow) {
|
|
369
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${candidateRef} micro-queue burst overflow (${smoothRes.delayMs}ms) — rotating to next candidate`);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (smoothRes.delayed) {
|
|
373
|
+
pool.state.smoothedRequests = (pool.state.smoothedRequests ?? 0) + 1;
|
|
374
|
+
pushEvent(pool, candidateRef, 'TRAFFIC_SMOOTHED', smoothRes.delayMs);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
321
377
|
// #464: Check deadline & attempt budget before dispatch
|
|
322
378
|
if (!budget.canAttempt()) {
|
|
323
379
|
const isTimeout = budget.isExpired();
|
|
@@ -388,7 +444,7 @@ export function createRotate(deps) {
|
|
|
388
444
|
break;
|
|
389
445
|
}
|
|
390
446
|
const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
391
|
-
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));
|
|
392
448
|
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
393
449
|
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
394
450
|
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
@@ -413,6 +469,7 @@ export function createRotate(deps) {
|
|
|
413
469
|
continue;
|
|
414
470
|
}
|
|
415
471
|
if (chunk && chunk.type === 'finish') {
|
|
472
|
+
const rawHdrs = chunk?.metadata?.headers ?? chunk?.headers ?? chunk?.reason?.failure?.headers;
|
|
416
473
|
const kind = chunk.reason?.kind;
|
|
417
474
|
const failure = chunk.reason?.failure;
|
|
418
475
|
const code = failure?.code;
|
|
@@ -421,7 +478,7 @@ export function createRotate(deps) {
|
|
|
421
478
|
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
422
479
|
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
423
480
|
if (switchable) {
|
|
424
|
-
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
481
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
|
|
425
482
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
426
483
|
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
427
484
|
pool.state.lastSwitchAt = now();
|
|
@@ -443,7 +500,7 @@ export function createRotate(deps) {
|
|
|
443
500
|
// content delta we may penalize, but must never replay the request.
|
|
444
501
|
if (kind === 'error' || kind === 'aborted') {
|
|
445
502
|
if (kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes)) {
|
|
446
|
-
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
503
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
|
|
447
504
|
}
|
|
448
505
|
yield chunk;
|
|
449
506
|
return;
|
|
@@ -506,6 +563,14 @@ export function createRotate(deps) {
|
|
|
506
563
|
// Proactive rate-limit (#115, #303): if response headers say this key is near
|
|
507
564
|
// its quota or has retry-after, cool it down so the NEXT request starts on a different key.
|
|
508
565
|
// We do NOT re-run this (already successful) request — that would double-send.
|
|
566
|
+
// #503: Extract provider limits and reset headers
|
|
567
|
+
if (rawHdrs && activeRef) {
|
|
568
|
+
const detected = extractProviderLimits(rawHdrs);
|
|
569
|
+
if (detected) {
|
|
570
|
+
if (!pool.state.detectedLimits) pool.state.detectedLimits = new Map();
|
|
571
|
+
pool.state.detectedLimits.set(activeRef, { ...detected, at: now() });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
509
574
|
const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
|
|
510
575
|
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
511
576
|
if (guardEnabled && rate && activeRef) {
|
|
@@ -544,7 +609,7 @@ export function createRotate(deps) {
|
|
|
544
609
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
545
610
|
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
546
611
|
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
547
|
-
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));
|
|
548
613
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
549
614
|
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
550
615
|
pool.state.lastSwitchAt = now();
|
|
@@ -561,7 +626,7 @@ export function createRotate(deps) {
|
|
|
561
626
|
continue; // Failover to next key!
|
|
562
627
|
}
|
|
563
628
|
if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
564
|
-
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));
|
|
565
630
|
}
|
|
566
631
|
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
567
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
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// lib/traffic-smoother.js — leaky bucket micro-queue traffic smoother (#496).
|
|
2
|
+
// Absorbs momentary request bursts without tripping upstream per-second/per-minute rate limits.
|
|
3
|
+
|
|
4
|
+
export class TrafficSmoother {
|
|
5
|
+
constructor({ maxQueueWaitMs = 800, now = Date.now, sleep = null } = {}) {
|
|
6
|
+
this.maxQueueWaitMs = maxQueueWaitMs;
|
|
7
|
+
this.now = now;
|
|
8
|
+
this.sleep = sleep || ((ms, signal) => new Promise((resolve, reject) => {
|
|
9
|
+
if (signal?.aborted) return reject(signal.reason ?? new Error('aborted'));
|
|
10
|
+
const timer = setTimeout(resolve, ms);
|
|
11
|
+
if (signal) {
|
|
12
|
+
signal.addEventListener('abort', () => {
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
15
|
+
}, { once: true });
|
|
16
|
+
}
|
|
17
|
+
}));
|
|
18
|
+
// Map<ref, { lastTime: number, tokens: number, lastUpdate: number }>
|
|
19
|
+
this._buckets = new Map();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Acquire a slot for `ref` given effective RPM or burst limits.
|
|
24
|
+
* @param {string} ref
|
|
25
|
+
* @param {object} opts { rpmLimit, burstLimit, maxQueueWaitMs, signal }
|
|
26
|
+
* @returns {Promise<{ allowed: boolean, delayed: boolean, delayMs: number, overflow: boolean }>}
|
|
27
|
+
*/
|
|
28
|
+
async acquire(ref, { rpmLimit, burstLimit, maxQueueWaitMs, signal } = {}) {
|
|
29
|
+
if (!ref) {
|
|
30
|
+
return { allowed: true, delayed: false, delayMs: 0, overflow: false };
|
|
31
|
+
}
|
|
32
|
+
const maxWait = typeof maxQueueWaitMs === 'number' && maxQueueWaitMs >= 0 ? maxQueueWaitMs : this.maxQueueWaitMs;
|
|
33
|
+
const effectiveRpm = Number(rpmLimit) > 0 ? Number(rpmLimit) : 0;
|
|
34
|
+
if (effectiveRpm <= 0) {
|
|
35
|
+
return { allowed: true, delayed: false, delayMs: 0, overflow: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const capacity = Number(burstLimit) > 0
|
|
39
|
+
? Math.floor(burstLimit)
|
|
40
|
+
: Math.max(2, Math.ceil(effectiveRpm / 12));
|
|
41
|
+
|
|
42
|
+
const ratePerSec = effectiveRpm / 60; // tokens per second
|
|
43
|
+
const intervalMs = Math.max(1, 1000 / ratePerSec); // ms between tokens
|
|
44
|
+
const curNow = this.now();
|
|
45
|
+
|
|
46
|
+
let bucket = this._buckets.get(ref);
|
|
47
|
+
if (!bucket) {
|
|
48
|
+
bucket = { tokens: capacity, lastUpdate: curNow, nextSlot: curNow };
|
|
49
|
+
this._buckets.set(ref, bucket);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Replenish tokens based on elapsed time
|
|
53
|
+
const elapsed = Math.max(0, curNow - bucket.lastUpdate);
|
|
54
|
+
const addedTokens = elapsed * (ratePerSec / 1000);
|
|
55
|
+
bucket.tokens = Math.min(capacity, bucket.tokens + addedTokens);
|
|
56
|
+
bucket.lastUpdate = curNow;
|
|
57
|
+
|
|
58
|
+
if (bucket.tokens >= 1) {
|
|
59
|
+
bucket.tokens -= 1;
|
|
60
|
+
bucket.nextSlot = Math.max(curNow, bucket.nextSlot);
|
|
61
|
+
return { allowed: true, delayed: false, delayMs: 0, overflow: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Burst is exhausted: calculate delay required for next available slot
|
|
65
|
+
const targetSlot = Math.max(curNow, bucket.nextSlot) + intervalMs;
|
|
66
|
+
const delayMs = Math.max(0, targetSlot - curNow);
|
|
67
|
+
|
|
68
|
+
if (delayMs > maxWait) {
|
|
69
|
+
// Exceeds max micro-queue wait: signal overflow so caller rotates to next candidate
|
|
70
|
+
return { allowed: false, delayed: false, delayMs, overflow: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
bucket.nextSlot = targetSlot;
|
|
74
|
+
|
|
75
|
+
// Wait for the scheduled slot
|
|
76
|
+
await this.sleep(delayMs, signal);
|
|
77
|
+
|
|
78
|
+
return { allowed: true, delayed: true, delayMs, overflow: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getQueueDepth(ref) {
|
|
82
|
+
if (!ref) return this._buckets.size;
|
|
83
|
+
const b = this._buckets.get(ref);
|
|
84
|
+
if (!b) return 0;
|
|
85
|
+
const curNow = this.now();
|
|
86
|
+
const wait = Math.max(0, b.nextSlot - curNow);
|
|
87
|
+
return wait > 0 ? Math.ceil(wait / 100) : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
clear(ref) {
|
|
91
|
+
if (ref) this._buckets.delete(ref);
|
|
92
|
+
else this._buckets.clear();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default TrafficSmoother;
|
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.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": [
|