@askalf/dario 6.10.6 → 6.11.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/README.md CHANGED
@@ -64,7 +64,7 @@ Prefer Docker? `ghcr.io/askalf/dario:latest` — multi-arch (`amd64` + `arm64`),
64
64
  - **Every tool, one URL.** No per-tool keys and no second bill: anything with a base-URL setting runs on the plan you already pay for. [Per-tool setup](docs/tools.md)
65
65
  - **Either plan, either wire shape.** Your ChatGPT plan behind Claude Code and the Anthropic SDKs; Codex CLI on your Claude plan. [Two plans](docs/two-plans.md)
66
66
  - **Failover that finishes the answer.** A 429 on one plan is re-served by the other, and a stream that dies mid-answer is completed on the same model first. [Failover](docs/two-plans.md#failover-between-subscriptions)
67
- - **Many seats, one endpoint.** Per-model headroom routing, session-sticky prompt cache, in-flight 429 failover across every seat you hold. [The pool](docs/pool-keys-and-analytics.md)
67
+ - **Many seats, one endpoint.** Per-model headroom routing, session-sticky prompt cache, in-flight 429 failover across every seat you hold. New conversations go to the seat with the most headroom by default; `--pool-strategy=fill-first` fills seats in alias order and `expiring-first` spends the seat whose weekly window resets soonest. The active strategy shows in the startup banner, `GET /status`, `dario doctor` and `dario accounts list`. [The pool](docs/pool-keys-and-analytics.md)
68
68
  - **One key per developer.** Named keys with a preferred seat, a model allowlist and a daily budget. [Keys](docs/pool-keys-and-analytics.md#one-key-per-developer)
69
69
  - **Knows what it saved you.** A ledger prices every request at API rates; see it in the TUI, `/analytics`, Prometheus `/metrics` and a per-request timing split. [Analytics](docs/pool-keys-and-analytics.md#what-it-would-have-cost)
70
70
  - **Tracks Claude Code on its own.** Eleven unattended watchers catch wire-shape drift and ship the fix, usually the same day. [Drift](docs/tracking-claude-code.md)
package/dist/cli.js CHANGED
@@ -52,7 +52,7 @@ function readBudgetFlags(args) {
52
52
  return budget;
53
53
  }
54
54
  import { loadAllAccounts as loadAllAccountsForIdentity, regenerateClientIdentity } from './accounts.js';
55
- import { maskEmail, parsePoolHeadroomFloor } from './pool.js';
55
+ import { maskEmail, parsePoolHeadroomFloor, configuredPoolRouting, describePoolStrategy } from './pool.js';
56
56
  import { realpathSync, readFileSync } from 'node:fs';
57
57
  import { join } from 'node:path';
58
58
  import { homedir } from 'node:os';
@@ -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
@@ -1341,6 +1344,11 @@ async function accounts() {
1341
1344
  const loaded = await loadAllAccounts();
1342
1345
  const now = Date.now();
1343
1346
  console.log(` Pool of ${aliases.length} (${aliases.length === 1 ? '1 account' : aliases.length + ' accounts'})`);
1347
+ {
1348
+ const { loadConfig } = await import('./config-file.js');
1349
+ const routing = configuredPoolRouting(loadConfig().config.pool);
1350
+ console.log(` Routing: ${describePoolStrategy(routing.strategy, routing.headroomFloor)}`);
1351
+ }
1344
1352
  if (aliases.length === 1) {
1345
1353
  console.log(' (A pool of one — add another with `dario accounts add <alias>` to load-balance.)');
1346
1354
  }
@@ -2171,13 +2179,15 @@ async function help() {
2171
2179
  dario returns 504 "queue-timeout"
2172
2180
  (default: 60000).
2173
2181
  Env: DARIO_QUEUE_TIMEOUT_MS. (dario#80)
2174
- --pool-strategy=<headroom|fill-first>
2182
+ --pool-strategy=<headroom|fill-first|expiring-first>
2175
2183
  Where new conversations land in a multi-
2176
2184
  account pool. headroom (default) spreads
2177
2185
  them to the seat with the most slack;
2178
2186
  fill-first concentrates them on the
2179
2187
  alphabetically-first eligible seat until
2180
- it drains to the 2% floor, then spills.
2188
+ it drains to the floor, then spills;
2189
+ expiring-first does the same in order of
2190
+ each seat's 7-day reset, soonest first.
2181
2191
  Sticky bindings are unaffected.
2182
2192
  Env: DARIO_POOL_STRATEGY.
2183
2193
  --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)) {
@@ -1132,8 +1132,13 @@ export async function runChecks(opts = {}) {
1132
1132
  // (v5.0): it confirms the sole account is eligible, not rejected.
1133
1133
  if (aliases.length >= 1) {
1134
1134
  try {
1135
- const { AccountPool } = await import('./pool.js');
1136
- const pool = new AccountPool();
1135
+ const { AccountPool, configuredPoolRouting, describePoolStrategy } = await import('./pool.js');
1136
+ const { loadConfig } = await import('./config-file.js');
1137
+ // The preview must use the strategy the proxy will run, not the
1138
+ // default: `new AccountPool()` here always predicted a max-headroom
1139
+ // pick, which is the wrong seat on a fill-first pool.
1140
+ const routing = configuredPoolRouting(loadConfig().config.pool);
1141
+ const pool = new AccountPool(routing.strategy, routing.headroomFloor);
1137
1142
  for (const acc of loaded) {
1138
1143
  pool.add(acc.alias, {
1139
1144
  accessToken: acc.accessToken,
@@ -1148,9 +1153,10 @@ export async function runChecks(opts = {}) {
1148
1153
  checks.push({
1149
1154
  status: 'info',
1150
1155
  label: 'Pool routing',
1151
- detail: next
1152
- ? `next: ${next.alias} (max-headroom select; ${ps.healthy}/${ps.accounts} healthy)`
1153
- : `no eligible account — all rejected or near-expiry (${ps.exhausted}/${ps.accounts} exhausted)`,
1156
+ detail: (next
1157
+ ? `next: ${next.alias} (${ps.healthy}/${ps.accounts} healthy)`
1158
+ : `no eligible account — all rejected or near-expiry (${ps.exhausted}/${ps.accounts} exhausted)`)
1159
+ + ` · ${describePoolStrategy(routing.strategy, routing.headroomFloor)} (env/config; a running proxy's --pool-strategy flag shows in GET /status)`,
1154
1160
  });
1155
1161
  }
1156
1162
  catch (err) {
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;
@@ -361,6 +369,10 @@ export declare function isProbeable(account: PoolAccount, now?: number, family?:
361
369
  */
362
370
  export declare function accountAction(account: PoolAccount, now?: number): 'none' | 'wait' | 'regrant';
363
371
  export interface PoolStatus {
372
+ /** The routing strategy in force for new conversations. */
373
+ strategy: PoolStrategy;
374
+ /** Headroom at/below which a seat counts as drained (ratio). */
375
+ headroomFloor: number;
364
376
  accounts: number;
365
377
  healthy: number;
366
378
  exhausted: number;
@@ -385,7 +397,18 @@ export interface PoolStatus {
385
397
  * pick the fill order. Sticky bindings behave identically in both modes;
386
398
  * strategy only decides where UNBOUND (new) conversations land.
387
399
  */
388
- export type PoolStrategy = 'headroom' | 'fill-first';
400
+ export type PoolStrategy = 'headroom' | 'fill-first' | 'expiring-first';
401
+ /**
402
+ * `expiring-first` — fill-first, ordered by WHEN each seat's capacity expires
403
+ * instead of by alias: new conversations fill the seat whose 7-day window
404
+ * resets soonest until it drains to the floor, then spill to the next-soonest.
405
+ * Subscription capacity is use-it-or-lose-it; spending the soonest-expiring
406
+ * first is what gets the most of it used. Fleet box, 2026-09-22: seat pro1 got
407
+ * a fresh limit reset expiring in ~50h while login's window had ~70h left, and
408
+ * alias-ordered fill-first kept every new conversation on login. A seat with
409
+ * no 7d reading yet (or whose window has already rolled) goes last; ties break
410
+ * by alias. Sticky bindings behave identically.
411
+ */
389
412
  /**
390
413
  * Resolve the pool strategy from an explicit value (CLI flag / config file,
391
414
  * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
@@ -394,6 +417,27 @@ export type PoolStrategy = 'headroom' | 'fill-first';
394
417
  * in this codebase (see resolveSessionRotationConfig).
395
418
  */
396
419
  export declare function resolvePoolStrategy(explicit?: string | null, env?: NodeJS.ProcessEnv): PoolStrategy;
420
+ /**
421
+ * One line saying where new conversations go, for every surface that names the
422
+ * strategy (startup banner, `dario doctor`, `dario accounts list`). The running
423
+ * strategy used to be invisible: the banner printed it only when it was NOT the
424
+ * default, and nothing else reported it, so a deployment whose compose file
425
+ * overrode the default ran that way unnoticed (fleet box, 2026-09-22).
426
+ */
427
+ export declare function describePoolStrategy(strategy: PoolStrategy, floor?: number): string;
428
+ /**
429
+ * The strategy and floor a proxy started with no flags would run with, for
430
+ * commands that do not run the proxy (`doctor`, `accounts list`): env first,
431
+ * then the config file, then the defaults — the proxy's own order minus the
432
+ * CLI flag, which only the running process knows (GET /status reports it).
433
+ */
434
+ export declare function configuredPoolRouting(file: {
435
+ strategy?: string;
436
+ headroomFloor?: number;
437
+ } | undefined, env?: NodeJS.ProcessEnv): {
438
+ strategy: PoolStrategy;
439
+ headroomFloor: number;
440
+ };
397
441
  /**
398
442
  * Which wire buckets bind which model family, when the wire does not say it
399
443
  * by name. `7d_sonnet` names its family; `7d_oi` does not — it is the plan's
@@ -550,8 +594,14 @@ export declare function parsePoolHeadroomFloor(value: string | number | null | u
550
594
  * default, matching `resolvePoolStrategy`.
551
595
  */
552
596
  export declare function resolvePoolHeadroomFloor(explicit?: string | number | null, env?: NodeJS.ProcessEnv): number;
597
+ /**
598
+ * The 7-day reset `expiring-first` orders by, in epoch ms: the seat's own
599
+ * reading while it is still ahead of `now`, else +Infinity (never read, or the
600
+ * window has rolled and the next reset is unknown), which sorts it last.
601
+ */
602
+ export declare function seatExpiry(account: PoolAccount, now?: number): number;
553
603
  export declare class AccountPool {
554
- private readonly strategy;
604
+ readonly strategy: PoolStrategy;
555
605
  /** Headroom at/below which a seat counts as drained — see DEFAULT_POOL_HEADROOM_FLOOR. */
556
606
  readonly headroomFloor: number;
557
607
  private accounts;
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,11 +423,41 @@ 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';
419
430
  }
431
+ /**
432
+ * One line saying where new conversations go, for every surface that names the
433
+ * strategy (startup banner, `dario doctor`, `dario accounts list`). The running
434
+ * strategy used to be invisible: the banner printed it only when it was NOT the
435
+ * default, and nothing else reported it, so a deployment whose compose file
436
+ * overrode the default ran that way unnoticed (fleet box, 2026-09-22).
437
+ */
438
+ export function describePoolStrategy(strategy, floor = DEFAULT_POOL_HEADROOM_FLOOR) {
439
+ const pct = `${Math.round(floor * 100)}% floor`;
440
+ switch (strategy) {
441
+ case 'fill-first':
442
+ return `fill-first — new conversations fill the alphabetically-first seat until the ${pct}, then spill`;
443
+ case 'expiring-first':
444
+ return `expiring-first — new conversations fill the seat whose 7-day window resets soonest until the ${pct}, then spill`;
445
+ default:
446
+ return `headroom (default) — new conversations go to the seat with the most headroom; ${pct}`;
447
+ }
448
+ }
449
+ /**
450
+ * The strategy and floor a proxy started with no flags would run with, for
451
+ * commands that do not run the proxy (`doctor`, `accounts list`): env first,
452
+ * then the config file, then the defaults — the proxy's own order minus the
453
+ * CLI flag, which only the running process knows (GET /status reports it).
454
+ */
455
+ export function configuredPoolRouting(file, env = process.env) {
456
+ return {
457
+ strategy: resolvePoolStrategy(env.DARIO_POOL_STRATEGY ?? file?.strategy, env),
458
+ headroomFloor: resolvePoolHeadroomFloor(env.DARIO_POOL_HEADROOM_FLOOR ?? file?.headroomFloor, env),
459
+ };
460
+ }
420
461
  /**
421
462
  * Match `anthropic-ratelimit-unified-7d_<family>-utilization`. Generic on
422
463
  * `<family>` so a future `7d_opus` / `7d_haiku` (or anything Anthropic
@@ -534,6 +575,7 @@ export function parseRateLimits(headers, family) {
534
575
  status: get('status') || 'unknown',
535
576
  util5h: parseFloat(get('5h-utilization')) || 0,
536
577
  util7d: parseFloat(get('7d-utilization')) || 0,
578
+ reset7d: parseInt(get('7d-reset'), 10) || 0,
537
579
  perModel7d,
538
580
  overageUtil: parseFloat(get('overage-utilization')) || 0,
539
581
  claim: get('representative-claim') || 'unknown',
@@ -797,6 +839,23 @@ function pickMaxHeadroom(accounts, family) {
797
839
  // readdir whose order the OS doesn't guarantee, and the operator can control
798
840
  // alias names but not readdir. Returns null when every candidate is at/below
799
841
  // the floor so the caller can fall back to max-headroom.
842
+ /**
843
+ * The 7-day reset `expiring-first` orders by, in epoch ms: the seat's own
844
+ * reading while it is still ahead of `now`, else +Infinity (never read, or the
845
+ * window has rolled and the next reset is unknown), which sorts it last.
846
+ */
847
+ export function seatExpiry(account, now = Date.now()) {
848
+ const r = (account.rateLimit.reset7d ?? 0) * 1000;
849
+ return r > now ? r : Number.POSITIVE_INFINITY;
850
+ }
851
+ function pickExpiringFirst(accounts, family, floor = DEFAULT_POOL_HEADROOM_FLOOR, now = Date.now()) {
852
+ const order = [...accounts].sort((a, b) => (seatExpiry(a, now) - seatExpiry(b, now)) || (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0));
853
+ for (const a of order) {
854
+ if (computeHeadroom(a.rateLimit, family) > floor)
855
+ return a;
856
+ }
857
+ return null;
858
+ }
800
859
  function pickFillFirst(accounts, family, floor = DEFAULT_POOL_HEADROOM_FLOOR) {
801
860
  let best = null;
802
861
  for (const a of accounts) {
@@ -932,8 +991,10 @@ export class AccountPool {
932
991
  const all = [...this.accounts.values()];
933
992
  const eligible = all.filter(a => isAccountEligible(a, now, family));
934
993
  if (eligible.length > 0) {
935
- if (this.strategy === 'fill-first') {
936
- const first = pickFillFirst(eligible, family, this.headroomFloor);
994
+ if (this.strategy === 'fill-first' || this.strategy === 'expiring-first') {
995
+ const first = this.strategy === 'fill-first'
996
+ ? pickFillFirst(eligible, family, this.headroomFloor)
997
+ : pickExpiringFirst(eligible, family, this.headroomFloor, now);
937
998
  if (first)
938
999
  return first;
939
1000
  // Every eligible account is at/below the floor — the terminal state
@@ -1099,8 +1160,10 @@ export class AccountPool {
1099
1160
  // after a 429 is the next alias in line, not the max-headroom seat —
1100
1161
  // otherwise a single failover would defeat the concentration the
1101
1162
  // strategy exists to provide.
1102
- if (this.strategy === 'fill-first') {
1103
- const first = pickFillFirst(eligible, family, this.headroomFloor);
1163
+ if (this.strategy === 'fill-first' || this.strategy === 'expiring-first') {
1164
+ const first = this.strategy === 'fill-first'
1165
+ ? pickFillFirst(eligible, family, this.headroomFloor)
1166
+ : pickExpiringFirst(eligible, family, this.headroomFloor, now);
1104
1167
  if (first)
1105
1168
  return first;
1106
1169
  }
@@ -1120,7 +1183,12 @@ export class AccountPool {
1120
1183
  const account = this.accounts.get(alias);
1121
1184
  if (!account)
1122
1185
  return;
1123
- account.rateLimit = withParkedBuckets(account.rateLimit, withBoundBuckets(account.rateLimit, snapshot), snapshot.updatedAt || Date.now());
1186
+ const at = snapshot.updatedAt || Date.now();
1187
+ // A reading without the 7d-reset header does not un-learn it while it is
1188
+ // still ahead: expiring-first would otherwise send the seat to the back.
1189
+ const prevReset7d = account.rateLimit.reset7d ?? 0;
1190
+ const next = !snapshot.reset7d && prevReset7d * 1000 > at ? { ...snapshot, reset7d: prevReset7d } : snapshot;
1191
+ account.rateLimit = withParkedBuckets(account.rateLimit, withBoundBuckets(account.rateLimit, next), at);
1124
1192
  account.adoptedFrom = undefined;
1125
1193
  account.requestCount++;
1126
1194
  }
@@ -1226,6 +1294,8 @@ export class AccountPool {
1226
1294
  const avgHeadroom = headrooms.length > 0 ? headrooms.reduce((a, b) => a + b, 0) / headrooms.length : 0;
1227
1295
  const best = this.select();
1228
1296
  return {
1297
+ strategy: this.strategy,
1298
+ headroomFloor: this.headroomFloor,
1229
1299
  accounts: all.length,
1230
1300
  healthy: healthy.length,
1231
1301
  exhausted: all.length - healthy.length,
package/dist/proxy.js CHANGED
@@ -13,7 +13,7 @@ import { CC_TOOL_DEFINITIONS_UNADVERTISABLE, CC_TEMPLATE_PROMPT_BYTES, resolveMa
13
13
  import { stampCch, hasCchSeed } from './cch.js';
14
14
  import { foldTiming, timingHeaders, timingLogFields } from './timing.js';
15
15
  import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
16
- import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, activeParkedBuckets, reconcilePoolAccounts, resolvePoolStrategy, resolvePoolHeadroomFloor, DEFAULT_POOL_HEADROOM_FLOOR, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail, isAccountEligible } from './pool.js';
16
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, activeParkedBuckets, reconcilePoolAccounts, resolvePoolStrategy, resolvePoolHeadroomFloor, describePoolStrategy, DEFAULT_POOL_HEADROOM_FLOOR, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail, isAccountEligible } from './pool.js';
17
17
  import { backfillIdentity } from './accounts.js';
18
18
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
19
19
  import { Analytics, billingBucketFromClaim, costOfTokens, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
@@ -1521,9 +1521,9 @@ export async function startProxy(opts = {}) {
1521
1521
  if (opts.poolSharedState && !poolSync) {
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
- if (poolStrategy !== 'headroom') {
1525
- console.log(` Pool strategy: ${poolStrategy} (new conversations fill the alphabetically-first seat, spill at the 2% floor)`);
1526
- }
1524
+ // Always named, the default included: a deployment that overrides the
1525
+ // default should be visible at a glance, and one that does not should say so.
1526
+ console.log(` Pool strategy: ${describePoolStrategy(poolStrategy, pool.headroomFloor)}`);
1527
1527
  if (poolSync) {
1528
1528
  console.log(` Pool shared state: on (instance ${poolSync.instance}, via ${lockUrl}, pulling peers every ${poolSync.intervalMs}ms; fails open)`);
1529
1529
  poolSync.start();
@@ -2679,7 +2679,14 @@ export async function startProxy(opts = {}) {
2679
2679
  // Version for auto-update checks (#640) — /status is key-gated (loopback
2680
2680
  // or DARIO_API_KEY), so no public-disclosure concern like /health has.
2681
2681
  res.writeHead(200, JSON_HEADERS);
2682
- res.end(JSON.stringify({ version: darioVersion(), ...s }));
2682
+ res.end(JSON.stringify({
2683
+ version: darioVersion(),
2684
+ ...s,
2685
+ // The routing the running process uses — the only surface that can
2686
+ // report a --pool-strategy flag (doctor and `accounts list` read env
2687
+ // and config). null with no pool.
2688
+ pool: pool.size > 0 ? { strategy: pool.strategy, headroomFloor: pool.headroomFloor, accounts: pool.size } : null,
2689
+ }));
2683
2690
  return;
2684
2691
  }
2685
2692
  // Pool status endpoint — shows loaded accounts, headroom, and the
@@ -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.1",
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": {