@askalf/dario 6.0.34 → 6.0.36

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 · ~31k 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 · ~32k 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
 
@@ -373,7 +373,7 @@ Keeping that current is the whole job, and it's automated. These watchers run un
373
373
  | [`deployed-version-watch`](./.github/workflows/deployed-version-watch.yml) | Publishing is not deploying: is what's running what was last released? | ![hourly](https://img.shields.io/github/actions/workflow/status/askalf/dario/deployed-version-watch.yml?branch=master&label=hourly) |
374
374
  | [`cc-drift-watcher-liveness`](./.github/workflows/cc-drift-watcher-liveness.yml) | The watcher itself going quiet. Lives on GitHub-hosted infrastructure on purpose, so it survives the failures it watches for. | ![every 2 h](https://img.shields.io/github/actions/workflow/status/askalf/dario/cc-drift-watcher-liveness.yml?branch=master&label=every%202%20h) |
375
375
 
376
- Guarded by a PR-time compat gate that runs the full suite against a live proxy before any wire-shape change merges. A few changes the watchers caught and shipped fixes for, same day:
376
+ Guarded at PR time by [`live-test`](./.github/workflows/live-test.yml), a required check that runs the full suite against a live proxy on a self-hosted runner, plus [`compat-test-self-hosted`](./.github/workflows/compat-test-self-hosted.yml), which replays the compat suite through a passthrough proxy on wire-shape changes. A few changes the watchers caught and shipped fixes for, same day:
377
377
 
378
378
  | Change (no subscriber-facing note) | Effect | dario shipped |
379
379
  |---|---|---|
@@ -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 | **~31k** lines of TypeScript across **67** files, auditable in a weekend. One credential path since v5: the pool. |
408
+ | Source | **~32k** 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 |
@@ -598,19 +598,32 @@ Two easy ways to help beyond code: **star the repo**, the clearest signal this i
598
598
  |---|---|
599
599
  | [@GodsBoy](https://github.com/GodsBoy) | Proxy auth, token redaction, error sanitization ([#2](https://github.com/askalf/dario/pull/2)) |
600
600
  | [@belangertrading](https://github.com/belangertrading) | Billing-classification investigation ([#4](https://github.com/askalf/dario/issues/4), [#6](https://github.com/askalf/dario/issues/6), [#7](https://github.com/askalf/dario/issues/7), [#12](https://github.com/askalf/dario/issues/12), [#23](https://github.com/askalf/dario/issues/23)), multi-agent billing FAQ ([#27](https://github.com/askalf/dario/pull/27)) |
601
+ | [@wysie](https://github.com/wysie) | ESM `require` crash in `dario login` ([#15](https://github.com/askalf/dario/issues/15)), OAuth for Max-plan accounts ([#18](https://github.com/askalf/dario/issues/18)) |
601
602
  | [@earlvanze](https://github.com/earlvanze) | OpenClaw tool mappings ([#19](https://github.com/askalf/dario/pull/19)), OAuth manual override ([#47](https://github.com/askalf/dario/pull/47)), HTTPS warning ([#53](https://github.com/askalf/dario/pull/53)) |
603
+ | [@nathan-widjaja](https://github.com/nathan-widjaja) | README positioning structure — the promise → who → first use → why-switch spine the page still runs on ([#21](https://github.com/askalf/dario/issues/21)) |
604
+ | [@trinhnvgem](https://github.com/trinhnvgem) | OAuth login failures on first release ([#22](https://github.com/askalf/dario/issues/22)), container and headless callback binding ([#28](https://github.com/askalf/dario/issues/28)) |
605
+ | [@adubkov](https://github.com/adubkov) | The container / headless-SSH case behind the manual OAuth code paste ([#28](https://github.com/askalf/dario/issues/28)) |
602
606
  | [@iNicholasBE](https://github.com/iNicholasBE) | macOS keychain credential detection ([#30](https://github.com/askalf/dario/pull/30)) |
603
607
  | [@boeingchoco](https://github.com/boeingchoco) | Reverse tool-param translation ([#29](https://github.com/askalf/dario/issues/29)), SSE framing regression catch, hybrid-tool motivation ([#33](https://github.com/askalf/dario/issues/33), [#36](https://github.com/askalf/dario/issues/36)) |
604
608
  | [@tetsuco](https://github.com/tetsuco) | Scrubber path corruption ([#35](https://github.com/askalf/dario/issues/35)), OpenClaw reverse-mapping collisions ([#37](https://github.com/askalf/dario/issues/37)), 20x-tier report ([#42](https://github.com/askalf/dario/issues/42)) |
605
609
  | [@mikelovatt](https://github.com/mikelovatt) | Silent subscription-drain surfaced via friendly billing buckets ([#34](https://github.com/askalf/dario/issues/34)) |
606
610
  | [@ringge](https://github.com/ringge) | `--no-auto-detect` for text-tool auto-preserve ([#40](https://github.com/askalf/dario/issues/40)) |
611
+ | [@rustanacexd](https://github.com/rustanacexd) | Cursor BYOK routing for Claude, and `--effort=max` ([#190](https://github.com/askalf/dario/issues/190)) |
612
+ | [@daimonbot](https://github.com/daimonbot) | Official multi-arch Docker image on GHCR ([#199](https://github.com/askalf/dario/issues/199)) |
607
613
  | [@Saik0s](https://github.com/Saik0s) | Wildcard CORS allow-headers, Opus 4.7 catalog entry ([#222](https://github.com/askalf/dario/pull/222)) |
614
+ | [@lwsh123k](https://github.com/lwsh123k) | `cch` anchored to the billing tag instead of first match ([#528](https://github.com/askalf/dario/issues/528)) |
608
615
  | [@boredland](https://github.com/boredland) | Time-to-reset in `dario doctor --usage` ([#550](https://github.com/askalf/dario/pull/550)) |
609
616
  | [@pnewell](https://github.com/pnewell) | `--preserve-output-format` for structured-output SDKs ([#583](https://github.com/askalf/dario/pull/583)) |
617
+ | [@matteo-rama](https://github.com/matteo-rama) | Headless admin bootstrap ([#599](https://github.com/askalf/dario/issues/599)), Analytics `NaN` and per-account rate-limit rows ([#600](https://github.com/askalf/dario/issues/600)), pool-aware `/status` and `/health` ([#636](https://github.com/askalf/dario/issues/636)), `version` on both ([#640](https://github.com/askalf/dario/issues/640)), Accounts TUI reads the live pool ([#641](https://github.com/askalf/dario/issues/641)) |
618
+ | [@miklisanton](https://github.com/miklisanton) | Mid-session `/model` switch 400 ([#744](https://github.com/askalf/dario/issues/744)), empty-turn guards behind the subagent 400s ([#1033](https://github.com/askalf/dario/issues/1033), [#1117](https://github.com/askalf/dario/issues/1117)) |
619
+ | [@p-i-](https://github.com/p-i-) | Independent wire-fidelity audit with a re-runnable harness — version-blind `bun-match`, and the correction to the packet-identical claim ([#813](https://github.com/askalf/dario/issues/813)) |
610
620
  | [@jerzydziewierz](https://github.com/jerzydziewierz) | TUI Config tab clipping and scrolling ([#861](https://github.com/askalf/dario/pull/861)) |
621
+ | [@ramarro123](https://github.com/ramarro123) | Admin bulk re-auth ([#913](https://github.com/askalf/dario/issues/913)), shared state across instances ([#993](https://github.com/askalf/dario/issues/993)), prompt-cache behaviour under litellm ([#1018](https://github.com/askalf/dario/issues/1018)), parked-seat and shared-window reporting ([#1244](https://github.com/askalf/dario/issues/1244)) |
622
+ | [@zytegalaxy](https://github.com/zytegalaxy) | The ChatGPT/Codex engine and `dario add altman` ([#1009](https://github.com/askalf/dario/issues/1009)) |
611
623
  | [@chaogebaba](https://github.com/chaogebaba) | Auto-release must never fire from a fork ([#1029](https://github.com/askalf/dario/pull/1029)) |
624
+ | [@robincle](https://github.com/robincle) | Utilisation freshness — `lastObservedAt` / `utilAgeMs` on `/accounts` ([#1032](https://github.com/askalf/dario/issues/1032)) |
612
625
  | [@anupamme](https://github.com/anupamme) | Refresh-lock ownership by server-issued lock id ([#1059](https://github.com/askalf/dario/pull/1059)) |
613
- | [@LiveNathan](https://github.com/LiveNathan) | Never send or stamp empty text blocks ([#1067](https://github.com/askalf/dario/pull/1067)) |
626
+ | [@LiveNathan](https://github.com/LiveNathan) | Never send or stamp empty text blocks ([#1067](https://github.com/askalf/dario/pull/1067)), empty final user turn from CC's stream-interruption retry ([#1092](https://github.com/askalf/dario/issues/1092), as [@NathanLively](https://github.com/NathanLively)) |
614
627
 
615
628
  ## Disclaimers
616
629
 
@@ -102,6 +102,8 @@ export interface AdminAccountLive {
102
102
  resetInMs: number | null;
103
103
  claim: string;
104
104
  status: string;
105
+ /** none · wait · regrant — the operator's next step (dario#1244). */
106
+ action?: 'none' | 'wait' | 'regrant';
105
107
  requestCount: number;
106
108
  /**
107
109
  * Upstream 429s this account answered. `requestCount` counts requests it
package/dist/admin-api.js CHANGED
@@ -378,6 +378,7 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
378
378
  reading_from: l.readingFrom ?? null,
379
379
  claim: l.claim,
380
380
  status: l.status,
381
+ action: l.action ?? 'none',
381
382
  request_count: l.requestCount,
382
383
  rejected_count: l.rejectedCount ?? 0,
383
384
  last_rejected_at: l.lastRejectedAt ?? null,
package/dist/cli.d.ts CHANGED
@@ -105,6 +105,42 @@ export declare function parseBooleanEnv(value: string | undefined): boolean | un
105
105
  * Set(['thinking','env']) — value "thinking,env" → preserve listed
106
106
  */
107
107
  export declare function resolvePreserveOrchestrationTags(args: string[], env: string | undefined): Set<string> | undefined;
108
+ /**
109
+ * The seat shape a running proxy returns from `/accounts`.
110
+ *
111
+ * Everything past `alias` is optional on purpose. A freshly installed CLI
112
+ * routinely queries a proxy still running the PREVIOUS release, whose seats
113
+ * predate whichever field the newest feature added — `sharesWindowWith`,
114
+ * `organizationId` and `grantedAt` all arrived that way (dario#1248 review).
115
+ * The payload is accepted on `mode` + `accounts` alone, so the renderer, not
116
+ * the fetch, is where a missing field would throw. Defaulting each one here
117
+ * keeps `accounts list --live` degrading to a thinner line instead of dying
118
+ * with a TypeError and skipping the on-disk fallback it advertises.
119
+ */
120
+ export interface LiveSeat {
121
+ alias?: string;
122
+ status?: string;
123
+ action?: 'none' | 'wait' | 'regrant';
124
+ util5h?: number;
125
+ util7d?: number;
126
+ utilAgeMs?: number | null;
127
+ resetInMs?: number | null;
128
+ requestCount?: number;
129
+ rejectedCount?: number;
130
+ organizationId?: string | null;
131
+ sharesWindowWith?: string[];
132
+ grantedAt?: number | null;
133
+ }
134
+ export interface LivePayload {
135
+ mode?: string;
136
+ accounts?: LiveSeat[];
137
+ distinctWindows?: number;
138
+ }
139
+ /**
140
+ * Render the live pool listing. Pure — returns the lines rather than printing
141
+ * them, so a legacy payload can be driven straight through it in a test.
142
+ */
143
+ export declare function formatLiveAccountsListing(payload: LivePayload, port: number, now: number): string[];
108
144
  /**
109
145
  * Decide whether this module is being invoked as the CLI entry point or
110
146
  * imported as a library. Pure, exported for tests; the file-bottom uses
package/dist/cli.js CHANGED
@@ -894,6 +894,52 @@ function parsePositiveIntFlag(prefix) {
894
894
  }
895
895
  return n;
896
896
  }
897
+ /**
898
+ * Render the live pool listing. Pure — returns the lines rather than printing
899
+ * them, so a legacy payload can be driven straight through it in a test.
900
+ */
901
+ export function formatLiveAccountsListing(payload, port, now) {
902
+ const seats = Array.isArray(payload.accounts) ? payload.accounts : [];
903
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
904
+ const pct = (n) => `${Math.round(num(n) * 100)}%`;
905
+ const mins = (ms) => {
906
+ const m = Math.max(1, Math.round(ms / 60_000));
907
+ return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`;
908
+ };
909
+ const age = (ms) => typeof ms !== 'number' || !Number.isFinite(ms)
910
+ ? 'never measured'
911
+ : ms < 60_000 ? `read ${Math.round(ms / 1000)}s ago` : `read ${mins(ms)} ago`;
912
+ const lines = [];
913
+ lines.push('');
914
+ lines.push(` dario — Accounts (live, from http://127.0.0.1:${port})`);
915
+ lines.push(' ────────────────');
916
+ lines.push('');
917
+ const windows = typeof payload.distinctWindows === 'number' ? payload.distinctWindows : seats.length;
918
+ lines.push(` Pool of ${seats.length} (${seats.length === 1 ? '1 seat' : seats.length + ' seats'} on ${windows} distinct window${windows === 1 ? '' : 's'})`);
919
+ lines.push('');
920
+ for (const s of seats) {
921
+ const alias = typeof s.alias === 'string' ? s.alias : '(unnamed)';
922
+ const rawStatus = typeof s.status === 'string' ? s.status : 'unknown';
923
+ const status = rawStatus === 'rejected' && typeof s.resetInMs === 'number' ? `rejected, back in ${mins(s.resetInMs)}` : rawStatus;
924
+ lines.push(` ${alias.padEnd(20)} ${status.padEnd(26)} 5h ${pct(s.util5h).padEnd(6)} 7d ${pct(s.util7d).padEnd(6)} ${age(s.utilAgeMs)}`);
925
+ // The one-word next step (dario#1244): a parked seat wants nothing from
926
+ // the operator; an auth-failure streak wants a re-grant.
927
+ const next = s.action === 'regrant' ? 'next: re-grant this seat (dario accounts remove + add)'
928
+ : s.action === 'wait' ? 'next: nothing, it comes back on its own'
929
+ : null;
930
+ const facts = [
931
+ `served ${num(s.requestCount)}`,
932
+ `429s ${num(s.rejectedCount)}`,
933
+ ...(next ? [next] : []),
934
+ typeof s.organizationId === 'string' && s.organizationId ? `org ${s.organizationId.slice(0, 8)}…` : 'org not yet observed',
935
+ ...(Array.isArray(s.sharesWindowWith) && s.sharesWindowWith.length > 0 ? [`shares its window with ${s.sharesWindowWith.join(', ')}`] : []),
936
+ ];
937
+ lines.push(` ${''.padEnd(20)} ${facts.join(' · ')}`);
938
+ lines.push(` ${''.padEnd(20)} ${describeGrantAge(grantAge(typeof s.grantedAt === 'number' ? s.grantedAt : undefined, now))}`);
939
+ }
940
+ lines.push('');
941
+ return lines;
942
+ }
897
943
  /**
898
944
  * `dario accounts list --live` — the running proxy's view of the pool
899
945
  * (dario#1244): status with its countdown, the reading and its age, 429s
@@ -924,34 +970,8 @@ async function accountsListLive() {
924
970
  }
925
971
  if (!payload || payload.mode !== 'pool' || !Array.isArray(payload.accounts))
926
972
  return false;
927
- const seats = payload.accounts;
928
- const now = Date.now();
929
- const pct = (n) => `${Math.round(n * 100)}%`;
930
- const mins = (ms) => {
931
- const m = Math.max(1, Math.round(ms / 60_000));
932
- return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`;
933
- };
934
- const age = (ms) => ms === null ? 'never measured' : ms < 60_000 ? `read ${Math.round(ms / 1000)}s ago` : `read ${mins(ms)} ago`;
935
- console.log('');
936
- console.log(` dario — Accounts (live, from http://127.0.0.1:${port})`);
937
- console.log(' ────────────────');
938
- console.log('');
939
- const windows = payload.distinctWindows ?? seats.length;
940
- console.log(` Pool of ${seats.length} (${seats.length === 1 ? '1 seat' : seats.length + ' seats'} on ${windows} distinct window${windows === 1 ? '' : 's'})`);
941
- console.log('');
942
- for (const s of seats) {
943
- const status = s.status === 'rejected' && typeof s.resetInMs === 'number' ? `rejected, back in ${mins(s.resetInMs)}` : s.status;
944
- console.log(` ${s.alias.padEnd(20)} ${status.padEnd(26)} 5h ${pct(s.util5h).padEnd(6)} 7d ${pct(s.util7d).padEnd(6)} ${age(s.utilAgeMs)}`);
945
- const facts = [
946
- `served ${s.requestCount}`,
947
- `429s ${s.rejectedCount}`,
948
- s.organizationId ? `org ${s.organizationId.slice(0, 8)}…` : 'org not yet observed',
949
- ...(s.sharesWindowWith.length > 0 ? [`shares its window with ${s.sharesWindowWith.join(', ')}`] : []),
950
- ];
951
- console.log(` ${''.padEnd(20)} ${facts.join(' · ')}`);
952
- console.log(` ${''.padEnd(20)} ${describeGrantAge(grantAge(s.grantedAt ?? undefined, now))}`);
953
- }
954
- console.log('');
973
+ for (const line of formatLiveAccountsListing(payload, port, Date.now()))
974
+ console.log(line);
955
975
  return true;
956
976
  }
957
977
  async function accounts() {
package/dist/pool.d.ts CHANGED
@@ -230,6 +230,22 @@ export declare function reportedAccountStatus(account: PoolAccount, now?: number
230
230
  export declare function accountIneligibility(account: PoolAccount, now?: number): AccountIneligibility | null;
231
231
  /** Boolean form of `accountIneligibility` — the router's eligibility filter. */
232
232
  export declare function isAccountEligible(account: PoolAccount, now?: number): boolean;
233
+ /**
234
+ * A seat parked on a 429 whose stated window has not rolled yet — the one
235
+ * state the router must never re-probe: the 429 named the reset, the clock
236
+ * has not reached it, and a probe can only 429 again. A rejection with no
237
+ * stated reset is NOT this: with nothing to expire, asking is the only way
238
+ * back, so it stays probeable (dario#1244).
239
+ */
240
+ export declare function isParkedInLiveWindow(account: PoolAccount, now?: number): boolean;
241
+ /**
242
+ * The operator's next step for one seat, next to `status` on both listings
243
+ * (dario#1244 — "do I have to re-login?" should not need the docs table).
244
+ * `wait`: the seat comes back on its own (a live rate-limit window, or a
245
+ * single auth blip cooling down). `regrant`: an auth-failure streak, which is
246
+ * a dead refresh token. `none`: nothing to do.
247
+ */
248
+ export declare function accountAction(account: PoolAccount, now?: number): 'none' | 'wait' | 'regrant';
233
249
  export interface PoolStatus {
234
250
  accounts: number;
235
251
  healthy: number;
@@ -341,6 +357,18 @@ export declare class AccountPool {
341
357
  * unified-buckets-only headroom — same behavior as before this PR.
342
358
  */
343
359
  select(family?: string | null): PoolAccount | null;
360
+ /**
361
+ * When EVERY seat is parked inside a live rate-limit window: the epoch ms
362
+ * the earliest window rolls, i.e. the moment the pool can serve again
363
+ * without a probe. Null otherwise — including a pool mixing parked seats
364
+ * with an auth-cooling or token-expired one, which is not "all seats over
365
+ * their windows" and must not be reported (or cooled) as if it were; those
366
+ * pools stay on the existing unavailable handling (dario#1244, and the
367
+ * review on dario#1254 that caught the mixed case).
368
+ */
369
+ parkedUntil(now?: number): number | null;
370
+ /** Seats currently parked inside a live window (dario#1244). */
371
+ parkedCount(now?: number): number;
344
372
  /**
345
373
  * Select with session stickiness. If `stickyKey` is already bound to a
346
374
  * healthy account (not rejected, token not near expiry, headroom > 2%),
package/dist/pool.js CHANGED
@@ -250,6 +250,31 @@ export function accountIneligibility(account, now = Date.now()) {
250
250
  export function isAccountEligible(account, now = Date.now()) {
251
251
  return accountIneligibility(account, now) === null;
252
252
  }
253
+ /**
254
+ * A seat parked on a 429 whose stated window has not rolled yet — the one
255
+ * state the router must never re-probe: the 429 named the reset, the clock
256
+ * has not reached it, and a probe can only 429 again. A rejection with no
257
+ * stated reset is NOT this: with nothing to expire, asking is the only way
258
+ * back, so it stays probeable (dario#1244).
259
+ */
260
+ export function isParkedInLiveWindow(account, now = Date.now()) {
261
+ const rl = account.rateLimit;
262
+ return rl.status === 'rejected' && rl.reset > 0 && rl.reset * 1000 > now;
263
+ }
264
+ /**
265
+ * The operator's next step for one seat, next to `status` on both listings
266
+ * (dario#1244 — "do I have to re-login?" should not need the docs table).
267
+ * `wait`: the seat comes back on its own (a live rate-limit window, or a
268
+ * single auth blip cooling down). `regrant`: an auth-failure streak, which is
269
+ * a dead refresh token. `none`: nothing to do.
270
+ */
271
+ export function accountAction(account, now = Date.now()) {
272
+ if (isInAuthCooldown(account, now))
273
+ return account.consecutiveAuthFailures >= 2 ? 'regrant' : 'wait';
274
+ if (isParkedInLiveWindow(account, now))
275
+ return 'wait';
276
+ return 'none';
277
+ }
253
278
  /**
254
279
  * Resolve the pool strategy from an explicit value (CLI flag / config file,
255
280
  * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
@@ -515,20 +540,45 @@ export class AccountPool {
515
540
  }
516
541
  return pickMaxHeadroom(eligible, family);
517
542
  }
518
- // All accounts exhausted return the one with the earliest reset.
519
- // Auth-cooldown'd accounts are excluded from this fallback too: we
520
- // know upstream rejected their tokens, so picking them on rate-limit
521
- // grounds wouldn't help. Better to return null and let the caller
522
- // surface "no account available" than to hand back a dead account.
523
- const withReset = all.filter(a => a.rateLimit.reset > 0 && !isInAuthCooldown(a, now));
524
- if (withReset.length > 0) {
525
- return withReset.reduce((a, b) => a.rateLimit.reset < b.rateLimit.reset ? a : b);
526
- }
527
- // No rate-limit data at all — least-used first, still skipping cool-downs.
528
- const usable = all.filter(a => !isInAuthCooldown(a, now));
529
- if (usable.length === 0)
543
+ // No seat is eligible. A seat parked inside a live window is not
544
+ // re-probed: its 429 named the reset, the clock has not reached it, and a
545
+ // probe there is one upstream round trip that can only 429 again — on the
546
+ // dario#1244 gateway that was 500 probes of one seat inside a single
547
+ // window, `rejectedCount` climbing by one each time and the operator
548
+ // reading it as a seat that needed a re-login. The caller reads
549
+ // `parkedUntil()` and answers the client itself; the seat returns on its
550
+ // own when the window rolls (`rateLimitWindowPassed` makes it eligible
551
+ // again). Auth-cooldown seats are skipped for the same reason: upstream
552
+ // already rejected their tokens.
553
+ //
554
+ // What is left — a rejection with no stated reset (nothing to expire, so
555
+ // asking is the only way back) or an expiring token — is tried least-used
556
+ // first, as before.
557
+ const probeable = all.filter(a => !isInAuthCooldown(a, now) && !isParkedInLiveWindow(a, now));
558
+ if (probeable.length === 0)
530
559
  return null;
531
- return usable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
560
+ return probeable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
561
+ }
562
+ /**
563
+ * When EVERY seat is parked inside a live rate-limit window: the epoch ms
564
+ * the earliest window rolls, i.e. the moment the pool can serve again
565
+ * without a probe. Null otherwise — including a pool mixing parked seats
566
+ * with an auth-cooling or token-expired one, which is not "all seats over
567
+ * their windows" and must not be reported (or cooled) as if it were; those
568
+ * pools stay on the existing unavailable handling (dario#1244, and the
569
+ * review on dario#1254 that caught the mixed case).
570
+ */
571
+ parkedUntil(now = Date.now()) {
572
+ if (this.accounts.size === 0)
573
+ return null;
574
+ const all = [...this.accounts.values()];
575
+ if (!all.every(a => isParkedInLiveWindow(a, now)))
576
+ return null;
577
+ return Math.min(...all.map(a => a.rateLimit.reset * 1000));
578
+ }
579
+ /** Seats currently parked inside a live window (dario#1244). */
580
+ parkedCount(now = Date.now()) {
581
+ return [...this.accounts.values()].filter(a => isParkedInLiveWindow(a, now)).length;
532
582
  }
533
583
  /**
534
584
  * Select with session stickiness. If `stickyKey` is already bound to a
@@ -645,8 +695,13 @@ export class AccountPool {
645
695
  }
646
696
  return pickMaxHeadroom(eligible, family);
647
697
  }
648
- if (candidates.length > 0) {
649
- return candidates.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
698
+ // Mid-flight: the seats a 429 could still hand this request to. A seat
699
+ // parked inside a live window is not one of them on the dario#1244
700
+ // gateway every request walked all six parked seats, six guaranteed 429s
701
+ // a request. Cool-downs are skipped for the same reason.
702
+ const probeable = candidates.filter(a => !isInAuthCooldown(a, now) && !isParkedInLiveWindow(a, now));
703
+ if (probeable.length > 0) {
704
+ return probeable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
650
705
  }
651
706
  return null;
652
707
  }
package/dist/proxy.js CHANGED
@@ -12,7 +12,7 @@ import { darioVersion } from './version.js';
12
12
  import { buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
13
13
  import { stampCch, hasCchSeed } from './cch.js';
14
14
  import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
15
- import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, describeRateLimitSnapshot, windowPeers, distinctWindows } from './pool.js';
15
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, describeRateLimitSnapshot, windowPeers, distinctWindows, accountAction } from './pool.js';
16
16
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
17
17
  import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
18
18
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
@@ -33,7 +33,7 @@ import { selectPoolFallbackModels } from './pool-fallback-tier.js';
33
33
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
34
34
  import { redactSecrets } from './redact.js';
35
35
  import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, retryModelCatalogNow, isSuspendedModel } from './model-catalog.js';
36
- import { classifyUpstreamRejection, diagnosticSnippet } from './upstream-rejection.js';
36
+ import { classifyUpstreamRejection, diagnosticSnippet, POOL_PARKED } from './upstream-rejection.js';
37
37
  import { ProviderCooldowns, canAttempt, allProvidersCooled, cooldownRetryAfterMs, parseRetryAfterMs, ALL_PROVIDERS_RATE_LIMITED, } from './provider-cooldown.js';
38
38
  const ANTHROPIC_API = 'https://api.anthropic.com';
39
39
  const DEFAULT_PORT = 3456;
@@ -1655,6 +1655,9 @@ export async function startProxy(opts = {}) {
1655
1655
  overlayTemplateHeaderValues(staticHeaders, CC_TEMPLATE.header_values);
1656
1656
  }
1657
1657
  let requestCount = 0;
1658
+ // dario#1244: the "pool parked" line is logged on the transition into the
1659
+ // state, not on every request that arrives while it holds.
1660
+ let poolParkedAnnounced = false;
1658
1661
  const queue = new RequestQueue({
1659
1662
  maxConcurrent: opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT,
1660
1663
  maxQueued: opts.maxQueued ?? DEFAULT_MAX_QUEUED,
@@ -2150,6 +2153,7 @@ export async function startProxy(opts = {}) {
2150
2153
  ...rateLimitWindow(a.rateLimit, snapNow),
2151
2154
  claim: a.rateLimit.claim,
2152
2155
  status: reportedAccountStatus(a, snapNow),
2156
+ action: accountAction(a, snapNow),
2153
2157
  requestCount: a.requestCount,
2154
2158
  rejectedCount: a.rejectedCount,
2155
2159
  lastRejectedAt: a.lastRejectedAt ?? null,
@@ -2258,6 +2262,8 @@ export async function startProxy(opts = {}) {
2258
2262
  ...rateLimitWindow(a.rateLimit, now),
2259
2263
  claim: a.rateLimit.claim,
2260
2264
  status: reportedAccountStatus(a, now),
2265
+ // The one-word next step: none · wait · regrant (dario#1244).
2266
+ action: accountAction(a, now),
2261
2267
  requestCount: a.requestCount,
2262
2268
  // 429s answered — the attempts requestCount does not count, so a
2263
2269
  // parked seat no longer reads as one that was never called.
@@ -2625,6 +2631,31 @@ export async function startProxy(opts = {}) {
2625
2631
  //
2626
2632
  // Shared because two callers must agree: the selector, and the dispatch
2627
2633
  // below for a request the selector DEFERRED but no provider could serve.
2634
+ /**
2635
+ * Every seat in the pool is parked inside a live window (dario#1244).
2636
+ * The old fallback re-probed the earliest-reset seat on every request:
2637
+ * one upstream round trip per request that could only 429,
2638
+ * `rejectedCount` climbing by one each time — 500 on one seat inside a
2639
+ * single window on the reporter's gateway — and the client waiting on a
2640
+ * verdict dario already held. Answer it here: 429, `retry-after` at the
2641
+ * earliest reset, the marker in the same channel the other rejection
2642
+ * classes use, and nothing sent upstream.
2643
+ */
2644
+ const writePoolParked = (untilMs) => {
2645
+ const retryAfterSec = Math.max(1, Math.ceil((untilMs - Date.now()) / 1000));
2646
+ res.writeHead(429, {
2647
+ ...JSON_HEADERS,
2648
+ 'retry-after': String(retryAfterSec),
2649
+ 'x-dario-upstream-rejection': POOL_PARKED,
2650
+ });
2651
+ res.end(JSON.stringify({
2652
+ error: {
2653
+ type: 'rate_limit_error',
2654
+ message: `All ${pool.size} pool seat${pool.size === 1 ? '' : 's'} are over their rate-limit windows; the earliest resets in ${retryAfterSec}s. Nothing was sent upstream.`,
2655
+ },
2656
+ reason: POOL_PARKED,
2657
+ }));
2658
+ };
2628
2659
  const writePoolUnavailable = () => {
2629
2660
  res.writeHead(503, JSON_HEADERS);
2630
2661
  res.end(JSON.stringify(pool.size === 0
@@ -2738,6 +2769,20 @@ export async function startProxy(opts = {}) {
2738
2769
  return true;
2739
2770
  }
2740
2771
  poolAccount = pool.select();
2772
+ if (poolAccount)
2773
+ poolParkedAnnounced = false;
2774
+ // Every seat parked inside a live window (dario#1244): cool the
2775
+ // provider to the earliest reset so a fallback chain sees the Claude
2776
+ // half as what it is, say so once, and — unless a fallback is armed —
2777
+ // answer the client here instead of spending a probe that can only 429.
2778
+ const parkedUntil = poolAccount ? null : pool.parkedUntil();
2779
+ if (parkedUntil !== null) {
2780
+ providerCooldowns.note('claude', parkedUntil - Date.now());
2781
+ if (!poolParkedAnnounced) {
2782
+ poolParkedAnnounced = true;
2783
+ console.error(`[dario] #${requestCount} pool parked: all ${pool.size} seats are over their rate-limit windows, earliest resets in ${Math.max(1, Math.ceil((parkedUntil - Date.now()) / 60000))}m — answering 429 locally until then, nothing sent upstream`);
2784
+ }
2785
+ }
2741
2786
  if (!poolAccount) {
2742
2787
  // Pool-exhausted fallback: when armed, the pool HAS accounts (all
2743
2788
  // drained / cooling), and the client speaks OpenAI shape, defer —
@@ -2763,7 +2808,10 @@ export async function startProxy(opts = {}) {
2763
2808
  // see, not traffic to quietly re-bill somewhere else.
2764
2809
  const fallbackViable = poolFallbackModels.length > 0 && pool.size > 0;
2765
2810
  if (!fallbackViable) {
2766
- writePoolUnavailable();
2811
+ if (parkedUntil !== null)
2812
+ writePoolParked(parkedUntil);
2813
+ else
2814
+ writePoolUnavailable();
2767
2815
  return false;
2768
2816
  }
2769
2817
  }
@@ -3295,6 +3343,13 @@ export async function startProxy(opts = {}) {
3295
3343
  // would fall through to the Claude path with no account and an empty
3296
3344
  // bearer token, turning a clean 503 into a confusing upstream 401.
3297
3345
  if (!upstreamApiKey && !poolAccount) {
3346
+ // A fallback was armed but nothing could serve, and the pool itself is
3347
+ // parked: the exact reset beats a cool-down estimate (dario#1244).
3348
+ const parkedNow = pool.parkedUntil();
3349
+ if (parkedNow !== null) {
3350
+ writePoolParked(parkedNow);
3351
+ return;
3352
+ }
3298
3353
  // A chain where every entry is cooling is a rate limit, not a
3299
3354
  // misconfiguration — say so in the machine-readable way, once.
3300
3355
  if (allProvidersCooled(poolFallbackModels.length > 0 ? ['codex', 'claude'] : ['claude'], providerCooldowns)) {
@@ -11,6 +11,12 @@ export interface UpstreamRejection {
11
11
  * every "this request was not served" verdict.
12
12
  */
13
13
  export declare const MODEL_UNROUTABLE = "model_unroutable";
14
+ /**
15
+ * Every seat in the pool is parked inside a live rate-limit window, so dario
16
+ * answered 429 itself, with `retry-after` at the earliest reset, and sent
17
+ * nothing upstream (dario#1244).
18
+ */
19
+ export declare const POOL_PARKED = "pool_parked";
14
20
  /** Classify subscription entitlement failures separately from temporary quota exhaustion. */
15
21
  export declare function classifyUpstreamRejection(status: number, body: string): UpstreamRejection;
16
22
  /** Operator action paired with the failure class. Never suggest credential churn for billing. */
@@ -6,6 +6,12 @@
6
6
  * every "this request was not served" verdict.
7
7
  */
8
8
  export const MODEL_UNROUTABLE = 'model_unroutable';
9
+ /**
10
+ * Every seat in the pool is parked inside a live rate-limit window, so dario
11
+ * answered 429 itself, with `retry-after` at the earliest reset, and sent
12
+ * nothing upstream (dario#1244).
13
+ */
14
+ export const POOL_PARKED = 'pool_parked';
9
15
  /** Classify subscription entitlement failures separately from temporary quota exhaustion. */
10
16
  export function classifyUpstreamRejection(status, body) {
11
17
  const normalized = body.toLowerCase();
@@ -113,7 +113,8 @@ is a proof that the tools / system_prompt / beta headers / field orders are
113
113
  byte-identical at the live version, so only the version string moves — the
114
114
  same deterministic-bump risk class `cc-drift-watch.yml` already auto-merges for
115
115
  `SUPPORTED_CC_RANGE.maxTested`. Auto-merge still gates on the required checks
116
- (build ×3, compat, test, docker-cap-drop-smoke); a red check leaves the PR open
116
+ (build ×3, live-test, CodeQL, actionlint, validate-package-json); compat runs
117
+ alongside but is not required. A red required check leaves the PR open
117
118
  with the bot branch preserved. A shape rebake (exit 2) changes the wire-shape
118
119
  contract, so a human reviews compat-test + the diff before merging.
119
120
 
@@ -93,7 +93,7 @@ curl http://localhost:3456/analytics # per-account / per-model stats, burn ra
93
93
 
94
94
  ## Reading a seat's `status`
95
95
 
96
- `GET /accounts` (and the admin API's `GET /admin/accounts`, in snake_case) report one `status` per seat. It is the routing verdict, and every value comes with the fields that explain it.
96
+ `GET /accounts` (and the admin API's `GET /admin/accounts`, in snake_case) report one `status` per seat. It is the routing verdict, and every value comes with the fields that explain it. Next to it, `action` is the last column of this table in one word: `none`, `wait` (the seat comes back on its own; `resetInMs` says when) or `regrant` (an auth-failure streak, which is a dead refresh token).
97
97
 
98
98
  | `status` | What it means | What to do |
99
99
  |---|---|---|
@@ -102,6 +102,8 @@ curl http://localhost:3456/analytics # per-account / per-model stats, burn ra
102
102
  | `unknown` | No current observation: a seat that has served nothing yet, or a rejection whose window has rolled (`resetInMs: 0`) and that nothing has measured since. | Nothing; the next request measures it. |
103
103
  | `auth-cooldown` | Upstream answered 401/403 or `invalid_grant`. `consecutiveAuthFailures` tells a blip (1) from a dead refresh token (a streak); the cool-down doubles with the streak, from 1 minute to 30. | A streak means re-grant the seat — `dario accounts remove` + `add`, or the admin login flow under the same alias. A new grant starts the seat fresh: no carried-over cool-down, rejection or identity. See [Refresh-token grant age](#refresh-token-grant-age) for the 28-day wall behind most streaks. |
104
104
 
105
+ **When every seat is parked.** A pool whose seats are all `rejected` inside live windows does not probe them again: dario answers the request itself with `429`, `retry-after` set to the earliest reset, `x-dario-upstream-rejection: pool_parked`, and nothing sent upstream. One log line marks the transition (`pool parked: all 6 seats are over their rate-limit windows, earliest resets in 21m`). Before 6.0.35 every such request re-probed the earliest-reset seat, so `rejectedCount` on that seat grew by one per request — a seat reading `rejected_count: 500` next to `request_count: 1` was that, not a seat that needed a re-login. With a `--pool-fallback` armed, the request goes to the fallback instead, as before.
106
+
105
107
  The proxy logs every parking as it happens, once per window: `rate limited (429) on account "spare": 5h 104%, 7d 25%, claim five_hour, resets in 37m — parked until the window rolls`. The re-probes the all-exhausted fallback makes of an already-parked seat are logged only under `-v`.
106
108
 
107
109
  `dario accounts list --live` prints the same view from the running proxy — status with its countdown, the reading and its age, requests served and 429s answered, the organization, shared windows, grant age — where the plain `dario accounts list` only knows what is on disk.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.34",
3
+ "version": "6.0.36",
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": {