@askalf/dario 5.2.4 → 5.2.6

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
@@ -10,6 +10,7 @@
10
10
  <a href="https://github.com/askalf/dario/actions/workflows/ci.yml"><img src="https://github.com/askalf/dario/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
11
11
  <a href="https://github.com/askalf/dario/actions/workflows/codeql.yml"><img src="https://github.com/askalf/dario/actions/workflows/codeql.yml/badge.svg" alt="CodeQL"></a>
12
12
  <a href="https://scorecard.dev/viewer/?uri=github.com/askalf/dario"><img src="https://img.shields.io/ossf-scorecard/github.com/askalf/dario?label=OpenSSF%20Scorecard&color=6f42c1" alt="OpenSSF Scorecard"></a>
13
+ <a href="https://www.bestpractices.dev/projects/13638"><img src="https://www.bestpractices.dev/projects/13638/badge" alt="OpenSSF Best Practices"></a>
13
14
  <a href="https://github.com/askalf/dario/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/@askalf/dario?color=6f42c1" alt="License"></a>
14
15
  <a href="https://www.npmjs.com/package/@askalf/dario"><img src="https://img.shields.io/npm/dm/@askalf/dario?color=6f42c1" alt="Downloads"></a>
15
16
  <a href="https://x.com/ask_alf"><img src="https://img.shields.io/badge/follow-@ask__alf-1da1f2?style=flat-square" alt="Follow on X"></a>
@@ -475,8 +475,27 @@ export declare const CC_CACHE_CONTROL: CacheControl;
475
475
  * without the enabling beta is not a shape real CC produces, and forwarding
476
476
  * half of it risks an upstream 400. DARIO_CACHE_TTL_5M=1 restores the
477
477
  * pre-fix behavior (always bare 5m) as the operator escape hatch.
