dsh-subscriptions 0.3.0 → 0.3.1

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/index.js CHANGED
@@ -7819,22 +7819,29 @@ var PoolAdapter = class extends LlmAdapter {
7819
7819
  throw this.exhaustedWithWarn(options.model, members, failures);
7820
7820
  }
7821
7821
  /**
7822
- * Order the candidates for one request. Health filters both strategies;
7823
- * `quota_aware` then ranks by urgency (members without telemetry, e.g.
7824
- * copilot, score zero and sink to the bottom of their class), while
7825
- * quota-exhausted members stay as a last-resort tail in pool order. The
7826
- * sticky member keeps its lead unless a challenger out-scores it by
7827
- * `switchMargin`.
7822
+ * Order the candidates for one request. Health and known exhaustion filter
7823
+ * both strategies: an applicable window at 100% must never become a paid
7824
+ * fallback. `quota_aware` then ranks by urgency (members without telemetry,
7825
+ * e.g. copilot, score zero and sink to the bottom of their class), while
7826
+ * near-full members stay as a last-resort tail in urgency order. The sticky
7827
+ * member keeps its lead unless a challenger out-scores it by `switchMargin`.
7828
7828
  */
7829
7829
  async select(poolId, members, sessionId) {
7830
7830
  const usable = members.filter((member) => this.options.adapters[member.provider] !== void 0 && this.options.health.isMemberAvailable(member.provider, member.account, member.model));
7831
7831
  if (usable.length === 0) return [];
7832
- const stickyMember = sessionId === void 0 ? void 0 : usable.find((member) => memberKey(member.provider, member.account, member.model) === this.sticky.get(stickyKey(poolId, sessionId)));
7833
- if (this.options.strategy === "priority") return stickyMember === void 0 ? usable : [stickyMember, ...usable.filter((member) => member !== stickyMember)];
7834
7832
  const quotas = new Map(await Promise.all(usable.map(async (member) => [member, await this.options.usage.quotaFor(member)])));
7835
- const scored = usable.filter((member) => quotas.get(member)?.available === true);
7836
- const quotaFull = usable.filter((member) => quotas.get(member)?.available === false);
7833
+ const candidates = usable.filter((member) => quotas.get(member)?.exhausted !== true);
7834
+ if (candidates.length === 0) {
7835
+ const message = `${poolId}: all currently usable accounts have exhausted subscription quota; the request was stopped before spending paid credits. After an external reset, use Settings > Subscriptions > Refresh to re-check usage.`;
7836
+ this.warnOnce(message);
7837
+ throw new LlmError(message, QUOTA_EXCEEDED_CODE);
7838
+ }
7839
+ const stickyMember = sessionId === void 0 ? void 0 : candidates.find((member) => memberKey(member.provider, member.account, member.model) === this.sticky.get(stickyKey(poolId, sessionId)));
7840
+ if (this.options.strategy === "priority") return stickyMember === void 0 ? candidates : [stickyMember, ...candidates.filter((member) => member !== stickyMember)];
7841
+ const scored = candidates.filter((member) => quotas.get(member)?.available === true);
7842
+ const quotaFull = candidates.filter((member) => quotas.get(member)?.available === false);
7837
7843
  scored.sort((a, b) => (quotas.get(b)?.urgency ?? 0) - (quotas.get(a)?.urgency ?? 0));
7844
+ quotaFull.sort((a, b) => (quotas.get(b)?.urgency ?? 0) - (quotas.get(a)?.urgency ?? 0));
7838
7845
  if (stickyMember !== void 0 && scored.includes(stickyMember)) {
7839
7846
  const best = scored[0];
7840
7847
  const stickyUrgency = quotas.get(stickyMember)?.urgency ?? 0;
@@ -8254,21 +8261,19 @@ var PoolUsageTracker = class {
8254
8261
  * (member selection must never block on the network mid-conversation). A
8255
8262
  * failure still cooling down degrades immediately with no network call.
8256
8263
  *
8257
- * Deliberately does NOT fall back to `lastSnapshot` the way
8258
- * {@link snapshotFor} does: scoring routing decisions off data that is
8259
- * known to be stale-and-unrefreshable risks steering traffic by a urgency
8260
- * number the endpoint itself is no longer vouching for, whereas
8261
- * `snapshotFor`'s stale-display concern (the Settings page, the composer
8262
- * badge) has no such downside — showing an old percentage beats showing
8263
- * nothing.
8264
+ * A failed refresh does not reuse stale urgency for routing order, but it
8265
+ * does retain a stale snapshot's known exhaustion bit. That one-way safety
8266
+ * guard prevents a failed Settings refresh from re-enabling paid-credit
8267
+ * fallback; an account with no successful usage sample still fails open.
8264
8268
  * @param member - the pool member to score (account resolved).
8265
- * @returns availability plus the urgency score.
8269
+ * @returns availability, known exhaustion, and the urgency score.
8266
8270
  */
8267
8271
  async quotaFor(member) {
8268
8272
  const key = `${member.provider}/${member.account}`;
8269
8273
  const fetcher = this.fetcherFor(member.provider, member.account);
8270
8274
  if (fetcher === void 0) return {
8271
8275
  available: true,
8276
+ exhausted: false,
8272
8277
  urgency: 0,
8273
8278
  fetchedAt: 0
8274
8279
  };
@@ -8279,7 +8284,7 @@ var PoolUsageTracker = class {
8279
8284
  if (!fresh) this.refresh(key, fetcher).catch(() => void 0);
8280
8285
  return this.score(member, entry);
8281
8286
  }
8282
- if (fresh) return degradedQuota(entry.error);
8287
+ if (fresh) return this.degraded(member, entry.error, entry.lastSnapshot);
8283
8288
  }
8284
8289
  try {
8285
8290
  const snapshot = await this.refresh(key, fetcher);
@@ -8288,7 +8293,8 @@ var PoolUsageTracker = class {
8288
8293
  at: Date.now()
8289
8294
  });
8290
8295
  } catch (error) {
8291
- return degradedQuota(error);
8296
+ const failed = this.entries.get(key);
8297
+ return this.degraded(member, error, failed?.snapshot ?? failed?.lastSnapshot);
8292
8298
  }
8293
8299
  }
