@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 +1 -1
- package/dist/cli.js +16 -6
- package/dist/config-file.d.ts +3 -1
- package/dist/config-file.js +2 -1
- package/dist/doctor-core.js +11 -5
- package/dist/pool.d.ts +52 -2
- package/dist/pool.js +76 -6
- package/dist/proxy.js +12 -5
- package/dist/tui/tabs/config.js +2 -2
- package/docs/commands.md +1 -1
- package/docs/multi-account-pool.md +3 -1
- package/docs/pool-keys-and-analytics.md +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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>
|
package/dist/config-file.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/config-file.js
CHANGED
|
@@ -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/doctor-core.js
CHANGED
|
@@ -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
|
|
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} (
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1525
|
-
|
|
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({
|
|
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
|
package/dist/tui/tabs/config.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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": {
|