@askalf/dario 6.0.45 → 6.0.47

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 CHANGED
@@ -27,7 +27,7 @@
27
27
 
28
28
  <p><strong>One local endpoint. Every AI tool you own. The subscriptions you already pay for.</strong></p>
29
29
 
30
- <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~32k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
30
+ <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~33k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
31
31
 
32
32
  <sub><a href="#start-in-60-seconds">Start</a> · <a href="#point-your-tools-at-it">Your tools</a> · <a href="#what-it-does-with-a-request">Routing</a> · <a href="#two-plans-one-endpoint">Two plans</a> · <a href="#many-seats-one-endpoint">Pool</a> · <a href="#it-tracks-a-moving-target">Drift</a> · <a href="#trust--transparency">Trust</a> · <a href="#will-my-account-get-suspended">Risk</a> · <a href="#commands">Commands</a> · <a href="#faq">FAQ</a> · <a href="docs/returning.md">Coming back after a while?</a></sub>
33
33
 
@@ -405,7 +405,7 @@ The split isn't live, but it was announced once on short notice and could return
405
405
 
406
406
  | Signal | Status |
407
407
  |---|---|
408
- | Source | **~32k** lines of TypeScript across **68** files, auditable in a weekend. One credential path since v5: the pool. |
408
+ | Source | **~33k** lines of TypeScript across **68** files, auditable in a weekend. One credential path since v5: the pool. |
409
409
  | Dependencies | **0 runtime.** Verify: `npm ls --production` |
410
410
  | Provenance | Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions + Sigstore, published with OIDC trusted publishing — no long-lived npm token exists to leak |