8294
8300
  /**
@@ -8394,17 +8400,27 @@ var PoolUsageTracker = class {
8394
8400
  }
8395
8401
  return pending;
8396
8402
  }
8403
+ /** Degrade ordering while retaining only a cached safety exhaustion bit. */
8404
+ degraded(member, error, snapshot) {
8405
+ return degradedQuota(error, (snapshot === void 0 ? void 0 : this.score(member, {
8406
+ snapshot,
8407
+ at: 0
8408
+ }))?.exhausted === true);
8409
+ }
8397
8410
  /** Score one member against a snapshot's windows. */
8398
8411
  score(member, entry) {
8399
8412
  const windows = (entry.snapshot.windows ?? []).filter((window) => windowApplies(window, member.model));
8400
8413
  let available = true;
8414
+ let exhausted = false;
8401
8415
  let urgency = 0;
8402
8416
  for (const window of windows) {
8403
8417
  if (window.usedPercent >= QUOTA_FULL_PERCENT) available = false;
8418
+ if (window.usedPercent >= 100) exhausted = true;
8404
8419
  urgency = Math.max(urgency, windowUrgency(window));
8405
8420
  }
8406
8421
  return {
8407
8422
  available,
8423
+ exhausted,
8408
8424
  urgency,
8409
8425
  fetchedAt: entry.at
8410
8426
  };
@@ -8412,17 +8428,14 @@ var PoolUsageTracker = class {
8412
8428
  };
8413
8429
  /**
8414
8430
  * The routing view of a fetch failure. Logged out: the member cannot serve
8415
- * at all. Any other failure (network, endpoint rate limit) must not block
8416
- * routing — the member stays available with a zero score, degrading the
8417
- * strategy to plain priority order for it.
8431
+ * at all. Any other failure (network, endpoint rate limit) has a zero urgency;
8432
+ * without a successful sample it stays available, while known exhaustion
8433
+ * remains unavailable so stale telemetry cannot re-enable paid fallback.
8418
8434
  */
8419
- function degradedQuota(error) {
8420
- return isMissingOrInvalidCredential(error) ? {
8421
- available: false,
8422
- urgency: 0,
8423
- fetchedAt: 0
8424
- } : {
8425
- available: true,
8435
+ function degradedQuota(error, exhausted = false) {
8436
+ return {
8437
+ available: !isMissingOrInvalidCredential(error) && !exhausted,
8438
+ exhausted,
8426
8439
  urgency: 0,
8427
8440
  fetchedAt: 0
8428
8441
  };
@@ -172,7 +172,9 @@ export declare const CLAUDE_REQUEST_IMAGE_BUDGET: number;
172
172
  * five-minute mark can still read an entry a one-hour mark wrote.
173
173
  * @returns the JSON body to POST.
174
174
  */
175
- export declare function claudeRequestBody(options: GenerateOptions, messages: readonly TranslatableMessage[], maxTokens: number, thinking?: Record<string, unknown>, effort?: string, cacheTtl?: PromptCacheTtl): Record<string, unknown>;
175
+ export declare function claudeRequestBody(options: Omit<GenerateOptions, 'purpose'> & {
176
+ purpose?: string;
177
+ }, messages: readonly TranslatableMessage[], maxTokens: number, thinking?: Record<string, unknown>, effort?: string, cacheTtl?: PromptCacheTtl): Record<string, unknown>;
176
178
  /** Claude wire adapter: one instance serves the `claude` provider route. */
177
179
  export declare class ClaudeAdapter extends LlmAdapter {
178
180
  private readonly options;
@@ -580,8 +580,8 @@ const CLAUDE_MODALITIES = ['text', 'image'];
580
580
  * @returns the JSON body to POST.
581
581
  */
582
582
  export function claudeRequestBody(options, messages, maxTokens, thinking, effort, cacheTtl) {
583
- // Read structurally so older host types need not declare `purpose`. Hosts
584
- // that do not classify auxiliary calls follow the configured TTL.
583
+ // Auxiliary callers own this extension even when a host's GenerateOptions
584
+ // omits or reserves `purpose`; ordinary turns retain the configured TTL.
585
585
  const purpose = options.purpose;
586
586
  const ttl = purpose === undefined ? cacheTtl : undefined;
587
587
  const anthropicMessages = toAnthropicMessages(messages);
@@ -19,8 +19,10 @@ export declare const QUOTA_FULL_PERCENT = 95;
19
19
  export declare const USAGE_TTL_MS: number;
20
20
  /** The scheduling view of one member's quota. */
21
21
  export interface MemberQuota {
22
- /** False when a window is effectively full or the login is gone. */
22
+ /** Near-full gate: false at 95% or when the login is gone; may still serve as a fallback. */
23
23
  available: boolean;
24
+ /** True at 100%: never spend this member's subscription quota or paid fallback. */
25
+ exhausted: boolean;
24
26
  /** Required burn rate (fraction of window per ms); 0 when unknown. */
25
27
  urgency: number;
26
28
  /** Epoch ms of the snapshot this was computed from; 0 when none. */
@@ -51,15 +53,12 @@ export declare class PoolUsageTracker {
51
53
  * (member selection must never block on the network mid-conversation). A
52
54
  * failure still cooling down degrades immediately with no network call.
53
55
  *
54
- * Deliberately does NOT fall back to `lastSnapshot` the way
55
- * {@link snapshotFor} does: scoring routing decisions off data that is
56
- * known to be stale-and-unrefreshable risks steering traffic by a urgency
57
- * number the endpoint itself is no longer vouching for, whereas
58
- * `snapshotFor`'s stale-display concern (the Settings page, the composer
59
- * badge) has no such downside — showing an old percentage beats showing
60
- * nothing.
56
+ * A failed refresh does not reuse stale urgency for routing order, but it
57
+ * does retain a stale snapshot's known exhaustion bit. That one-way safety
58
+ * guard prevents a failed Settings refresh from re-enabling paid-credit
59
+ * fallback; an account with no successful usage sample still fails open.
61
60
  * @param member - the pool member to score (account resolved).
62
- * @returns availability plus the urgency score.
61
+ * @returns availability, known exhaustion, and the urgency score.
63
62
  */
64
63
  quotaFor(member: ConcretePoolMember): Promise<MemberQuota>;
65
64
  /**
@@ -93,6 +92,8 @@ export declare class PoolUsageTracker {
93
92
  * waiting out a stale cooldown.
94
93
  */
95
94
  private refresh;
95
+ /** Degrade ordering while retaining only a cached safety exhaustion bit. */
96
+ private degraded;
96
97
  /** Score one member against a snapshot's windows. */
97
98
  private score;
98
99
  }
@@ -49,21 +49,18 @@ export class PoolUsageTracker {
49
49
  * (member selection must never block on the network mid-conversation). A
50
50
  * failure still cooling down degrades immediately with no network call.
51
51
  *
52
- * Deliberately does NOT fall back to `lastSnapshot` the way
53
- * {@link snapshotFor} does: scoring routing decisions off data that is
54
- * known to be stale-and-unrefreshable risks steering traffic by a urgency
55
- * number the endpoint itself is no longer vouching for, whereas
56
- * `snapshotFor`'s stale-display concern (the Settings page, the composer
57
- * badge) has no such downside — showing an old percentage beats showing
58
- * nothing.
52
+ * A failed refresh does not reuse stale urgency for routing order, but it
53
+ * does retain a stale snapshot's known exhaustion bit. That one-way safety
54
+ * guard prevents a failed Settings refresh from re-enabling paid-credit
55
+ * fallback; an account with no successful usage sample still fails open.
59
56
  * @param member - the pool member to score (account resolved).
60
- * @returns availability plus the urgency score.
57
+ * @returns availability, known exhaustion, and the urgency score.
61
58
  */
62
59
  async quotaFor(member) {
63
60
  const key = `${member.provider}/${member.account}`;
64
61
  const fetcher = this.fetcherFor(member.provider, member.account);
65
62
  if (fetcher === undefined)
66
- return { available: true, urgency: 0, fetchedAt: 0 };
63
+ return { available: true, exhausted: false, urgency: 0, fetchedAt: 0 };
67
64
  const entry = this.entries.get(key);
68
65
  if (entry !== undefined) {
69
66
  const fresh = Date.now() - entry.at < (entry.cooldownMs ?? this.ttlMs);
@@ -73,7 +70,7 @@ export class PoolUsageTracker {
73
70
  return this.score(member, entry);
74
71
  }
75
72
  if (fresh)
76
- return degradedQuota(entry.error);
73
+ return this.degraded(member, entry.error, entry.lastSnapshot);
77
74
  // The cooldown expired: fall through to a fresh, blocking attempt.
78
75
  }
79
76
  try {
@@ -81,7 +78,8 @@ export class PoolUsageTracker {
81
78
  return this.score(member, { snapshot, at: Date.now() });
82
79
  }
83
80
  catch (error) {
84
- return degradedQuota(error);
81
+ const failed = this.entries.get(key);
82
+ return this.degraded(member, error, failed?.snapshot ?? failed?.lastSnapshot);
85
83
  }
86
84
  }
87
85
  /**
@@ -214,29 +212,42 @@ export class PoolUsageTracker {
214
212
  }
215
213
  return pending;
216
214
  }
215
+ /** Degrade ordering while retaining only a cached safety exhaustion bit. */
216
+ degraded(member, error, snapshot) {
217
+ const cached = snapshot === undefined
218
+ ? undefined
219
+ : this.score(member, { snapshot, at: 0 });
220
+ return degradedQuota(error, cached?.exhausted === true);
221
+ }
217
222
  /** Score one member against a snapshot's windows. */
218
223
  score(member, entry) {
219
224
  const windows = (entry.snapshot.windows ?? []).filter((window) => windowApplies(window, member.model));
220
225
  let available = true;
226
+ let exhausted = false;
221
227
  let urgency = 0;
222
228
  for (const window of windows) {
223
229
  if (window.usedPercent >= QUOTA_FULL_PERCENT)
224
230
  available = false;
231
+ if (window.usedPercent >= 100)
232
+ exhausted = true;
225
233
  urgency = Math.max(urgency, windowUrgency(window));
226
234
  }
227
- return { available, urgency, fetchedAt: entry.at };
235
+ return { available, exhausted, urgency, fetchedAt: entry.at };
228
236
  }
229
237
  }
230
238
  /**
231
239
  * The routing view of a fetch failure. Logged out: the member cannot serve
232
- * at all. Any other failure (network, endpoint rate limit) must not block
233
- * routing — the member stays available with a zero score, degrading the
234
- * strategy to plain priority order for it.
240
+ * at all. Any other failure (network, endpoint rate limit) has a zero urgency;
241
+ * without a successful sample it stays available, while known exhaustion
242
+ * remains unavailable so stale telemetry cannot re-enable paid fallback.
235
243
  */
236
- function degradedQuota(error) {
237
- return isMissingOrInvalidCredential(error)
238
- ? { available: false, urgency: 0, fetchedAt: 0 }
239
- : { available: true, urgency: 0, fetchedAt: 0 };
244
+ function degradedQuota(error, exhausted = false) {
245
+ return {
246
+ available: !isMissingOrInvalidCredential(error) && !exhausted,
247
+ exhausted,
248
+ urgency: 0,
249
+ fetchedAt: 0,
250
+ };
240
251
  }
241
252
  /** How long to hold a failure in the negative cache: the endpoint's own `retry-after`, or the default TTL. */
242
253
  function cooldownFor(error, defaultTtlMs) {
@@ -95,12 +95,12 @@ export declare class PoolAdapter extends LlmAdapter {
95
95
  */
96
96
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
97
97
  /**
98
- * Order the candidates for one request. Health filters both strategies;
99
- * `quota_aware` then ranks by urgency (members without telemetry, e.g.
100
- * copilot, score zero and sink to the bottom of their class), while
101
- * quota-exhausted members stay as a last-resort tail in pool order. The
102
- * sticky member keeps its lead unless a challenger out-scores it by
103
- * `switchMargin`.
98
+ * Order the candidates for one request. Health and known exhaustion filter
99
+ * both strategies: an applicable window at 100% must never become a paid
100
+ * fallback. `quota_aware` then ranks by urgency (members without telemetry,
101
+ * e.g. copilot, score zero and sink to the bottom of their class), while
102
+ * near-full members stay as a last-resort tail in urgency order. The sticky
103
+ * member keeps its lead unless a challenger out-scores it by `switchMargin`.
104
104
  */
105
105
  private select;
106
106
  /** Pin the serving member to the session (with bounded memory). */
@@ -287,31 +287,43 @@ export class PoolAdapter extends LlmAdapter {
287
287
  throw this.exhaustedWithWarn(options.model, members, failures);
288
288
  }
289
289
  /**
290
- * Order the candidates for one request. Health filters both strategies;
291
- * `quota_aware` then ranks by urgency (members without telemetry, e.g.
292
- * copilot, score zero and sink to the bottom of their class), while
293
- * quota-exhausted members stay as a last-resort tail in pool order. The
294
- * sticky member keeps its lead unless a challenger out-scores it by
295
- * `switchMargin`.
290
+ * Order the candidates for one request. Health and known exhaustion filter
291
+ * both strategies: an applicable window at 100% must never become a paid
292
+ * fallback. `quota_aware` then ranks by urgency (members without telemetry,
293
+ * e.g. copilot, score zero and sink to the bottom of their class), while
294
+ * near-full members stay as a last-resort tail in urgency order. The sticky
295
+ * member keeps its lead unless a challenger out-scores it by `switchMargin`.
296
296
  */
297
297
  async select(poolId, members, sessionId) {
298
298
  const usable = members.filter((member) => this.options.adapters[member.provider] !== undefined &&
299
299
  this.options.health.isMemberAvailable(member.provider, member.account, member.model));
300
300
  if (usable.length === 0)
301
301
  return [];
302
+ const quotas = new Map(await Promise.all(usable.map(async (member) => [member, await this.options.usage.quotaFor(member)])));
303
+ const candidates = usable.filter((member) => quotas.get(member)?.exhausted !== true);
304
+ if (candidates.length === 0) {
305
+ const message = `${poolId}: all currently usable accounts have exhausted subscription quota; ` +
306
+ 'the request was stopped before spending paid credits. After an external reset, ' +
307
+ 'use Settings > Subscriptions > Refresh to re-check usage.';
308
+ this.warnOnce(message);
309
+ throw new LlmError(message, QUOTA_EXCEEDED_CODE);
310
+ }
302
311
  const stickyMember = sessionId === undefined
303
312
  ? undefined
304
- : usable.find((member) => memberKey(member.provider, member.account, member.model) ===
313
+ : candidates.find((member) => memberKey(member.provider, member.account, member.model) ===
305
314
  this.sticky.get(stickyKey(poolId, sessionId)));
306
315
  if (this.options.strategy === 'priority') {
307
316
  return stickyMember === undefined
308
- ? usable
309
- : [stickyMember, ...usable.filter((member) => member !== stickyMember)];
317
+ ? candidates
318
+ : [
319
+ stickyMember,
320
+ ...candidates.filter((member) => member !== stickyMember),
321
+ ];
310
322
  }
311
- const quotas = new Map(await Promise.all(usable.map(async (member) => [member, await this.options.usage.quotaFor(member)])));
312
- const scored = usable.filter((member) => quotas.get(member)?.available === true);
313
- const quotaFull = usable.filter((member) => quotas.get(member)?.available === false);
323
+ const scored = candidates.filter((member) => quotas.get(member)?.available === true);
324
+ const quotaFull = candidates.filter((member) => quotas.get(member)?.available === false);
314
325
  scored.sort((a, b) => (quotas.get(b)?.urgency ?? 0) - (quotas.get(a)?.urgency ?? 0));
326
+ quotaFull.sort((a, b) => (quotas.get(b)?.urgency ?? 0) - (quotas.get(a)?.urgency ?? 0));
315
327
  if (stickyMember !== undefined && scored.includes(stickyMember)) {
316
328
  const best = scored[0];
317
329
  const stickyUrgency = quotas.get(stickyMember)?.urgency ?? 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-subscriptions",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Unified subscription providers and usage dashboard for DeepSeek Harness",
5
5
  "icon": "./icon.svg",
6
6
  "license": "MIT",