@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/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.29",
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": [