@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 +14 -0
- package/README.md +4 -0
- package/README.ru.md +4 -0
- package/README.zh.md +4 -0
- package/lib/balance-inspector.js +176 -0
- package/lib/client.js +46 -0
- package/lib/ops-balance.js +147 -0
- package/lib/ops-paths.js +1 -0
- package/lib/ops-status.js +3 -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 +2 -0
- package/package.json +1 -1
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
|
});
|
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-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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|