@askalf/dario 6.10.6 → 6.11.0

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/dist/cli.js CHANGED
@@ -520,11 +520,14 @@ async function proxy() {
520
520
  // `fill-first` concentrates them on the alphabetically-first eligible seat
521
521
  // until it drains to the 2% floor, then spills — primary/backup semantics,
522
522
  // alias naming (`1-main`, `2-overflow`) is the ordering knob. Sticky
523
- // bindings behave identically under both.
523
+ // bindings behave identically under both. `expiring-first` is fill-first
524
+ // ordered by each seat's 7-day reset, soonest first: spend the capacity that
525
+ // expires soonest before touching the rest.
524
526
  const poolStrategyFromFlag = args.find((a) => a.startsWith('--pool-strategy='))?.split('=')[1];
525
527
  if (poolStrategyFromFlag !== undefined
526
- && poolStrategyFromFlag !== 'headroom' && poolStrategyFromFlag !== 'fill-first') {
527
- console.error(`[dario] Invalid --pool-strategy "${poolStrategyFromFlag}". Must be headroom or fill-first.`);
528
+ && poolStrategyFromFlag !== 'headroom' && poolStrategyFromFlag !== 'fill-first'
529
+ && poolStrategyFromFlag !== 'expiring-first') {
530
+ console.error(`[dario] Invalid --pool-strategy "${poolStrategyFromFlag}". Must be headroom, fill-first or expiring-first.`);
528
531
  process.exit(1);
529
532
  }
530
533
  const poolStrategy = poolStrategyFromFlag
@@ -2171,13 +2174,15 @@ async function help() {
2171
2174
  dario returns 504 "queue-timeout"
2172
2175
  (default: 60000).
2173
2176
  Env: DARIO_QUEUE_TIMEOUT_MS. (dario#80)
2174
- --pool-strategy=<headroom|fill-first>
2177
+ --pool-strategy=<headroom|fill-first|expiring-first>
2175
2178
  Where new conversations land in a multi-
2176
2179
  account pool. headroom (default) spreads
2177
2180
  them to the seat with the most slack;
2178
2181
  fill-first concentrates them on the
2179
2182
  alphabetically-first eligible seat until
2180
- it drains to the 2% floor, then spills.
2183
+ it drains to the floor, then spills;
2184
+ expiring-first does the same in order of
2185
+ each seat's 7-day reset, soonest first.
2181
2186
  Sticky bindings are unaffected.
2182
2187
  Env: DARIO_POOL_STRATEGY.
2183
2188
  --pool-headroom-floor=<ratio|percent>
@@ -86,8 +86,10 @@ export interface DarioConfig {
86
86
  * most headroom; `fill-first` concentrates them on the alphabetically-
87
87
  * first eligible seat until it drains to the 2% floor, then spills to
88
88
  * the next — primary/backup semantics, alias order is the knob.
89
+ * `expiring-first` is fill-first ordered by each seat's 7-day reset,
90
+ * soonest first.
89
91
  */
90
- strategy?: 'headroom' | 'fill-first';
92
+ strategy?: 'headroom' | 'fill-first' | 'expiring-first';
91
93
  /** Headroom at/below which a seat counts as drained — ratio 0.02..0.5 (dario#1333). */
92
94
  headroomFloor?: number;
93
95
  };
@@ -308,7 +308,8 @@ function sanitize(parsed) {
308
308
  }
309
309
  if (isPlainObject(parsed.pool)) {
310
310
  out.pool = {};
311
- if (parsed.pool.strategy === 'headroom' || parsed.pool.strategy === 'fill-first') {
311
+ if (parsed.pool.strategy === 'headroom' || parsed.pool.strategy === 'fill-first'
312
+ || parsed.pool.strategy === 'expiring-first') {
312
313
  out.pool.strategy = parsed.pool.strategy;
313
314
  }
314
315
  if (typeof parsed.pool.headroomFloor === 'number' && Number.isFinite(parsed.pool.headroomFloor)) {
package/dist/pool.d.ts CHANGED
@@ -33,6 +33,14 @@ export interface RateLimitSnapshot {
33
33
  * were on the response.
34
34
  */
35
35
  perModel7d: Record<string, number>;
36
+ /**
37
+ * Epoch SECONDS the seat's own 7-day window resets at, from
38
+ * `anthropic-ratelimit-unified-7d-reset`; 0 when the response did not carry
39
+ * it. Distinct from `reset`, which is the representative claim's reset (on
40
+ * most responses the five-hour one). `expiring-first` orders seats by it:
41
+ * capacity that expires soonest is spent first.
42
+ */
43
+ reset7d?: number;
36
44
  overageUtil: number;
37
45
  claim: string;
38
46
  reset: number;
@@ -385,7 +393,18 @@ export interface PoolStatus {
385
393
  * pick the fill order. Sticky bindings behave identically in both modes;
386
394
  * strategy only decides where UNBOUND (new) conversations land.
387
395
  */
388
- export type PoolStrategy = 'headroom' | 'fill-first';
396
+ export type PoolStrategy = 'headroom' | 'fill-first' | 'expiring-first';
397
+ /**
398
+ * `expiring-first` — fill-first, ordered by WHEN each seat's capacity expires
399
+ * instead of by alias: new conversations fill the seat whose 7-day window
400
+ * resets soonest until it drains to the floor, then spill to the next-soonest.
401
+ * Subscription capacity is use-it-or-lose-it; spending the soonest-expiring
402
+ * first is what gets the most of it used. Fleet box, 2026-09-22: seat pro1 got
403
+ * a fresh limit reset expiring in ~50h while login's window had ~70h left, and
404
+ * alias-ordered fill-first kept every new conversation on login. A seat with
405
+ * no 7d reading yet (or whose window has already rolled) goes last; ties break
406
+ * by alias. Sticky bindings behave identically.
407
+ */
389
408
  /**
390
409
  * Resolve the pool strategy from an explicit value (CLI flag / config file,
391
410
  * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
@@ -550,6 +569,12 @@ export declare function parsePoolHeadroomFloor(value: string | number | null | u
550
569
  * default, matching `resolvePoolStrategy`.
551
570
  */
552
571
  export declare function resolvePoolHeadroomFloor(explicit?: string | number | null, env?: NodeJS.ProcessEnv): number;
572
+ /**
573
+ * The 7-day reset `expiring-first` orders by, in epoch ms: the seat's own
574
+ * reading while it is still ahead of `now`, else +Infinity (never read, or the
575
+ * window has rolled and the next reset is unknown), which sorts it last.
576
+ */
577
+ export declare function seatExpiry(account: PoolAccount, now?: number): number;
553
578
  export declare class AccountPool {
554
579
  private readonly strategy;
555
580
  /** Headroom at/below which a seat counts as drained — see DEFAULT_POOL_HEADROOM_FLOOR. */
package/dist/pool.js CHANGED
@@ -400,6 +400,17 @@ export function accountAction(account, now = Date.now()) {
400
400
  return 'wait';
401
401
  return 'none';
402
402
  }
403
+ /**
404
+ * `expiring-first` — fill-first, ordered by WHEN each seat's capacity expires
405
+ * instead of by alias: new conversations fill the seat whose 7-day window
406
+ * resets soonest until it drains to the floor, then spill to the next-soonest.
407
+ * Subscription capacity is use-it-or-lose-it; spending the soonest-expiring
408
+ * first is what gets the most of it used. Fleet box, 2026-09-22: seat pro1 got
409
+ * a fresh limit reset expiring in ~50h while login's window had ~70h left, and
410
+ * alias-ordered fill-first kept every new conversation on login. A seat with
411
+ * no 7d reading yet (or whose window has already rolled) goes last; ties break
412
+ * by alias. Sticky bindings behave identically.
413
+ */
403
414
  /**
404
415
  * Resolve the pool strategy from an explicit value (CLI flag / config file,
405
416
  * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
@@ -412,7 +423,7 @@ export function resolvePoolStrategy(explicit, env = process.env) {
412
423
  if (typeof c !== 'string')
413
424
  continue;
414
425
  const s = c.trim().toLowerCase();
415
- if (s === 'headroom' || s === 'fill-first')
426
+ if (s === 'headroom' || s === 'fill-first' || s === 'expiring-first')
416
427
  return s;
417
428
  }
418
429
  return 'headroom';
@@ -534,6 +545,7 @@ export function parseRateLimits(headers, family) {
534
545
  status: get('status') || 'unknown',
535
546
  util5h: parseFloat(get('5h-utilization')) || 0,
536
547
  util7d: parseFloat(get('7d-utilization')) || 0,
548
+ reset7d: parseInt(get('7d-reset'), 10) || 0,
537
549
  perModel7d,
538
550
  overageUtil: parseFloat(get('overage-utilization')) || 0,
539
551
  claim: get('representative-claim') || 'unknown',
@@ -797,6 +809,23 @@ function pickMaxHeadroom(accounts, family) {
797
809
  // readdir whose order the OS doesn't guarantee, and the operator can control
798
810
  // alias names but not readdir. Returns null when every candidate is at/below
799
811
  // the floor so the caller can fall back to max-headroom.
812
+ /**
813
+ * The 7-day reset `expiring-first` orders by, in epoch ms: the seat's own
814
+ * reading while it is still ahead of `now`, else +Infinity (never read, or the
815
+ * window has rolled and the next reset is unknown), which sorts it last.
816
+ */
817
+ export function seatExpiry(account, now = Date.now()) {
818
+ const r = (account.rateLimit.reset7d ?? 0) * 1000;
819
+ return r > now ? r : Number.POSITIVE_INFINITY;
820
+ }
821
+ function pickExpiringFirst(accounts, family, floor = DEFAULT_POOL_HEADROOM_FLOOR, now = Date.now()) {
822
+ const order = [...accounts].sort((a, b) => (seatExpiry(a, now) - seatExpiry(b, now)) || (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0));
823
+ for (const a of order) {
824
+ if (computeHeadroom(a.rateLimit, family) > floor)
825
+ return a;
826
+ }
827
+ return null;
828
+ }
800
829
  function pickFillFirst(accounts, family, floor = DEFAULT_POOL_HEADROOM_FLOOR) {
801
830
  let best = null;
802
831
  for (const a of accounts) {
@@ -932,8 +961,10 @@ export class AccountPool {
932
961
  const all = [...this.accounts.values()];
933
962
  const eligible = all.filter(a => isAccountEligible(a, now, family));
934
963
  if (eligible.length > 0) {
935
- if (this.strategy === 'fill-first') {
936
- const first = pickFillFirst(eligible, family, this.headroomFloor);
964
+ if (this.strategy === 'fill-first' || this.strategy === 'expiring-first') {
965
+ const first = this.strategy === 'fill-first'
966
+ ? pickFillFirst(eligible, family, this.headroomFloor)
967
+ : pickExpiringFirst(eligible, family, this.headroomFloor, now);
937
968
  if (first)
938
969
  return first;
939
970
  // Every eligible account is at/below the floor — the terminal state
@@ -1099,8 +1130,10 @@ export class AccountPool {
1099
1130
  // after a 429 is the next alias in line, not the max-headroom seat —
1100
1131
  // otherwise a single failover would defeat the concentration the
1101
1132
  // strategy exists to provide.
1102
- if (this.strategy === 'fill-first') {
1103
- const first = pickFillFirst(eligible, family, this.headroomFloor);
1133
+ if (this.strategy === 'fill-first' || this.strategy === 'expiring-first') {
1134
+ const first = this.strategy === 'fill-first'
1135
+ ? pickFillFirst(eligible, family, this.headroomFloor)
1136
+ : pickExpiringFirst(eligible, family, this.headroomFloor, now);
1104
1137
  if (first)
1105
1138
  return first;
1106
1139
  }
@@ -1120,7 +1153,12 @@ export class AccountPool {
1120
1153
  const account = this.accounts.get(alias);
1121
1154
  if (!account)
1122
1155
  return;
1123
- account.rateLimit = withParkedBuckets(account.rateLimit, withBoundBuckets(account.rateLimit, snapshot), snapshot.updatedAt || Date.now());
1156
+ const at = snapshot.updatedAt || Date.now();
1157
+ // A reading without the 7d-reset header does not un-learn it while it is
1158
+ // still ahead: expiring-first would otherwise send the seat to the back.
1159
+ const prevReset7d = account.rateLimit.reset7d ?? 0;
1160
+ const next = !snapshot.reset7d && prevReset7d * 1000 > at ? { ...snapshot, reset7d: prevReset7d } : snapshot;
1161
+ account.rateLimit = withParkedBuckets(account.rateLimit, withBoundBuckets(account.rateLimit, next), at);
1124
1162
  account.adoptedFrom = undefined;
1125
1163
  account.requestCount++;
1126
1164
  }
package/dist/proxy.js CHANGED
@@ -1522,7 +1522,8 @@ export async function startProxy(opts = {}) {
1522
1522
  console.error('[dario] --pool-shared-state needs DARIO_REFRESH_LOCK_URL (the lock service carries the shared state) — running with this instance\'s own state');
1523
1523
  }
1524
1524
  if (poolStrategy !== 'headroom') {
1525
- console.log(` Pool strategy: ${poolStrategy} (new conversations fill the alphabetically-first seat, spill at the 2% floor)`);
1525
+ const order = poolStrategy === 'expiring-first' ? 'the seat whose 7-day window resets soonest' : 'the alphabetically-first seat';
1526
+ console.log(` Pool strategy: ${poolStrategy} (new conversations fill ${order}, spill at the ${Math.round(pool.headroomFloor * 100)}% floor)`);
1526
1527
  }
1527
1528
  if (poolSync) {
1528
1529
  console.log(` Pool shared state: on (instance ${poolSync.instance}, via ${lockUrl}, pulling peers every ${poolSync.intervalMs}ms; fails open)`);
@@ -38,7 +38,7 @@ const FIELDS = [
38
38
  { path: 'thinkTime.maxMs', label: 'Think-time cap (ms)', type: 'number', hint: 'upper bound for the whole formula' },
39
39
  { path: 'sessionStart.minMs', label: 'Session-start min', type: 'number', hint: 'first-request delay floor' },
40
40
  { path: 'sessionStart.jitterMs', label: 'Session-start jitter', type: 'number' },
41
- { path: 'pool.strategy', label: 'Pool strategy', type: 'string', hint: '"headroom" (default) or "fill-first"' },
41
+ { path: 'pool.strategy', label: 'Pool strategy', type: 'string', hint: '"headroom" (default), "expiring-first" or "fill-first"' },
42
42
  // ── Overage-guard (v4.1, dario#288) ─────────────────────────
43
43
  { path: 'overageGuard.enabled', label: 'Overage-guard', type: 'bool', hint: 'halt proxy on any representative-claim=overage' },
44
44
  { path: 'overageGuard.behavior', label: 'Overage behavior', type: 'string', hint: '"halt" (default) or "warn"' },
@@ -271,7 +271,7 @@ function commitEdit(state) {
271
271
  */
272
272
  const STRING_ENUMS = {
273
273
  'overageGuard.behavior': ['halt', 'warn'],
274
- 'pool.strategy': ['headroom', 'fill-first'],
274
+ 'pool.strategy': ['headroom', 'fill-first', 'expiring-first'],
275
275
  };
276
276
  function doSave(state) {
277
277
  try {
package/docs/commands.md CHANGED
@@ -51,7 +51,7 @@ This page is the per-flag reference. For environment variables grouped by task
51
51
  | `--session-rotate-jitter=<ms>` / `DARIO_SESSION_JITTER_MS` | Jitter sampled once per session at creation — hides the exact idle floor. (v3.28) | `0` |
52
52
  | `--session-max-age=<ms>` / `DARIO_SESSION_MAX_AGE_MS` | Hard ceiling on a session-id's lifetime regardless of activity. (v3.28) | off |
53
53
  | `--session-per-client` / `DARIO_SESSION_PER_CLIENT=1` | Split session-id registry by a per-client header so multi-UI fan-out doesn't collapse onto one id. (v3.28) | off |
54
- | `--pool-strategy=<headroom\|fill-first>` / `DARIO_POOL_STRATEGY` | Where new conversations land in a multi-account pool. `headroom` spreads them to the seat with the most slack; `fill-first` concentrates them on the alphabetically-first eligible seat until it drains to the 2% floor, then spills to the next — primary/backup semantics, alias naming (`1-main`, `2-overflow`) picks the fill order. Sticky bindings behave identically under both. See [Multi-account pool](./multi-account-pool.md#routing-strategy). | `headroom` |
54
+ | `--pool-strategy=<headroom\|fill-first\|expiring-first>` / `DARIO_POOL_STRATEGY` | Where new conversations land in a multi-account pool. `headroom` spreads them to the seat with the most slack; `fill-first` concentrates them on the alphabetically-first eligible seat until it drains to the floor, then spills to the next — primary/backup semantics, alias naming (`1-main`, `2-overflow`) picks the fill order; `expiring-first` does the same in order of each seat's 7-day reset, soonest first, so capacity that expires soonest is spent first. Sticky bindings behave identically under all three. See [Multi-account pool](./multi-account-pool.md#routing-strategy). | `headroom` |
55
55
  | `--pool-fallback=<models>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | Strictly opt-in. When every pool seat is drained or in auth cool-down (at selection, or mid-flight on a 429 with no peer left), serve the request as `<model>` from whichever provider can, instead of surfacing the 429/503. Accepts a **chain** — `gpt-5.6-sol,claude-sonnet-5` — read left to right, each provider taking the first entry it can serve. A Codex/ChatGPT subscription that lists the model is preferred (no per-token cost) and works on **both** wire shapes; otherwise a configured openai-compat backend, which is still OpenAI-path only (no Messages translation on that route). A chain also makes failover **symmetric**: a rate-limited or failing subscription hands the request back to the Claude pool. Only a 429/5xx fails over — a 400 surfaces, since a bad request would just reproduce itself elsewhere. Every substituted response carries `x-dario-pool-fallback: <model>` — never silent. Needs a Codex account (`dario add altman`) or a backend (`dario backend add …`); `dario doctor` reports it as INERT with neither. Empty pool still 503s (setup error, not traffic to re-bill). Empty flag value disables, overriding env + config. See [Pool-exhausted fallback](./multi-account-pool.md#pool-exhausted-fallback). | off |
56
56
  | `--system-prompt=<verbatim\|partial\|aggressive\|filepath>` / `DARIO_SYSTEM_PROMPT` | System-prompt mode for outbound CC-shaped requests. `partial` strips behavioral constraints (Tone-and-style, Text-output, scope/verbosity/comment bullets) for ~1.2–2.8× output capability on open-ended work. `aggressive` adds prompt-level RLHF restatement removal (<3% over partial — alignment is RLHF-trained). `<filepath>` fully replaces the slot with file contents. Empirically validated as unfingerprinted by the billing classifier — see [`system-prompt.md`](./system-prompt.md) and [`research/system-prompt-classifier-study.md`](./research/system-prompt-classifier-study.md). (v3.34) | `verbatim` |
57
57
  | `--upstream-proxy=<url>` / `--via=<url>` / `DARIO_UPSTREAM_PROXY` | Route dario's outbound fetches (api.anthropic.com, OpenAI-compat backends, OAuth) through an HTTP/HTTPS proxy. Pair with the HTTP proxy mode of a VPN provider (Mullvad, AirVPN), a corporate proxy, privoxy/Tor, etc. Localhost calls bypass. Requires Bun runtime; SOCKS5 not supported. Full provider matrix + setup in [`vpn-routing.md`](./vpn-routing.md). (v3.35) | unset |
@@ -29,7 +29,9 @@ Two situations where that beats spreading:
29
29
  - **Primary/backup seats.** A `z-backup` account stays completely untouched — fresh 5h and 7d windows — until `a-main` is actually drained. Headroom spreading would nibble at both from the first request.
30
30
  - **Cache concentration.** Every fresh conversation lands where the prompt-cache pressure already is, so the spill seat's windows are fully fresh when the primary hits its wall.
31
31
 
32
- Alias order is the operator's knob: name seats `1-main` / `2-overflow` to pick the fill order. Strategy only decides where **unbound** conversations land — sticky bindings (below) behave identically in both modes, and a conversation bound to a seat stays there until that seat is rejected, expiring, or under the floor.
32
+ Alias order is the operator's knob: name seats `1-main` / `2-overflow` to pick the fill order. Strategy only decides where **unbound** conversations land — sticky bindings (below) behave identically in every mode, and a conversation bound to a seat stays there until that seat is rejected, expiring, or under the floor.
33
+
34
+ `--pool-strategy=expiring-first` is fill-first ordered by **when each seat's capacity expires** instead of by alias: new conversations fill the seat whose 7-day window resets soonest (`anthropic-ratelimit-unified-7d-reset`) until it drains to the floor, then spill to the next-soonest; failover follows the same order. Subscription capacity is use-it-or-lose-it, so this is the order that gets the most of it used — the case it was built for is a seat that just got a limit reset, or joined the pool mid-week, sitting beside one whose window has days left. A seat with no 7-day reading yet, or whose window has already rolled, goes last; ties break by alias, so a pool with no readings behaves exactly like fill-first.
33
35
 
34
36
  ## Session stickiness
35
37
 
@@ -21,7 +21,7 @@ Three things it does that a round-robin doesn't:
21
21
 
22
22
  <img src="../.github/readme/pool.jpg" alt="Three pooled seats, work, personal and side, each with a headroom bar. dario routes the request to the seat with the most headroom." width="100%">
23
23
 
24
- `--pool-strategy=fill-first` concentrates new conversations on one seat until it drains, for primary/backup setups. `--pool-headroom-floor=5%` (env `DARIO_POOL_HEADROOM_FLOOR`, config `pool.headroomFloor`; default 2%) moves the line at which a seat counts as drained: a sticky session rebinds off it and new conversations skip it once its headroom is at or below the floor, so a seat that answers with API errors in its last percent is left alone before the 429, not at it. Refresh tokens expire about 28 days after the original grant regardless of rotation, so every seat's grant age is tracked and surfaced in `dario accounts list`, `dario doctor` and `GET /accounts` before it becomes a silent outage. Provision over HTTP with the headless [admin API](admin-api.md); pin one request to one seat with `dario accounts check <alias>` (admin API required: `DARIO_ADMIN=1` and a `DARIO_ADMIN_TOKEN`). Internals and the live `/accounts` + `/analytics` endpoints: [multi-account-pool.md](multi-account-pool.md); covered end-to-end by [`test/pool-e2e.mjs`](../test/pool-e2e.mjs).
24
+ `--pool-strategy=fill-first` concentrates new conversations on one seat until it drains, for primary/backup setups; `--pool-strategy=expiring-first` fills in order of each seat's 7-day reset, soonest first, so capacity that expires soonest is spent first. `--pool-headroom-floor=5%` (env `DARIO_POOL_HEADROOM_FLOOR`, config `pool.headroomFloor`; default 2%) moves the line at which a seat counts as drained: a sticky session rebinds off it and new conversations skip it once its headroom is at or below the floor, so a seat that answers with API errors in its last percent is left alone before the 429, not at it. Refresh tokens expire about 28 days after the original grant regardless of rotation, so every seat's grant age is tracked and surfaced in `dario accounts list`, `dario doctor` and `GET /accounts` before it becomes a silent outage. Provision over HTTP with the headless [admin API](admin-api.md); pin one request to one seat with `dario accounts check <alias>` (admin API required: `DARIO_ADMIN=1` and a `DARIO_ADMIN_TOKEN`). Internals and the live `/accounts` + `/analytics` endpoints: [multi-account-pool.md](multi-account-pool.md); covered end-to-end by [`test/pool-e2e.mjs`](../test/pool-e2e.mjs).
25
25
 
26
26
  ## One key per developer
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.10.6",
3
+ "version": "6.11.0",
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": {