478
+ *
479
+ * DARIO_CACHE_TTL_1H=1 is the opposite override: force `ttl:'1h'` on every
480
+ * breakpoint regardless of what the client sent, for a client that can't
481
+ * emit the 1h stamp itself (an SDK/agent harness that only stamps bare 5m —
482
+ * dario#678). The proxy adds the enabling `extended-cache-ttl-` beta to the
483
+ * outbound set so the 1h is honored. Deliberate override of the mirror
484
+ * guardrail: 1h cache *writes* bill ~2× the 5m rate, so it only wins when
485
+ * idle gaps routinely exceed the 5-minute window; on rapid back-to-back
486
+ * turns it costs more. 5M takes precedence if both are set.
478
487
  */
479
488
  export declare function effectiveCacheControl(clientBody: Record<string, unknown>, clientBeta?: string): CacheControl;
489
+ /** The anthropic-beta flag that enables the 1-hour prompt-cache TTL. */
490
+ export declare const EXTENDED_CACHE_TTL_BETA = "extended-cache-ttl-2025-04-11";
491
+ /**
492
+ * When DARIO_CACHE_TTL_1H forces the 1h stamp, the outbound beta set must also
493
+ * carry `extended-cache-ttl-` or Anthropic ignores the ttl. Add it (idempotent)
494
+ * unless DARIO_CACHE_TTL_5M overrides (5M wins, matching effectiveCacheControl).
495
+ * Pure — `env` is injectable for tests. Returns `beta` unchanged when the flag
496
+ * is off or the beta is already present.
497
+ */
498
+ export declare function withForced1hBeta(beta: string, env?: Record<string, string | undefined>): string;
480
499
  /**
481
500
  * Place CC-style prompt-cache breakpoints on the conversation. The system
482
501
  * prompt is already cached at build time (2 system breakpoints); this adds a
@@ -1260,10 +1260,21 @@ export const CC_CACHE_CONTROL = { type: 'ephemeral' };
1260
1260
  * without the enabling beta is not a shape real CC produces, and forwarding
1261
1261
  * half of it risks an upstream 400. DARIO_CACHE_TTL_5M=1 restores the
1262
1262
  * pre-fix behavior (always bare 5m) as the operator escape hatch.
1263
+ *
1264
+ * DARIO_CACHE_TTL_1H=1 is the opposite override: force `ttl:'1h'` on every
1265
+ * breakpoint regardless of what the client sent, for a client that can't
1266
+ * emit the 1h stamp itself (an SDK/agent harness that only stamps bare 5m —
1267
+ * dario#678). The proxy adds the enabling `extended-cache-ttl-` beta to the
1268
+ * outbound set so the 1h is honored. Deliberate override of the mirror
1269
+ * guardrail: 1h cache *writes* bill ~2× the 5m rate, so it only wins when
1270
+ * idle gaps routinely exceed the 5-minute window; on rapid back-to-back
1271
+ * turns it costs more. 5M takes precedence if both are set.
1263
1272
  */
1264
1273
  export function effectiveCacheControl(clientBody, clientBeta) {
1265
1274
  if (process.env['DARIO_CACHE_TTL_5M'] === '1')
1266
1275
  return CC_CACHE_CONTROL;
1276
+ if (process.env['DARIO_CACHE_TTL_1H'] === '1')
1277
+ return { type: 'ephemeral', ttl: '1h' };
1267
1278
  if (!clientBeta || !clientBeta.includes('extended-cache-ttl-'))
1268
1279
  return CC_CACHE_CONTROL;
1269
1280
  const scan = (blocks) => {
@@ -1289,6 +1300,22 @@ export function effectiveCacheControl(clientBody, clientBeta) {
1289
1300
  }
1290
1301
  return CC_CACHE_CONTROL;
1291
1302
  }
1303
+ /** The anthropic-beta flag that enables the 1-hour prompt-cache TTL. */
1304
+ export const EXTENDED_CACHE_TTL_BETA = 'extended-cache-ttl-2025-04-11';
1305
+ /**
1306
+ * When DARIO_CACHE_TTL_1H forces the 1h stamp, the outbound beta set must also
1307
+ * carry `extended-cache-ttl-` or Anthropic ignores the ttl. Add it (idempotent)
1308
+ * unless DARIO_CACHE_TTL_5M overrides (5M wins, matching effectiveCacheControl).
1309
+ * Pure — `env` is injectable for tests. Returns `beta` unchanged when the flag
1310
+ * is off or the beta is already present.
1311
+ */
1312
+ export function withForced1hBeta(beta, env = process.env) {
1313
+ if (env['DARIO_CACHE_TTL_1H'] !== '1' || env['DARIO_CACHE_TTL_5M'] === '1')
1314
+ return beta;
1315
+ if (beta.split(',').includes(EXTENDED_CACHE_TTL_BETA))
1316
+ return beta;
1317
+ return beta.length > 0 ? beta + ',' + EXTENDED_CACHE_TTL_BETA : EXTENDED_CACHE_TTL_BETA;
1318
+ }
1292
1319
  /**
1293
1320
  * Place CC-style prompt-cache breakpoints on the conversation. The system
1294
1321
  * prompt is already cached at build time (2 system breakpoints); this adds a
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import { join } from 'node:path';
22
22
  import { homedir } from 'node:os';
23
23
  import { pathToFileURL } from 'node:url';
24
24
  import { startAutoOAuthFlow, startManualOAuthFlow, detectHeadlessEnvironment, getStatus, refreshTokens, loadCredentials } from './oauth.js';
25
- import { startProxy, sanitizeError } from './proxy.js';
25
+ import { startProxy, sanitizeError, parseModelAliasSpecs } from './proxy.js';
26
26
  import { VALID_EFFORT_VALUES } from './cc-template.js';
27
27
  import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, addAccountViaManualOAuth, addAccountFromKeychain, KeychainImportError, removeAccount, ensureLoginCredentialsInPool, MIGRATED_LOGIN_ALIAS } from './accounts.js';
28
28
  import { listBackends, saveBackend, removeBackend } from './openai-backend.js';
@@ -428,6 +428,21 @@ async function proxy() {
428
428
  ?? parsePositiveIntEnv(process.env['DARIO_MAX_QUEUED']);
429
429
  const queueTimeoutMs = parsePositiveIntFlag('--queue-timeout=')
430
430
  ?? parsePositiveIntEnv(process.env['DARIO_QUEUE_TIMEOUT_MS']);
431
+ // --pool-strategy=headroom|fill-first — where UNBOUND (new) conversations
432
+ // land. `headroom` (default) spreads them to the seat with the most slack;
433
+ // `fill-first` concentrates them on the alphabetically-first eligible seat
434
+ // until it drains to the 2% floor, then spills — primary/backup semantics,
435
+ // alias naming (`1-main`, `2-overflow`) is the ordering knob. Sticky
436
+ // bindings behave identically under both.
437
+ const poolStrategyFromFlag = args.find((a) => a.startsWith('--pool-strategy='))?.split('=')[1];
438
+ if (poolStrategyFromFlag !== undefined
439
+ && poolStrategyFromFlag !== 'headroom' && poolStrategyFromFlag !== 'fill-first') {
440
+ console.error(`[dario] Invalid --pool-strategy "${poolStrategyFromFlag}". Must be headroom or fill-first.`);
441
+ process.exit(1);
442
+ }
443
+ const poolStrategy = poolStrategyFromFlag
444
+ ?? process.env['DARIO_POOL_STRATEGY']
445
+ ?? fileCfg.pool?.strategy;
431
446
  // --effort=low|medium|high|xhigh|ultracode|max|client — pin the outbound
432
447
  // output_config.effort (dario#87). Default (unset) forwards the client's
433
448
  // own effort — it's a user knob, real CC wires whatever the user tuned —
@@ -503,6 +518,30 @@ async function proxy() {
503
518
  // billable-filter. Empty values are dropped. Falls back to
504
519
  // DARIO_PASSTHROUGH_BETAS env var.
505
520
  const passthroughBetas = parsePassthroughBetasFlag(args, process.env['DARIO_PASSTHROUGH_BETAS']);
521
+ // --pool-fallback=<model> / DARIO_POOL_FALLBACK / config poolFallback.model
522
+ // — strictly opt-in. When the Claude pool can't serve, OpenAI-shape
523
+ // requests are re-pointed at the configured openai-compat backend as
524
+ // <model> (response marked x-dario-pool-fallback) instead of surfacing
525
+ // the 429/503. `--pool-fallback=` (empty value) disables, overriding
526
+ // env + config — same clear-the-default shape as --passthrough-betas=.
527
+ const poolFallbackFromFlag = args.find((a) => a.startsWith('--pool-fallback='))?.split('=').slice(1).join('=');
528
+ const poolFallbackModel = (poolFallbackFromFlag
529
+ ?? process.env['DARIO_POOL_FALLBACK']
530
+ ?? fileCfg.poolFallback?.model
531
+ ?? '').trim() || undefined;
532
+ // --model-alias=name=target (repeatable) / DARIO_MODEL_ALIASES=name=target,…
533
+ // / config modelAliases — user-defined model aliases, merged per-key with
534
+ // flags winning over env winning over the config file. Applied at request
535
+ // time before provider-prefix parsing; a target may carry a prefix
536
+ // (`--model-alias=my-fast=openai:gpt-4o-mini`) to retarget the backend.
537
+ const modelAliases = {
538
+ ...(fileCfg.modelAliases ?? {}),
539
+ ...parseModelAliasSpecs((process.env['DARIO_MODEL_ALIASES'] ?? '')
540
+ .split(',').map((s) => s.trim()).filter((s) => s.length > 0)),
541
+ ...parseModelAliasSpecs(args
542
+ .filter((a) => a.startsWith('--model-alias='))
543
+ .map((a) => a.slice('--model-alias='.length))),
544
+ };
506
545
  // --overage-guard / --no-overage-guard / DARIO_OVERAGE_GUARD=off|on (v4.1)
507
546
  // When any upstream response carries `representative-claim: overage`,
508
547
  // halt the proxy: every new request returns 503 with an Anthropic-shaped
@@ -577,7 +616,7 @@ async function proxy() {
577
616
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
578
617
  process.exit(1);
579
618
  }
580
- await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, effort, maxTokens, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat });
619
+ await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, poolStrategy, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat });
581
620
  }
582
621
  /**
583
622
  * Parse `--system-prompt=<verbatim|partial|aggressive|filepath>` (or the
@@ -1355,6 +1394,36 @@ async function help() {
1355
1394
  dario returns 504 "queue-timeout"
1356
1395
  (default: 60000).
1357
1396
  Env: DARIO_QUEUE_TIMEOUT_MS. (dario#80)
1397
+ --pool-strategy=<headroom|fill-first>
1398
+ Where new conversations land in a multi-
1399
+ account pool. headroom (default) spreads
1400
+ them to the seat with the most slack;
1401
+ fill-first concentrates them on the
1402
+ alphabetically-first eligible seat until
1403
+ it drains to the 2% floor, then spills.
1404
+ Sticky bindings are unaffected.
1405
+ Env: DARIO_POOL_STRATEGY.
1406
+ --pool-fallback=<model> When every pool seat is drained or cooling,
1407
+ forward OpenAI-shape requests to the
1408
+ configured openai-compat backend as <model>
1409
+ instead of surfacing the 429/503. Response
1410
+ carries x-dario-pool-fallback. Anthropic-
1411
+ shape requests keep the error (no reverse
1412
+ response translation). Requires an
1413
+ openai-compat backend. Empty value disables.
1414
+ Env: DARIO_POOL_FALLBACK. Config:
1415
+ poolFallback.model.
1416
+ --model-alias=<name=target>
1417
+ User-defined model alias, repeatable.
1418
+ Applied to the client's model name before
1419
+ provider-prefix parsing, so the target may
1420
+ carry a prefix to retarget the backend
1421
+ (--model-alias=my-fast=openai:gpt-4o-mini).
1422
+ Advertised on /v1/models. Names match
1423
+ case-insensitively; one step, never
1424
+ recursive. Env: DARIO_MODEL_ALIASES=
1425
+ name=target,name2=target2. Config:
1426
+ modelAliases.
1358
1427
  --effort=<low|medium|high|xhigh|ultracode|max|client>
1359
1428
  Pin the outbound output_config.effort on
1360
1429
  non-haiku requests, overriding the
@@ -80,8 +80,34 @@ export interface DarioConfig {
80
80
  maxQueued?: number | null;
81
81
  timeoutMs?: number | null;
82
82
  };
83
+ pool?: {
84
+ /**
85
+ * `headroom` (default) spreads new conversations to the seat with the
86
+ * most headroom; `fill-first` concentrates them on the alphabetically-
87
+ * first eligible seat until it drains to the 2% floor, then spills to
88
+ * the next — primary/backup semantics, alias order is the knob.
89
+ */
90
+ strategy?: 'headroom' | 'fill-first';
91
+ };
83
92
  effort?: string | null;
84
93
  maxTokens?: number | 'client' | null;
94
+ /**
95
+ * Pool-exhausted fallback. When `model` is a non-empty string and an
96
+ * openai-compat backend is configured, OpenAI-shape requests that the
97
+ * Claude pool can't serve are forwarded to that backend as `model`
98
+ * (response marked `x-dario-pool-fallback`) instead of surfacing the
99
+ * 429/503. Null/absent = off.
100
+ */
101
+ poolFallback?: {
102
+ model?: string | null;
103
+ };
104
+ /**
105
+ * User-defined model aliases: client-visible name → target model.
106
+ * Resolved at request time before provider-prefix parsing, so a target
107
+ * may carry a prefix (`"my-fast": "openai:gpt-4o-mini"`). Names are
108
+ * matched case-insensitively; one step, never recursive.
109
+ */
110
+ modelAliases?: Record<string, string>;
85
111
  passthroughBetas?: string[];
86
112
  systemPrompt?: string | null;
87
113
  preserveOrchestrationTags?: boolean;
@@ -65,8 +65,11 @@ export function defaultConfig() {
65
65
  perClient: false,
66
66
  },
67
67
  queue: { maxConcurrent: null, maxQueued: null, timeoutMs: null },
68
+ pool: { strategy: 'headroom' },
68
69
  effort: null,
69
70
  maxTokens: null,
71
+ poolFallback: { model: null },
72
+ modelAliases: {},
70
73
  passthroughBetas: [],
71
74
  systemPrompt: null,
72
75
  preserveOrchestrationTags: false,
@@ -303,6 +306,18 @@ function sanitize(parsed) {
303
306
  }
304
307
  }