411
411
  | Scanning | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) on every push and weekly · [ClusterFuzzLite](./.github/workflows/cflite.yml) fuzzes the SSE translator and rejection parsers weekly · [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/askalf/dario) and [Best Practices](https://www.bestpractices.dev/projects/13638) badges above are live |
@@ -1,6 +1,14 @@
1
1
  /** Backward-compatible selection of a pool-fallback target for one request.
2
2
  * A bare value or comma-separated model chain retains the legacy meaning.
3
- * Tier maps use `tier:model`, e.g. `haiku:gpt-5.4-mini,sonnet:gpt-5.6-terra`.
3
+ * Tier maps use `tier:model`, e.g. `haiku:gpt-5.6-luna,sonnet:gpt-5.6-terra`.
4
4
  * Unknown models use `default`, or the first (normally cheapest) configured tier.
5
+ *
6
+ * The Codex rungs are named for a size ladder — `sol` (sun) > `terra` (earth) >
7
+ * `luna` (moon) — which is why the tier tests below read model NAMES rather
8
+ * than any stated capability: the backend publishes a routable set and no tier
9
+ * metadata, so the ladder is the only signal there is. `luna` was added when
10
+ * codex-drift-watch reported the account-visible list as `gpt-5.5`,
11
+ * `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-reserve` (dario#1272);
12
+ * `gpt-5.4-mini`, the previous example here, is no longer on that list.
5
13
  */
6
14
  export declare function selectPoolFallbackModels(spec: string | undefined, requestedModel: string): string[];
@@ -1,7 +1,15 @@
1
1
  /** Backward-compatible selection of a pool-fallback target for one request.
2
2
  * A bare value or comma-separated model chain retains the legacy meaning.
3
- * Tier maps use `tier:model`, e.g. `haiku:gpt-5.4-mini,sonnet:gpt-5.6-terra`.
3
+ * Tier maps use `tier:model`, e.g. `haiku:gpt-5.6-luna,sonnet:gpt-5.6-terra`.
4
4
  * Unknown models use `default`, or the first (normally cheapest) configured tier.
5
+ *
6
+ * The Codex rungs are named for a size ladder — `sol` (sun) > `terra` (earth) >
7
+ * `luna` (moon) — which is why the tier tests below read model NAMES rather
8
+ * than any stated capability: the backend publishes a routable set and no tier
9
+ * metadata, so the ladder is the only signal there is. `luna` was added when
10
+ * codex-drift-watch reported the account-visible list as `gpt-5.5`,
11
+ * `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-reserve` (dario#1272);
12
+ * `gpt-5.4-mini`, the previous example here, is no longer on that list.
5
13
  */
6
14
  export function selectPoolFallbackModels(spec, requestedModel) {
7
15
  const entries = (spec ?? '').trim().split(',').map((entry) => entry.trim()).filter(Boolean);
@@ -19,7 +27,7 @@ export function selectPoolFallbackModels(spec, requestedModel) {
19
27
  tiers.set(tier, model);
20
28
  }
21
29
  const model = requestedModel.toLowerCase();
22
- const tier = /haiku|mini|small/.test(model) ? 'haiku'
30
+ const tier = /haiku|mini|small|luna/.test(model) ? 'haiku'
23
31
  : /opus|sol|large/.test(model) ? 'opus'
24
32
  : /sonnet|terra|medium/.test(model) ? 'sonnet'
25
33
  : 'default';
package/dist/pool.d.ts CHANGED
@@ -431,7 +431,47 @@ export declare function modelFamily(modelId: string | null | undefined): string
431
431
  * `WIRE_BUCKET_BINDINGS` says it binds the family (`7d_oi` → fable, dario#1262),
432
432
  * or when this account's responses have shown it does (`boundBuckets`).
433
433
  */
434
- export declare function computeHeadroom(snapshot: RateLimitSnapshot, family?: string | null): number;
434
+ export declare function computeHeadroom(snapshot: RateLimitSnapshot, family?: string | null, now?: number): number;
435
+ /**
436
+ * Drop a utilization reading whose own window has already rolled over
437
+ * (dario#1244 follow-up, 2026-09-09).
438
+ *
439
+ * `rateLimitWindowPassed` has expired *rejections* against
440
+ * `anthropic-ratelimit-unified-reset` since #1232. The utilization on the same
441
+ * snapshot never got the same treatment, and that asymmetry is a routing bug —
442
+ * not the reporting one `utilFreshness` describes, whose "the pool returns
443
+ * parked accounts to service on its own and the value corrects itself" only
444
+ * holds for seats that were parked.
445
+ *
446
+ * A seat that never 429'd but whose last response read `5h 99%` keeps that
447
+ * reading forever: `computeHeadroom` returns 0.01, under `POOL_HEADROOM_FLOOR`,
448
+ * so the selector skips it, `pickFillFirst` won't take it, sticky bindings
449
+ * rebind away from it, and `drainQueue`'s probe loop *breaks* on it. Nothing
450
+ * sends it a request, so `updateRateLimits` never runs, so the reading never
451
+ * refreshes. Unlike a rejection there is no all-exhausted fallback to rescue
452
+ * it, because it was never ineligible — only permanently unattractive.
453
+ *
454
+ * Reported on a nine-seat pool (dario#1244, 2026-09-09): every seat read 0.98
455
+ * to 1.03, so every seat sat at or under the floor and `waitForAccount` queued
456
+ * until it timed out — a whole pool reporting exhausted while two of its seats
457
+ * carried `reset_in_ms: 0` and were provably free, one having served exactly
458
+ * ONE request 3.7 hours earlier. `rejected_count` was 0 on all of them, which
459
+ * is why none of the rejection-side fixes (#1232, #1254, the 6.0.39 cool-down)
460
+ * reached this: every one of them requires a 429 to have happened.
461
+ *
462
+ * Only the bucket the reading's own `claim` names is dropped. `reset` states
463
+ * the rollover of the representative window and nothing else, so a five-hour
464
+ * rollover must not clear a seven-day reading — that would route traffic onto
465
+ * a seat whose weekly quota really is spent. A claim naming neither window
466
+ * (`unknown`) is left untouched: nothing identifies which bucket expired, and
467
+ * guessing is how a genuinely throttled seat gets pushed back into rotation.
468
+ *
469
+ * Zeroing rather than flagging is deliberate: the seat becomes an attractive
470
+ * candidate, is picked once, and its own response refreshes the reading. That
471
+ * is the same "asking is the only way back" rule `isParkedInLiveWindow`
472
+ * documents, and it costs no synthetic probe.
473
+ */
474
+ export declare function expireElapsedWindow(snapshot: RateLimitSnapshot, now?: number): RateLimitSnapshot;
435
475
  /** Every bucket name that binds `family` for this reading — by name, by seed, or as learned. */
436
476
  export declare function bucketsBindingFamily(snapshot: RateLimitSnapshot, family: string): string[];
437
477
  export declare class AccountPool {
package/dist/pool.js CHANGED
@@ -530,17 +530,74 @@ export function modelFamily(modelId) {
530
530
  * `WIRE_BUCKET_BINDINGS` says it binds the family (`7d_oi` → fable, dario#1262),
531
531
  * or when this account's responses have shown it does (`boundBuckets`).
532
532
  */
533
- export function computeHeadroom(snapshot, family) {
534
- const utils = [snapshot.util5h, snapshot.util7d];
533
+ export function computeHeadroom(snapshot, family, now = Date.now()) {
534
+ const rl = expireElapsedWindow(snapshot, now);
535
+ const utils = [rl.util5h, rl.util7d];
535
536
  if (family) {
536
- for (const bucket of bucketsBindingFamily(snapshot, family)) {
537
- const util = snapshot.perModel7d[bucket];
537
+ for (const bucket of bucketsBindingFamily(rl, family)) {
538
+ const util = rl.perModel7d[bucket];
538
539
  if (util !== undefined)
539
540
  utils.push(util);
540
541
  }
541
542
  }
542
543
  return 1 - Math.max(...utils);
543
544
  }
545
+ /**
546
+ * Drop a utilization reading whose own window has already rolled over
547
+ * (dario#1244 follow-up, 2026-09-09).
548
+ *
549
+ * `rateLimitWindowPassed` has expired *rejections* against
550
+ * `anthropic-ratelimit-unified-reset` since #1232. The utilization on the same
551
+ * snapshot never got the same treatment, and that asymmetry is a routing bug —
552
+ * not the reporting one `utilFreshness` describes, whose "the pool returns
553
+ * parked accounts to service on its own and the value corrects itself" only
554
+ * holds for seats that were parked.
555
+ *
556
+ * A seat that never 429'd but whose last response read `5h 99%` keeps that
557
+ * reading forever: `computeHeadroom` returns 0.01, under `POOL_HEADROOM_FLOOR`,
558
+ * so the selector skips it, `pickFillFirst` won't take it, sticky bindings
559
+ * rebind away from it, and `drainQueue`'s probe loop *breaks* on it. Nothing
560
+ * sends it a request, so `updateRateLimits` never runs, so the reading never
561
+ * refreshes. Unlike a rejection there is no all-exhausted fallback to rescue
562
+ * it, because it was never ineligible — only permanently unattractive.
563
+ *
564
+ * Reported on a nine-seat pool (dario#1244, 2026-09-09): every seat read 0.98
565
+ * to 1.03, so every seat sat at or under the floor and `waitForAccount` queued
566
+ * until it timed out — a whole pool reporting exhausted while two of its seats
567
+ * carried `reset_in_ms: 0` and were provably free, one having served exactly
568
+ * ONE request 3.7 hours earlier. `rejected_count` was 0 on all of them, which
569
+ * is why none of the rejection-side fixes (#1232, #1254, the 6.0.39 cool-down)
570
+ * reached this: every one of them requires a 429 to have happened.
571
+ *
572
+ * Only the bucket the reading's own `claim` names is dropped. `reset` states
573
+ * the rollover of the representative window and nothing else, so a five-hour
574
+ * rollover must not clear a seven-day reading — that would route traffic onto
575
+ * a seat whose weekly quota really is spent. A claim naming neither window
576
+ * (`unknown`) is left untouched: nothing identifies which bucket expired, and
577
+ * guessing is how a genuinely throttled seat gets pushed back into rotation.
578
+ *
579
+ * Zeroing rather than flagging is deliberate: the seat becomes an attractive
580
+ * candidate, is picked once, and its own response refreshes the reading. That
581
+ * is the same "asking is the only way back" rule `isParkedInLiveWindow`
582
+ * documents, and it costs no synthetic probe.
583
+ */
584
+ export function expireElapsedWindow(snapshot, now = Date.now()) {
585
+ // Deliberately NOT `rateLimitWindowPassed`: that predicate answers "is this
586
+ // rejection over", and for a 429 that named no exhausted window it answers
587
+ // yes as soon as the cool-down elapses, whatever the window says. A seat
588
+ // 429'd at `5h 99%` (under the 1.0 threshold, so `exhausted: false`) would
589
+ // then have a true 99% reading zeroed a minute later and take traffic it
590
+ // cannot serve. Only the stated rollover retires a reading.
591
+ if (!(snapshot.reset > 0 && snapshot.reset * 1000 <= now))
592
+ return snapshot;
593
+ if (snapshot.claim === 'five_hour')
594
+ return { ...snapshot, util5h: 0 };
595
+ // `seven_day`, plus the overage variants that carry the same weekly window
596
+ // (`seven_day_overage_included`, see the overage note above).
597
+ if (snapshot.claim.startsWith('seven_day'))
598
+ return { ...snapshot, util7d: 0 };
599
+ return snapshot;
600
+ }
544
601
  /** Every bucket name that binds `family` for this reading — by name, by seed, or as learned. */
545
602
  export function bucketsBindingFamily(snapshot, family) {
546
603
  const out = [family];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.45",
3
+ "version": "6.0.47",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {