@goodandready/dsh-key-rotation 0.7.37 → 0.7.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/README.ru.md +7 -0
- package/README.zh.md +7 -0
- package/lib/index.js +15 -14
- package/lib/pool.js +17 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -253,6 +253,13 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
|
|
|
253
253
|
|
|
254
254
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
255
255
|
|
|
256
|
+
### v0.7.38
|
|
257
|
+
- **Hot-Path Stream Optimization**: Eliminated 4 redundant `buildRuntime()` calls inside the `rotate()` finish chunk handler by reusing the request-scoped `runtime0` snapshot.
|
|
258
|
+
- **Allocation-Free Rate Limit Header Parsing**: Optimized `extractRateLimit()` with single-pass header inspection and length guards, completely removing dynamic lowercase/uppercase string allocations on every response chunk.
|
|
259
|
+
- **Date ISO String Memoization**: Memoized `todayIso` string calculation once per finishing request instead of creating multiple `Date` instances for `costDays` and `usageDays`.
|
|
260
|
+
- **Zero-Allocation Metrics Aggregation**: Replaced intermediate array allocation `[...values()].reduce()` with iterative summation for `totalUsage` in the `/dsh-key-rotation/status` endpoint.
|
|
261
|
+
- **Stale Notification Cleanup**: Automatically purge stale entries in notification throttling maps (`budgetNotifiedAt`, `lowHealthNotifiedAt`) when provider pools are deleted.
|
|
262
|
+
|
|
256
263
|
### v0.7.37
|
|
257
264
|
- **Request Context Isolation via AsyncLocalStorage**: Scoped active key resolution (`pickedRef`), start timestamps, and retry counts strictly to each async dispatch context using Node's `node:async_hooks`. Eliminates race conditions where concurrent streaming requests could penalize healthy keys.
|
|
258
265
|
- **Failover on Pre-Yield Stream Exceptions**: Fixed fatal stream termination where transport errors (e.g. HTTP 429 thrown before headers, socket hang-up) aborted the generator. The catch block now inspects `isSwitchableError` and cascades seamlessly to the next key if no content tokens have been yielded.
|
package/README.ru.md
CHANGED
|
@@ -253,6 +253,13 @@ dsh-key-rotation:
|
|
|
253
253
|
|
|
254
254
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
255
255
|
|
|
256
|
+
### v0.7.38
|
|
257
|
+
- **Оптимизация горячего пути стриминга**: Устранены 4 избыточных вызова `buildRuntime()` при обработке завершающего чанка в `rotate()` за счёт повторного использования снимка `runtime0`.
|
|
258
|
+
- **Парсинг заголовков лимитов без лишних аллокаций**: Оптимизирована функция `extractRateLimit()` — однократный проход по объекту с проверкой длины ключей без постоянных аллокаций строк `.toLowerCase()` и `.toUpperCase()`.
|
|
259
|
+
- **Мемоизация строки даты ISO**: Дата текущего дня (`todayIso`) теперь вычисляется ровно один раз на запрос, исключая создание нескольких дублирующих объектов `Date` для `costDays` и `usageDays`.
|
|
260
|
+
- **Итеративный подсчет totalUsage**: В эндпоинте статуса `/dsh-key-rotation/status` расчёт суммарного потребления переведён на прямой цикл без аллокации промежуточного массива `[...values()]`.
|
|
261
|
+
- **Очистка устаревших записей уведомлений**: При удалении пулов провайдеров из конфигурации автоматически вычищаются связанные ключи из карт троттлинга уведомлений (`budgetNotifiedAt`, `lowHealthNotifiedAt`).
|
|
262
|
+
|
|
256
263
|
### v0.7.37
|
|
257
264
|
- **Изоляция контекста запросов через AsyncLocalStorage**: Полная привязка активного ключа (`pickedRef`), времени старта и попыток к асинхронному контексту вызова через `node:async_hooks`. Устранена гонка, при которой параллельные запросы могли ошибочно штрафовать здоровый ключ соседа.
|
|
258
265
|
- **Failover при исключениях в потоке до первого чанка**: Устранено аварийное прерывание потока при транспортных сбоях (например, выброс HTTP 429 до отправки заголовков). Теперь блок перехвата проверяет `isSwitchableError` и прозрачно переключает поток на запасной ключ, если клиенту ещё не было отдано полезных данных.
|
package/README.zh.md
CHANGED
|
@@ -214,6 +214,13 @@ dsh-key-rotation:
|
|
|
214
214
|
|
|
215
215
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
216
216
|
|
|
217
|
+
### v0.7.38
|
|
218
|
+
- **热路径流处理优化**:在 `rotate()` 结束块处理中消除 4 次多余的 `buildRuntime()` 重复调用,直接复用请求作用域内的 `runtime0` 快照。
|
|
219
|
+
- **零额外字符串分配的限流头解析**:重构 `extractRateLimit()`,采用单次遍历结合键长度检查,彻底消除对每个响应头执行 `.toLowerCase()` / `.toUpperCase()` 的内存碎片分配。
|
|
220
|
+
- **日期 ISO 字符串记忆化**:在统计 `costDays` 与 `usageDays` 时将 `todayIso` 计算收敛为单次,杜绝重复创建 `Date` 实例。
|
|
221
|
+
- **状态统计零数组分配**:在 `/dsh-key-rotation/status` 中将 `totalUsage` 的计算由 `[...values()].reduce()` 改为直接迭代累加,避免频繁轮询引发的垃圾回收波动。
|
|
222
|
+
- **孤立通知记录自动清理**:在配置移除提供商模型池时,自动清理关联的通知限频缓存。
|
|
223
|
+
|
|
217
224
|
### v0.7.37
|
|
218
225
|
- **通过 AsyncLocalStorage 隔离请求上下文**:使用 Node.js 的 `node:async_hooks` 将解析后的密钥 (`pickedRef`)、启动时间和重试严格限定在单个请求上下文内,彻底消除并发请求间的竞态条件与误罚。
|
|
219
226
|
- **流异常自动故障转移**:修复流在首个 token 返回前抛出传输异常(如 HTTP 429)直接终止的问题。若未发送内容块,现在会自动触发 `isSwitchableError` 并顺畅切换到备用密钥。
|
package/lib/index.js
CHANGED
|
@@ -695,7 +695,11 @@ export function apply(ctx, config = {}) {
|
|
|
695
695
|
|
|
696
696
|
// auto-cleanup: remove poolState for providers that are now empty or removed
|
|
697
697
|
for (const key of [...poolState.keys()]) {
|
|
698
|
-
if (![...poolByRef.values()].some((p) => p.base === key))
|
|
698
|
+
if (![...poolByRef.values()].some((p) => p.base === key)) {
|
|
699
|
+
poolState.delete(key);
|
|
700
|
+
lowHealthNotifiedAt.delete(key);
|
|
701
|
+
budgetNotifiedAt.delete(key + ':budget');
|
|
702
|
+
}
|
|
699
703
|
}
|
|
700
704
|
// #192: drop RPM windows for refs that no longer belong to any pool
|
|
701
705
|
for (const st of poolState.values()) {
|
|
@@ -845,12 +849,11 @@ export function apply(ctx, config = {}) {
|
|
|
845
849
|
// the resolve patch hands out the next key on each dispatch.
|
|
846
850
|
function rotate(options, pool) {
|
|
847
851
|
return (async function* () {
|
|
848
|
-
const
|
|
852
|
+
const runtime0 = buildRuntime();
|
|
853
|
+
const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
|
|
849
854
|
let lastFailure = null;
|
|
850
855
|
const reqStore = { pool, pickedRef: undefined, startMs: Date.now() };
|
|
851
856
|
_rotateStartMs = reqStore.startMs;
|
|
852
|
-
|
|
853
|
-
const runtime0 = buildRuntime();
|
|
854
857
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
855
858
|
if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
856
859
|
// #193: prefer least-loaded key within limit
|
|
@@ -936,8 +939,8 @@ export function apply(ctx, config = {}) {
|
|
|
936
939
|
lastFailure = chunk;
|
|
937
940
|
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
938
941
|
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
939
|
-
if (
|
|
940
|
-
notifySwitch(
|
|
942
|
+
if (switchNotify && activeRef) {
|
|
943
|
+
notifySwitch(runtime0, pool, {
|
|
941
944
|
provider: options.provider,
|
|
942
945
|
from: activeRef,
|
|
943
946
|
code: String(code ?? 'UNKNOWN'),
|
|
@@ -948,6 +951,7 @@ export function apply(ctx, config = {}) {
|
|
|
948
951
|
break;
|
|
949
952
|
}
|
|
950
953
|
// cost tracking if provider returns usage.cost
|
|
954
|
+
const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
|
|
951
955
|
if (chunk.usage?.cost != null && activeRef) {
|
|
952
956
|
const c = Number(chunk.usage.cost);
|
|
953
957
|
if (!isNaN(c)) {
|
|
@@ -955,18 +959,16 @@ export function apply(ctx, config = {}) {
|
|
|
955
959
|
pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
|
|
956
960
|
// #208: cost per day per key (mirrors usageDays) for budget checks
|
|
957
961
|
if (!pool.state.costDays) pool.state.costDays = new Map();
|
|
958
|
-
const cday = new Date().toISOString().slice(0, 10);
|
|
959
962
|
const cMap = pool.state.costDays.get(activeRef) || new Map();
|
|
960
|
-
cMap.set(
|
|
963
|
+
cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
|
|
961
964
|
pool.state.costDays.set(activeRef, cMap);
|
|
962
965
|
}
|
|
963
966
|
}
|
|
964
967
|
// Usage by day (#119)
|
|
965
968
|
if (activeRef) {
|
|
966
969
|
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
967
|
-
const day = new Date().toISOString().slice(0, 10);
|
|
968
970
|
const dayMap = pool.state.usageDays.get(activeRef) || new Map();
|
|
969
|
-
dayMap.set(
|
|
971
|
+
dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
|
|
970
972
|
pool.state.usageDays.set(activeRef, dayMap);
|
|
971
973
|
}
|
|
972
974
|
// Per-model request detail (#121)
|
|
@@ -981,7 +983,6 @@ export function apply(ctx, config = {}) {
|
|
|
981
983
|
// We do NOT re-run this (already successful) request — that would double-send.
|
|
982
984
|
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
983
985
|
if (rate && activeRef) {
|
|
984
|
-
const { rateLimitThreshold } = buildRuntime();
|
|
985
986
|
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
986
987
|
const cool = rate.reset && rate.reset > Date.now() ? (rate.reset - Date.now()) : pool.cooldownMs;
|
|
987
988
|
recordFailure(pool, activeRef, Date.now(), cool, pool.maxCooldownMs);
|
|
@@ -1010,8 +1011,8 @@ export function apply(ctx, config = {}) {
|
|
|
1010
1011
|
pool.state.lastSwitchAt = Date.now();
|
|
1011
1012
|
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
1012
1013
|
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
1013
|
-
if (
|
|
1014
|
-
notifySwitch(
|
|
1014
|
+
if (switchNotify && activeRef) {
|
|
1015
|
+
notifySwitch(runtime0, pool, {
|
|
1015
1016
|
provider: options.provider,
|
|
1016
1017
|
from: activeRef,
|
|
1017
1018
|
code: String(e?.code ?? 'TRANSPORT'),
|
|
@@ -1148,7 +1149,7 @@ export function apply(ctx, config = {}) {
|
|
|
1148
1149
|
lastSwitchAt: pool.state.lastSwitchAt ?? null,
|
|
1149
1150
|
lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
|
|
1150
1151
|
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
1151
|
-
totalUsage:
|
|
1152
|
+
totalUsage: (() => { let s = 0; if (pool.state.usageCounts) for (const v of pool.state.usageCounts.values()) s += v; return s; })(),
|
|
1152
1153
|
// #225: aggregate p95 across the pool's keys
|
|
1153
1154
|
p95: (() => {
|
|
1154
1155
|
const vals = (pool.refs ?? []).map((r) => latencyHistogram.snapshot(r)).filter((s) => s && s.p95 != null).map((s) => s.p95);
|
package/lib/pool.js
CHANGED
|
@@ -248,14 +248,23 @@ export function computeHealthScore(state) {
|
|
|
248
248
|
/** Extract rate-limit info from an object that may carry response headers. */
|
|
249
249
|
export function extractRateLimit(headers) {
|
|
250
250
|
if (!headers || typeof headers !== 'object') return null;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
251
|
+
let remaining, limit, reset;
|
|
252
|
+
for (const k in headers) {
|
|
253
|
+
const lk = k.length;
|
|
254
|
+
if (lk === 21 || lk === 16 || lk === 17) {
|
|
255
|
+
const lower = k.toLowerCase();
|
|
256
|
+
if (lower === 'x-ratelimit-remaining') {
|
|
257
|
+
const v = headers[k];
|
|
258
|
+
if (v != null) remaining = Number(String(v));
|
|
259
|
+
} else if (lower === 'x-ratelimit-limit') {
|
|
260
|
+
const v = headers[k];
|
|
261
|
+
if (v != null) limit = Number(String(v));
|
|
262
|
+
} else if (lower === 'x-ratelimit-reset') {
|
|
263
|
+
const v = headers[k];
|
|
264
|
+
if (v != null) reset = Number(String(v));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
259
268
|
if (remaining === undefined && limit === undefined) return null;
|
|
260
269
|
return { remaining, limit, reset };
|
|
261
270
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.38",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|