305
308
  }
309
+ if (isPlainObject(parsed.pool)) {
310
+ out.pool = {};
311
+ if (parsed.pool.strategy === 'headroom' || parsed.pool.strategy === 'fill-first') {
312
+ out.pool.strategy = parsed.pool.strategy;
313
+ }
314
+ }
315
+ if (isPlainObject(parsed.poolFallback)) {
316
+ out.poolFallback = {};
317
+ if (parsed.poolFallback.model === null || typeof parsed.poolFallback.model === 'string') {
318
+ out.poolFallback.model = parsed.poolFallback.model;
319
+ }
320
+ }
306
321
  const effort = pickStringOrNull('effort');
307
322
  if (effort !== undefined)
308
323
  out.effort = effort;
@@ -316,6 +331,19 @@ function sanitize(parsed) {
316
331
  if (n !== undefined)
317
332
  out.maxTokens = n;
318
333
  }
334
+ if (isPlainObject(parsed.modelAliases)) {
335
+ const aliases = {};
336
+ for (const [k, v] of Object.entries(parsed.modelAliases)) {
337
+ if (typeof v !== 'string')
338
+ continue;
339
+ const name = k.trim().toLowerCase();
340
+ const target = v.trim();
341
+ if (!name || !target)
342
+ continue;
343
+ aliases[name] = target;
344
+ }
345
+ out.modelAliases = aliases;
346
+ }
319
347
  if (Array.isArray(parsed.passthroughBetas)) {
320
348
  out.passthroughBetas = parsed.passthroughBetas
321
349
  .filter((x) => typeof x === 'string');
@@ -282,7 +282,7 @@ export declare function _resetInstalledVersionProbeForTest(): void;
282
282
  */
283
283
  export declare const SUPPORTED_CC_RANGE: {
284
284
  readonly min: "1.0.0";
285
- readonly maxTested: "2.1.211";
285
+ readonly maxTested: "2.1.212";
286
286
  };
287
287
  /**
288
288
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -806,7 +806,7 @@ export function _resetInstalledVersionProbeForTest() {
806
806
  */
807
807
  export const SUPPORTED_CC_RANGE = {
808
808
  min: '1.0.0',
809
- maxTested: '2.1.211',
809
+ maxTested: '2.1.212',
810
810
  };
811
811
  /**
812
812
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
package/dist/pool.d.ts CHANGED
@@ -75,6 +75,32 @@ export interface PoolStatus {
75
75
  bestAccount: string;
76
76
  queued: number;
77
77
  }
78
+ /**
79
+ * Pool routing strategy.
80
+ *
81
+ * `headroom` (default) — every selection picks the account with the most
82
+ * headroom, spreading new conversations across all seats.
83
+ *
84
+ * `fill-first` — concentrate new conversations on the lexicographically-
85
+ * first eligible account (by alias) until its headroom drops to the 2%
86
+ * floor, then spill to the next. Two things headroom spreading can't give
87
+ * you: primary/backup semantics (a `z-backup` seat stays untouched until
88
+ * `a-main` is actually drained), and cache concentration (every fresh
89
+ * conversation lands where the prompt-cache pressure already is, keeping
90
+ * the spill seat's windows fully fresh for when they're needed). Alias
91
+ * order is the operator's knob — name seats `1-main` / `2-overflow` to
92
+ * pick the fill order. Sticky bindings behave identically in both modes;
93
+ * strategy only decides where UNBOUND (new) conversations land.
94
+ */
95
+ export type PoolStrategy = 'headroom' | 'fill-first';
96
+ /**
97
+ * Resolve the pool strategy from an explicit value (CLI flag / config file,
98
+ * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
99
+ * the env fallback. Unrecognized values fall through — a typo behaves like
100
+ * the default rather than crashing startup, matching the other resolvers
101
+ * in this codebase (see resolveSessionRotationConfig).
102
+ */
103
+ export declare function resolvePoolStrategy(explicit?: string | null, env?: NodeJS.ProcessEnv): PoolStrategy;
78
104
  /** Parse an Anthropic response's rate-limit headers into a snapshot. */
79
105
  export declare function parseRateLimits(headers: Headers): RateLimitSnapshot;
80
106
  /**
@@ -105,6 +131,7 @@ export declare function modelFamily(modelId: string | null | undefined): string
105
131
  */
106
132
  export declare function computeHeadroom(snapshot: RateLimitSnapshot, family?: string | null): number;
107
133
  export declare class AccountPool {
134
+ private readonly strategy;
108
135
  private accounts;
109
136
  private queue;
110
137
  private queueMaxSize;
@@ -112,6 +139,7 @@ export declare class AccountPool {
112
139
  private drainTimer;
113
140
  private sticky;
114
141
  private lastStickyCleanup;
142
+ constructor(strategy?: PoolStrategy);
115
143
  add(alias: string, opts: {
116
144
  accessToken: string;
117
145
  refreshToken: string;
package/dist/pool.js CHANGED
@@ -54,6 +54,23 @@ export function isInAuthCooldown(account, now = Date.now()) {
54
54
  const cooldown = authCooldownMs(account.consecutiveAuthFailures);
55
55
  return now - account.lastAuthFailureAt < cooldown;
56
56
  }
57
+ /**
58
+ * Resolve the pool strategy from an explicit value (CLI flag / config file,
59
+ * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
60
+ * the env fallback. Unrecognized values fall through — a typo behaves like
61
+ * the default rather than crashing startup, matching the other resolvers
62
+ * in this codebase (see resolveSessionRotationConfig).
63
+ */
64
+ export function resolvePoolStrategy(explicit, env = process.env) {
65
+ for (const c of [explicit, env.DARIO_POOL_STRATEGY]) {
66
+ if (typeof c !== 'string')
67
+ continue;
68
+ const s = c.trim().toLowerCase();
69
+ if (s === 'headroom' || s === 'fill-first')
70
+ return s;
71
+ }
72
+ return 'headroom';
73
+ }
57
74
  /**
58
75
  * Match `anthropic-ratelimit-unified-7d_<family>-utilization`. Generic on
59
76
  * `<family>` so a future `7d_opus` / `7d_haiku` (or anything Anthropic
@@ -166,7 +183,23 @@ function pickMaxHeadroom(accounts, family) {
166
183
  }
167
184
  return best;
168
185
  }
186
+ // Fill-first pick: lexicographically-first eligible account still above the
187
+ // headroom floor. Alias order (not insertion order) — accounts load from a
188
+ // readdir whose order the OS doesn't guarantee, and the operator can control
189
+ // alias names but not readdir. Returns null when every candidate is at/below
190
+ // the floor so the caller can fall back to max-headroom.
191
+ function pickFillFirst(accounts, family) {
192
+ let best = null;
193
+ for (const a of accounts) {
194
+ if (best !== null && a.alias >= best.alias)
195
+ continue;
196
+ if (computeHeadroom(a.rateLimit, family) > POOL_HEADROOM_FLOOR)
197
+ best = a;
198
+ }
199
+ return best;
200
+ }
169
201
  export class AccountPool {
202
+ strategy;
170
203
  accounts = new Map();
171
204
  queue = [];
172
205
  queueMaxSize = 50;
@@ -175,6 +208,9 @@ export class AccountPool {
175
208
  sticky = new Map();
176
209
  // Amortize the O(n) sticky TTL/orphan sweep — timestamp of the last run.
177
210
  lastStickyCleanup = 0;
211
+ constructor(strategy = 'headroom') {
212
+ this.strategy = strategy;
213
+ }
178
214
  add(alias, opts) {
179
215
  const existing = this.accounts.get(alias);
180
216
  this.accounts.set(alias, {
@@ -257,6 +293,14 @@ export class AccountPool {
257
293
  a.expiresAt > now + 30_000 &&
258
294
  !isInAuthCooldown(a, now));
259
295
  if (eligible.length > 0) {
296
+ if (this.strategy === 'fill-first') {
297
+ const first = pickFillFirst(eligible, family);
298
+ if (first)
299
+ return first;
300
+ // Every eligible account is at/below the floor — the terminal state
301
+ // both strategies share. Fall through to max-headroom so the caller
302
+ // still gets the least-drained account instead of null.
303
+ }
260
304
  return pickMaxHeadroom(eligible, family);
261
305
  }
262
306
  // All accounts exhausted — return the one with the earliest reset.
@@ -374,6 +418,15 @@ export class AccountPool {
374
418
  a.expiresAt > now + 30_000 &&
375
419
  !isInAuthCooldown(a, now));
376
420
  if (eligible.length > 0) {
421
+ // Fill-first failover keeps the fill order: the next account tried
422
+ // after a 429 is the next alias in line, not the max-headroom seat —
423
+ // otherwise a single failover would defeat the concentration the
424
+ // strategy exists to provide.
425
+ if (this.strategy === 'fill-first') {
426
+ const first = pickFillFirst(eligible, family);
427
+ if (first)
428
+ return first;
429
+ }
377
430
  return pickMaxHeadroom(eligible, family);
378
431
  }
379
432
  if (candidates.length > 0) {
package/dist/proxy.d.ts CHANGED
@@ -37,6 +37,24 @@ export declare function buildBillingTag(cliVersion: string, cch: string | null):
37
37
  * that needs to Just Work.
38
38
  */
39
39
  export declare function resolveClaudeAlias(model: string): string;
40
+ /**
41
+ * User-defined model aliases: client-visible name → target model.
42
+ *
43
+ * Complements the built-in family shorthands above — those track the model
44
+ * catalog; these are operator-declared (config `modelAliases`, repeatable
45
+ * `--model-alias=name=target`, `DARIO_MODEL_ALIASES=name=target,…`) and
46
+ * resolve FIRST at request time, before provider-prefix parsing, so a
47
+ * target may carry a prefix (`my-fast` → `openai:gpt-4o-mini`) and
48
+ * retarget the backend. One step, never recursive: a target that names
49
+ * another alias is forwarded as-is. Alias names match case-insensitively;
50
+ * targets forward verbatim. An alias may shadow a real model id or a
51
+ * built-in shorthand — deliberate, that's how you downgrade every `opus`
52
+ * call from a client whose picker you don't control.
53
+ */
54
+ export declare function parseModelAliasSpecs(specs: readonly string[]): Record<string, string>;
55
+ /** Resolve `model` through user aliases. Null = no alias applies (also on
56
+ * self-mapping, so a misconfigured `opus=opus` can't loop the caller). */
57
+ export declare function applyModelAlias(model: string | null | undefined, aliases: Record<string, string> | undefined): string | null;
40
58
  /**
41
59
  * Pick the per-request model override under a forced `--model`.
42
60
  *
@@ -52,6 +70,13 @@ export declare function resolveClaudeAlias(model: string): string;
52
70
  * change is inert until the operator opts in.
53
71
  */
54
72
  export declare function selectModelOverride(incomingModel: string, modelOverride: string | null, fastModelOverride: string | null): string | null;
73
+ /**
74
+ * Rebuild an OpenAI-shape request body with the model swapped to the pool-
75
+ * fallback target. Null when the body isn't a JSON object — the caller
76
+ * surfaces the original error instead of forwarding garbage. Exported for
77
+ * tests; the two call sites are the pool-exhausted dispatch paths.
78
+ */
79
+ export declare function buildPoolFallbackBody(body: Buffer, fallbackModel: string): Buffer | null;
55
80
  export declare function parseProviderPrefix(model: string): {
56
81
  provider: 'openai' | 'claude';
57
82
  model: string;
@@ -288,6 +313,16 @@ interface ProxyOptions {
288
313
  * --strict-tls. dario#77.
289
314
  */
290
315
  strictTemplate?: boolean;
316
+ /**
317
+ * Pool routing strategy. `headroom` (default) spreads new conversations
318
+ * to the seat with the most headroom; `fill-first` concentrates them on
319
+ * the alphabetically-first eligible seat until it drains to the 2%
320
+ * floor, then spills to the next — primary/backup semantics where a
321
+ * `z-backup` seat stays untouched until `a-main` is actually drained.
322
+ * Sticky bindings behave identically in both modes. Sourced from
323
+ * `--pool-strategy` / `DARIO_POOL_STRATEGY` / config `pool.strategy`.
324
+ */
325
+ poolStrategy?: string;
291
326
  /** Max concurrent in-flight requests. Default 10. dario#80. */
292
327
  maxConcurrent?: number;
293
328
  /** Max requests buffered waiting for a concurrency slot. Default 128. dario#80. */
@@ -312,6 +347,27 @@ interface ProxyOptions {
312
347
  * their output capacity. dario#88 (Hermes compat).
313
348
  */
314
349
  maxTokens?: number | 'client';
350
+ /**
351
+ * Pool-exhausted fallback model (strictly opt-in; off when unset/empty).
352
+ * When the Claude pool can't serve — selection finds every seat drained
353
+ * or cooling, or a mid-flight 429 has no peer left — OpenAI-shape
354
+ * requests (/v1/chat/completions) are forwarded to the configured
355
+ * openai-compat backend with the model swapped to this value, instead
356
+ * of surfacing the 429/503. Responses carry `x-dario-pool-fallback`.
357
+ * Anthropic-shape requests keep the error: dario has no OpenAI→Anthropic
358
+ * response translation. Inert without a configured backend. Sourced from
359
+ * `--pool-fallback` / `DARIO_POOL_FALLBACK` / config `poolFallback.model`.
360
+ */
361
+ poolFallbackModel?: string;
362
+ /**
363
+ * User-defined model aliases, client-visible name (lowercase) → target.
364
+ * Resolved per request BEFORE provider-prefix parsing (a target may
365
+ * carry a prefix and retarget the backend) and advertised on
366
+ * /v1/models. One step, never recursive. See parseModelAliasSpecs.
367
+ * Sourced from config `modelAliases` < `DARIO_MODEL_ALIASES` <
368
+ * repeatable `--model-alias=name=target`, merged per-key by the CLI.
369
+ */
370
+ modelAliases?: Record<string, string>;
315
371
  /**
316
372
  * Append-only request log file. One JSON line per completed request,
317
373
  * with secrets scrubbed via redactSecrets. Useful for backgrounded
package/dist/proxy.js CHANGED
@@ -9,10 +9,10 @@ import { arch, platform } from 'node:process';
9
9
  import { getAccessToken, getStatus } from './oauth.js';
10
10
  import { buildHealthResponse, derivePoolStatus, shouldDiscloseHealthInternals } from './health-response.js';
11
11
  import { darioVersion } from './version.js';
12
- import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, isMcpToolName, CC_TEMPLATE, effectiveCacheControl } from './cc-template.js';
12
+ import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
13
13
  import { stampCch, hasCchSeed } from './cch.js';
14
14
  import { describeTemplate, detectDrift, checkCCCompat } from './live-fingerprint.js';
15
- import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, reconcilePoolAccounts } from './pool.js';
15
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, reconcilePoolAccounts, resolvePoolStrategy } from './pool.js';
16
16
  import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS } from './analytics.js';
17
17
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
18
18
  import { notify as osNotify } from './notify.js';
@@ -235,6 +235,44 @@ const MODEL_ALIASES = {
235
235
  export function resolveClaudeAlias(model) {
236
236
  return resolveAliasAgainst(model, getCachedBases()) ?? MODEL_ALIASES[model] ?? model;
237
237
  }
238
+ /**
239
+ * User-defined model aliases: client-visible name → target model.
240
+ *
241
+ * Complements the built-in family shorthands above — those track the model
242
+ * catalog; these are operator-declared (config `modelAliases`, repeatable
243
+ * `--model-alias=name=target`, `DARIO_MODEL_ALIASES=name=target,…`) and
244
+ * resolve FIRST at request time, before provider-prefix parsing, so a
245
+ * target may carry a prefix (`my-fast` → `openai:gpt-4o-mini`) and
246
+ * retarget the backend. One step, never recursive: a target that names
247
+ * another alias is forwarded as-is. Alias names match case-insensitively;
248
+ * targets forward verbatim. An alias may shadow a real model id or a
249
+ * built-in shorthand — deliberate, that's how you downgrade every `opus`
250
+ * call from a client whose picker you don't control.
251
+ */
252
+ export function parseModelAliasSpecs(specs) {
253
+ const out = {};
254
+ for (const spec of specs) {
255
+ const idx = spec.indexOf('=');
256
+ if (idx <= 0)
257
+ continue;
258
+ const name = spec.slice(0, idx).trim().toLowerCase();
259
+ const target = spec.slice(idx + 1).trim();
260
+ if (!name || !target)
261
+ continue;
262
+ out[name] = target;
263
+ }
264
+ return out;
265
+ }
266
+ /** Resolve `model` through user aliases. Null = no alias applies (also on
267
+ * self-mapping, so a misconfigured `opus=opus` can't loop the caller). */
268
+ export function applyModelAlias(model, aliases) {
269
+ if (!aliases || !model)
270
+ return null;
271
+ const target = aliases[model.trim().toLowerCase()];
272
+ if (target === undefined || target === model)
273
+ return null;
274
+ return target;
275
+ }
238
276
  /**
239
277
  * Pick the per-request model override under a forced `--model`.
240
278
  *
@@ -268,6 +306,24 @@ const PROVIDER_PREFIXES = {
268
306
  claude: 'claude',
269
307
  anthropic: 'claude',
270
308
  };
309
+ /**
310
+ * Rebuild an OpenAI-shape request body with the model swapped to the pool-
311
+ * fallback target. Null when the body isn't a JSON object — the caller
312
+ * surfaces the original error instead of forwarding garbage. Exported for
313
+ * tests; the two call sites are the pool-exhausted dispatch paths.
314
+ */
315
+ export function buildPoolFallbackBody(body, fallbackModel) {
316
+ try {
317
+ const parsed = JSON.parse(body.toString());
318
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
319
+ return null;
320
+ parsed.model = fallbackModel;
321
+ return Buffer.from(JSON.stringify(parsed));
322
+ }
323
+ catch {
324
+ return null;
325
+ }
326
+ }
271
327
  export function parseProviderPrefix(model) {
272
328
  const idx = model.indexOf(':');
273
329
  if (idx <= 0)
@@ -1035,6 +1091,30 @@ export async function startProxy(opts = {}) {
1035
1091
  if (openaiBackend) {
1036
1092
  console.log(` OpenAI-compat backend: ${openaiBackend.name} → ${openaiBackend.baseUrl}`);
1037
1093
  }
1094
+ // Pool-exhausted fallback (strictly opt-in). When the Claude pool can't
1095
+ // serve — every seat rate-limited or in auth cool-down — OpenAI-shape
1096
+ // requests (/v1/chat/completions) are re-pointed at the configured
1097
+ // openai-compat backend with the model swapped to `poolFallbackModel`,
1098
+ // instead of surfacing the 429/503. Anthropic-shape requests keep the
1099
+ // error: dario has no OpenAI→Anthropic response translation, and
1100
+ // half-translating would corrupt streaming clients. Every substituted
1101
+ // response carries `x-dario-pool-fallback: <model>` — a silently swapped
1102
+ // model is the kind of surprise this project exists to avoid.
1103
+ const poolFallbackModel = (opts.poolFallbackModel ?? '').trim() || null;
1104
+ if (poolFallbackModel && openaiBackend) {
1105
+ console.log(` Pool fallback: exhausted-pool /v1/chat/completions requests → ${openaiBackend.name} as ${poolFallbackModel} (marked x-dario-pool-fallback)`);
1106
+ }
1107
+ else if (poolFallbackModel && !openaiBackend) {
1108
+ console.warn('[dario] --pool-fallback is set but no OpenAI-compat backend is configured (`dario backend add …`) — fallback is inert.');
1109
+ }
1110
+ // User-defined model aliases (see parseModelAliasSpecs). Resolved by the
1111
+ // CLI (config < env < flags, per-key) and applied per request before
1112
+ // provider-prefix parsing; also advertised on /v1/models so client model
1113
+ // pickers can offer them.
1114
+ const modelAliases = opts.modelAliases ?? {};
1115
+ if (Object.keys(modelAliases).length > 0) {
1116
+ console.log(` Model aliases: ${Object.entries(modelAliases).map(([k, v]) => `${k} → ${v}`).join(', ')}`);
1117
+ }
1038
1118
  // Pool-as-primitive (v5.0). The account pool is the one credential model:
1039
1119
  // a plain `dario login` is a pool of one under the reserved `login` alias,
1040
1120
  // and a pool of many is the same path with more members. There is no
@@ -1063,7 +1143,11 @@ export async function startProxy(opts = {}) {
1063
1143
  // POST /admin/login/*, taking effect with no restart (see onAccountsChanged).
1064
1144
  const adminEnabled = process.env.DARIO_ADMIN === '1';
1065
1145
  const accountsList = await loadAllAccounts();
1066
- const pool = new AccountPool();
1146
+ const poolStrategy = resolvePoolStrategy(opts.poolStrategy);
1147
+ const pool = new AccountPool(poolStrategy);
1148
+ if (poolStrategy !== 'headroom') {
1149
+ console.log(` Pool strategy: ${poolStrategy} (new conversations fill the alphabetically-first seat, spill at the 2% floor)`);
1150
+ }
1067
1151
  // Per-model rate-limit bucket families seen during this proxy run. First-
1068
1152
  // sight is logged once when verbose so a new Anthropic bucket (e.g. an
1069
1153
  // eventual `7d_opus`) doesn't slip past unnoticed. Pure observability —
@@ -1803,7 +1887,12 @@ export async function startProxy(opts = {}) {
1803
1887
  // throws). [1m] variants come from the shared long-context rule, so
1804
1888
  // every family advertises its 1M form the same way.
1805
1889
  const catalog = await getModelCatalog(catalogDeps);
1806
- const body = JSON.stringify(buildOpenAIModelsList(withLongContextVariants(catalog.bases)));
1890
+ // User aliases are advertised after the real ids so pickers offer
1891
+ // them; already-advertised names aren't duplicated (an alias that
1892
+ // shadows a real id still applies at request time).
1893
+ const advertised = withLongContextVariants(catalog.bases);
1894
+ const aliasNames = Object.keys(modelAliases).filter((n) => !advertised.includes(n));
1895
+ const body = JSON.stringify(buildOpenAIModelsList(advertised.concat(aliasNames)));
1807
1896
  res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
1808
1897
  res.end(body);
1809
1898
  return;
@@ -1914,26 +2003,36 @@ export async function startProxy(opts = {}) {
1914
2003
  // pool of one, so every OAuth request selects from the pool.
1915
2004
  poolAccount = pool.select();
1916
2005
  if (!poolAccount) {
1917
- // Two distinct empty-selection cases (#599): the pool has no accounts
1918
- // at all (headless admin bootstrap nothing added yet), vs. it has
1919
- // accounts but all are rate-limited / in auth cool-down. Give each a
1920
- // truthful, actionable message so a headless operator isn't told
1921
- // "rate-limited" when they simply haven't added an account.
1922
- res.writeHead(503, JSON_HEADERS);
1923
- res.end(JSON.stringify(pool.size === 0
1924
- ? {
1925
- error: 'No account configured',
1926
- message: adminEnabled
1927
- ? 'dario is running in admin mode with no account yet. Add one via POST /admin/login/start, then retry.'
1928
- : 'No accounts available. Run `dario login`, or add accounts with `dario accounts add`.',
1929
- }
1930
- : {
1931
- error: 'No accounts available in pool',
1932
- message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
1933
- }));
1934
- return;
2006
+ // Pool-exhausted fallback: when armed, the pool HAS accounts (all
2007
+ // drained / cooling), and the client speaks OpenAI shape, defer
2008
+ // the fallback dispatch below the body read re-points the request
2009
+ // at the openai-compat backend. An EMPTY pool still 503s: that's
2010
+ // a setup error the operator needs to see, not traffic to quietly
2011
+ // re-bill somewhere else.
2012
+ const fallbackViable = poolFallbackModel !== null && openaiBackend !== null
2013
+ && isOpenAI && pool.size > 0;
2014
+ if (!fallbackViable) {
2015
+ // Two distinct empty-selection cases (#599): the pool has no accounts
2016
+ // at all (headless admin bootstrap nothing added yet), vs. it has
2017
+ // accounts but all are rate-limited / in auth cool-down. Give each a
2018
+ // truthful, actionable message so a headless operator isn't told
2019
+ // "rate-limited" when they simply haven't added an account.
2020
+ res.writeHead(503, JSON_HEADERS);
2021
+ res.end(JSON.stringify(pool.size === 0
2022
+ ? {
2023
+ error: 'No account configured',
2024
+ message: adminEnabled
2025
+ ? 'dario is running in admin mode with no account yet. Add one via POST /admin/login/start, then retry.'
2026
+ : 'No accounts available. Run `dario login`, or add accounts with `dario accounts add`.',
2027
+ }
2028
+ : {
2029
+ error: 'No accounts available in pool',
2030
+ message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
2031
+ }));
2032
+ return;
2033
+ }
1935
2034
  }
1936
- accessToken = poolAccount.accessToken;
2035
+ accessToken = poolAccount?.accessToken ?? '';
1937
2036
  }
1938
2037
  // Client-side session key (constant per request) for the rotation registry
1939
2038
  // — consulted at body-build, at the outbound header, and on each mid-request
@@ -1987,6 +2086,19 @@ export async function startProxy(opts = {}) {
1987
2086
  try {
1988
2087
  const parsed = JSON.parse(body.toString());
1989
2088
  parsedBody = parsed;
2089
+ // User-defined aliases first — before provider-prefix parsing, so
2090
+ // an alias target carrying a prefix (`my-fast` → `openai:gpt-4o`)
2091
+ // retargets the backend through the existing machinery below.
2092
+ {
2093
+ const clientModel = parsed.model ?? '';
2094
+ const aliasTarget = applyModelAlias(clientModel, modelAliases);
2095
+ if (aliasTarget !== null) {
2096
+ parsed.model = aliasTarget;
2097
+ body = Buffer.from(JSON.stringify(parsed));
2098
+ if (verbose)
2099
+ console.log(`[dario] model alias: ${clientModel} → ${aliasTarget}`);
2100
+ }
2101
+ }
1990
2102
  const rawModel = parsed.model ?? '';
1991
2103
  const prefix = parseProviderPrefix(rawModel);
1992
2104
  if (prefix) {
@@ -2057,6 +2169,28 @@ export async function startProxy(opts = {}) {
2057
2169
  }
2058
2170
  catch { /* not JSON — fall through to existing path */ }
2059
2171
  }
2172
+ // Pool-exhausted fallback dispatch. In OAuth mode poolAccount can only
2173
+ // be null here when the selection above deferred to this path (armed
2174
+ // fallback + drained pool + OpenAI-shape request): swap the model and
2175
+ // forward the client's own body to the openai-compat backend. The
2176
+ // response carries `x-dario-pool-fallback` — a substituted model must
2177
+ // never be silent. GPT-bound requests never reach here (the routing
2178
+ // block above already forwarded them; they don't need the pool).
2179
+ if (!upstreamApiKey && !poolAccount && poolFallbackModel && openaiBackend) {
2180
+ const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
2181
+ if (!fallbackBody) {
2182
+ res.writeHead(503, JSON_HEADERS);
2183
+ res.end(JSON.stringify({
2184
+ error: 'No accounts available in pool',
2185
+ message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
2186
+ }));
2187
+ return;
2188
+ }
2189
+ console.log(`[dario] #${requestCount} pool exhausted — /v1/chat/completions → ${openaiBackend.name} as ${poolFallbackModel}`);
2190
+ requestCount++;
2191
+ await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, UPSTREAM_TIMEOUT_MS, verbose);
2192
+ return;
2193
+ }
2060
2194
  // Parse body once, apply OpenAI translation, model override, and sanitization
2061
2195
  let finalBody = body.length > 0 ? body : undefined;
2062
2196
  let ccToolMap = null;
@@ -2399,6 +2533,12 @@ export async function startProxy(opts = {}) {
2399
2533
  if (toAdd.length > 0)
2400
2534
  beta += ',' + toAdd.join(',');
2401
2535
  }
2536
+ // Forced 1h cache (DARIO_CACHE_TTL_1H): effectiveCacheControl stamps
2537
+ // ttl:'1h', but the 1h is only honored WITH the extended-cache-ttl
2538
+ // beta — add it here (no-op unless the flag is set). If the upstream
2539
+ // 400s the flag on a non-sub account, the rejected-set strip below
2540
+ // drops it on the retry.
2541
+ beta = withForced1hBeta(beta);
2402
2542
  // Strip any beta flags the upstream has previously rejected on this
2403
2543
  // account so we don't re-pay the 400 round-trip (dario#42 afk-mode
2404
2544
  // fallout: captured templates carry tier-gated flags whose availability
@@ -2921,6 +3061,22 @@ export async function startProxy(opts = {}) {
2921
3061
  continue dispatchLoop;
2922
3062
  }
2923
3063
  }
3064
+ // Pool-exhausted fallback: no peer left to fail over to. For an
3065
+ // OpenAI-shape request with the fallback armed, re-point the
3066
+ // client's own body at the openai-compat backend instead of
3067
+ // surfacing the 429. `body` still holds the client's OpenAI-shape
3068
+ // bytes — the Anthropic translation went into finalBody, never
3069
+ // back into body. Marked via x-dario-pool-fallback, same as the
3070
+ // selection-time path.
3071
+ if (isOpenAI && poolFallbackModel && openaiBackend) {
3072
+ const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
3073
+ if (fallbackBody) {
3074
+ console.log(`[dario] #${requestCount} pool exhausted mid-flight (429, no peer) — /v1/chat/completions → ${openaiBackend.name} as ${poolFallbackModel}`);
3075
+ requestCount++;
3076
+ await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, UPSTREAM_TIMEOUT_MS, verbose);
3077
+ return;
3078
+ }
3079
+ }
2924
3080
  const errBody = await upstream.text().catch(() => '');
2925
3081
  const enriched = enrich429(errBody, upstream.headers);
2926
3082
  const responseHeaders = {
@@ -38,6 +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
42
  // ── Overage-guard (v4.1, dario#288) ─────────────────────────
42
43
  { path: 'overageGuard.enabled', label: 'Overage-guard', type: 'bool', hint: 'halt proxy on any representative-claim=overage' },
43
44
  { path: 'overageGuard.behavior', label: 'Overage behavior', type: 'string', hint: '"halt" (default) or "warn"' },
@@ -255,6 +256,7 @@ function commitEdit(state) {
255
256
  */
256
257
  const STRING_ENUMS = {
257
258
  'overageGuard.behavior': ['halt', 'warn'],
259
+ 'pool.strategy': ['headroom', 'fill-first'],
258
260
  };
259
261
  function doSave(state) {
260
262
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.2.4",
3
+ "version": "5.2.6",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {