@goodandready/dsh-key-rotation 0.8.29 → 0.8.30
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 +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/budget-monitor.js +33 -29
- package/lib/client.js +283 -1
- package/lib/http-bridge.js +69 -6
- package/lib/index.js +31 -139
- package/lib/model-quota.js +298 -0
- package/lib/ops-keys.js +4 -1
- package/lib/ops-status.js +21 -5
- package/lib/persistence.js +25 -1
- package/lib/pool-builder.js +96 -6
- package/lib/pool-index.js +143 -0
- package/lib/pool-state.js +1 -0
- package/lib/pool.js +25 -5
- package/lib/resolver.js +159 -0
- package/lib/rotate.js +85 -1
- package/package.json +1 -1
package/lib/rotate.js
CHANGED
|
@@ -16,6 +16,34 @@ import {
|
|
|
16
16
|
} from './pool.js';
|
|
17
17
|
import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
|
|
18
18
|
import { nextQuotaReset } from './quota-window.js';
|
|
19
|
+
import {
|
|
20
|
+
isModelQuotaAvailable,
|
|
21
|
+
hasModelQuotaConfig,
|
|
22
|
+
anyModelQuotaAvailable,
|
|
23
|
+
consumeModelTokens,
|
|
24
|
+
extractUsageTokens,
|
|
25
|
+
formatModelQuotaExhaustion,
|
|
26
|
+
} from './model-quota.js';
|
|
27
|
+
|
|
28
|
+
export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
|
|
29
|
+
|
|
30
|
+
export async function* preserveDispatchContext(iterable, storage, store) {
|
|
31
|
+
if (!storage || typeof storage.getStore !== 'function') {
|
|
32
|
+
yield* iterable;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
36
|
+
const step = (method, arg) => storage.run(store, () => iterator[method](arg));
|
|
37
|
+
try {
|
|
38
|
+
while (true) {
|
|
39
|
+
const next = await step('next');
|
|
40
|
+
if (next.done) return next.value;
|
|
41
|
+
yield next.value;
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
if (typeof iterator.return === 'function') await step('return');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
19
47
|
|
|
20
48
|
/**
|
|
21
49
|
* Create the rotate(options, pool) async generator used by llm/stream.
|
|
@@ -53,8 +81,44 @@ export function createRotate(deps) {
|
|
|
53
81
|
let activePermit = null;
|
|
54
82
|
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
55
83
|
setRotateStartMs(reqStore.startMs);
|
|
84
|
+
const wallNow = Date.now();
|
|
85
|
+
const quotaManaged = hasModelQuotaConfig(pool);
|
|
56
86
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
57
87
|
attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r));
|
|
88
|
+
if (quotaManaged) {
|
|
89
|
+
attemptList = attemptList.filter((ref) => isModelQuotaAvailable(pool, ref, wallNow));
|
|
90
|
+
if (attemptList.length === 0) {
|
|
91
|
+
pool.state.lastExhaustionAt = now();
|
|
92
|
+
pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
|
|
93
|
+
logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local model token budget exhausted for all ${pool.refs.length} key(s) — no upstream request sent`);
|
|
94
|
+
const runtimeQ = buildRuntime();
|
|
95
|
+
notifyExhaustion(runtimeQ, pool, { provider: options.provider });
|
|
96
|
+
const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
|
|
97
|
+
const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
|
|
98
|
+
if (currentDepth < maxDepth && Array.isArray(runtimeQ.cascade) && runtimeQ.cascade.length > 0) {
|
|
99
|
+
const fb = pickCascadeFallback(options.provider, runtimeQ, runtimeQ.providerToPool, options.model);
|
|
100
|
+
if (fb && fb.pool && fb.pool !== pool) {
|
|
101
|
+
pool.state.lastReason = 'CASCADE';
|
|
102
|
+
pool.state.lastSwitchAt = now();
|
|
103
|
+
pool.state.cascadeCount = (pool.state.cascadeCount ?? 0) + 1;
|
|
104
|
+
pushEvent(pool, reqStore.pickedRef ?? 'pool', 'CASCADE_TRIGGERED', 0);
|
|
105
|
+
const innerCascade = rotate({
|
|
106
|
+
...options,
|
|
107
|
+
provider: fb.provider,
|
|
108
|
+
model: fb.model || options.model,
|
|
109
|
+
__isCascade: true,
|
|
110
|
+
__cascadeDepth: currentDepth + 1,
|
|
111
|
+
}, fb.pool);
|
|
112
|
+
for await (const chunk of innerCascade) {
|
|
113
|
+
yield chunk;
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
yield finishError(LOCAL_QUOTA_CODE, formatModelQuotaExhaustion(options.provider, pool));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
58
122
|
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
59
123
|
if (strategy !== 'round-robin') {
|
|
60
124
|
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
|
|
@@ -80,6 +144,7 @@ export function createRotate(deps) {
|
|
|
80
144
|
|
|
81
145
|
const penalizeRef = (targetRef, errCode, errMsg) => {
|
|
82
146
|
if (!targetRef) return;
|
|
147
|
+
if (errCode === LOCAL_QUOTA_CODE) return;
|
|
83
148
|
const _retry = parseRetryAfter(errMsg);
|
|
84
149
|
const _base = pool.cooldownMs ?? cooldownMs;
|
|
85
150
|
const _max = pool.maxCooldownMs ?? maxCooldownMs;
|
|
@@ -144,6 +209,11 @@ export function createRotate(deps) {
|
|
|
144
209
|
return llm.stream({ ...options, [MARKER]: true });
|
|
145
210
|
});
|
|
146
211
|
} catch (e) {
|
|
212
|
+
if (e?.localQuota || e?.code === LOCAL_QUOTA_CODE) {
|
|
213
|
+
lastFailure = finishError(LOCAL_QUOTA_CODE, String(e?.message ?? ''));
|
|
214
|
+
logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local model token budget blocks dispatch — no upstream request sent`);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
147
217
|
const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
148
218
|
penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
|
|
149
219
|
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
@@ -156,7 +226,7 @@ export function createRotate(deps) {
|
|
|
156
226
|
const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
|
|
157
227
|
try {
|
|
158
228
|
try {
|
|
159
|
-
for await (const chunk of inner) {
|
|
229
|
+
for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
|
|
160
230
|
// Only actual content deltas lock the stream (no more rotation).
|
|
161
231
|
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
162
232
|
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
@@ -232,6 +302,20 @@ export function createRotate(deps) {
|
|
|
232
302
|
if (activeRef) {
|
|
233
303
|
recordSuccess(pool, activeRef, now());
|
|
234
304
|
}
|
|
305
|
+
if (activeRef && quotaManaged) {
|
|
306
|
+
const charged = consumeModelTokens(
|
|
307
|
+
pool, activeRef, extractUsageTokens(chunk.usage),
|
|
308
|
+
runtime0.quotaResetWindow, Date.now(),
|
|
309
|
+
);
|
|
310
|
+
if (charged) {
|
|
311
|
+
if (typeof schedulePersist === 'function') {
|
|
312
|
+
try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
|
|
313
|
+
}
|
|
314
|
+
if (charged.exhausted) {
|
|
315
|
+
logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: key ${activeRef} reached its local token budget (${charged.used}/${charged.limit}) — excluded from this model only`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
235
319
|
|
|
236
320
|
// Proactive rate-limit (#115, #303): if response headers say this key is near
|
|
237
321
|
// its quota or has retry-after, cool it down so the NEXT request starts on a different key.
|
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.30",
|
|
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": [
|