@askalf/dario 6.13.31 → 6.14.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/dist/cli.js CHANGED
@@ -649,6 +649,16 @@ async function proxy() {
649
649
  ?? process.env['DARIO_POOL_FALLBACK']
650
650
  ?? fileCfg.poolFallback?.model
651
651
  ?? '').trim() || undefined;
652
+ // --family-fallback=fable=claude-opus-5-5 / DARIO_FAMILY_FALLBACK / config
653
+ // familyFallback: strictly opt-in. A request for a family every seat is
654
+ // parked on is served as the named model of another family the pool can
655
+ // serve (response marked x-dario-family-fallback). The proxy parses and
656
+ // validates it. `--family-fallback=` (empty value) disables, overriding env
657
+ // and config, as --pool-fallback= does.
658
+ const familyFallbackFromFlag = args.find((a) => a.startsWith('--family-fallback='))?.split('=').slice(1).join('=');
659
+ const familyFallback = familyFallbackFromFlag
660
+ ?? process.env['DARIO_FAMILY_FALLBACK']
661
+ ?? fileCfg.familyFallback;
652
662
  // --model-alias=name=target (repeatable) / DARIO_MODEL_ALIASES=name=target,…
653
663
  // / config modelAliases — user-defined model aliases, merged per-key with
654
664
  // flags winning over env winning over the config file. Applied at request
@@ -776,7 +786,7 @@ async function proxy() {
776
786
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
777
787
  process.exit(1);
778
788
  }
779
- await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, 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, maxConcurrentPerConsumer, shutdownGraceMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath, keySockets: keySockets.length > 0 ? keySockets : undefined });
789
+ await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, 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, maxConcurrentPerConsumer, shutdownGraceMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, familyFallback, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath, keySockets: keySockets.length > 0 ? keySockets : undefined });
780
790
  }
781
791
  /**
782
792
  * `dario keys` — named keys for a shared dario (v6.8, dario#1318). One
@@ -2260,6 +2270,16 @@ async function help() {
2260
2270
  Needs one of those configured. Empty value
2261
2271
  disables. Env: DARIO_POOL_FALLBACK. Config:
2262
2272
  poolFallback.model.
2273
+ --family-fallback=<family=model,...>
2274
+ When every pool seat is parked for a model
2275
+ family, serve its requests as <model> on the
2276
+ Claude pool instead, while some seat can
2277
+ serve that model's family. For Fable, which
2278
+ runs out of included credit while Opus still
2279
+ has room: fable=claude-opus-5-5. Response
2280
+ carries x-dario-family-fallback. Empty value
2281
+ disables. Env: DARIO_FAMILY_FALLBACK.
2282
+ Config: familyFallback.
2263
2283
  --model-alias=<name=target>
2264
2284
  User-defined model alias, repeatable.
2265
2285
  Applied to the client's model name before
@@ -106,6 +106,13 @@ export interface DarioConfig {
106
106
  poolFallback?: {
107
107
  model?: string | null;
108
108
  };
109
+ /**
110
+ * Family fallback: source family to target Claude model (`{ "fable":
111
+ * "claude-opus-5-5" }`). When every pool seat is parked for a request's
112
+ * family, the request is served as the target on the Claude pool. The
113
+ * response is marked `x-dario-family-fallback`. Empty = off.
114
+ */
115
+ familyFallback?: Record<string, string>;
109
116
  /**
110
117
  * User-defined model aliases: client-visible name → target model.
111
118
  * Resolved at request time before provider-prefix parsing, so a target
@@ -69,6 +69,7 @@ export function defaultConfig() {
69
69
  effort: null,
70
70
  maxTokens: null,
71
71
  poolFallback: { model: null },
72
+ familyFallback: {},
72
73
  modelAliases: {},
73
74
  passthroughBetas: [],
74
75
  systemPrompt: null,
@@ -335,6 +336,14 @@ function sanitize(parsed) {
335
336
  if (n !== undefined)
336
337
  out.maxTokens = n;
337
338
  }
339
+ if (isPlainObject(parsed.familyFallback)) {
340
+ const pairs = {};
341
+ for (const [k, v] of Object.entries(parsed.familyFallback)) {
342
+ if (typeof v === 'string')
343
+ pairs[k] = v;
344
+ }
345
+ out.familyFallback = pairs;
346
+ }
338
347
  if (isPlainObject(parsed.modelAliases)) {
339
348
  const aliases = {};
340
349
  for (const [k, v] of Object.entries(parsed.modelAliases)) {
@@ -1261,6 +1261,22 @@ export async function runChecks(opts = {}) {
1261
1261
  catch (err) {
1262
1262
  checks.push({ status: 'warn', label: 'Failover', detail: `check failed: ${err.message}` });
1263
1263
  }
1264
+ // ---- Family fallback: what the proxy will parse, from the same sources.
1265
+ try {
1266
+ const { loadConfig } = await import('./config-file.js');
1267
+ const { parseFamilyFallback, describeFamilyFallback } = await import('./family-fallback.js');
1268
+ const spec = process.env.DARIO_FAMILY_FALLBACK ?? loadConfig().config.familyFallback ?? null;
1269
+ const { map, errors } = parseFamilyFallback(spec);
1270
+ if (errors.length > 0) {
1271
+ checks.push({ status: 'warn', label: 'Family fallback', detail: `dropped ${errors.join('; ')}${map.size > 0 ? `; kept ${describeFamilyFallback(map)}` : ''}` });
1272
+ }
1273
+ else {
1274
+ checks.push({ status: map.size > 0 ? 'ok' : 'info', label: 'Family fallback', detail: map.size > 0 ? `${describeFamilyFallback(map)} when every seat is parked for the first` : 'off (set DARIO_FAMILY_FALLBACK=fable=claude-opus-5-5 or --family-fallback)' });
1275
+ }
1276
+ }
1277
+ catch (err) {
1278
+ checks.push({ status: 'warn', label: 'Family fallback', detail: `check failed: ${err.message}` });
1279
+ }
1264
1280
  // ---- Ledger (v6.6.4) — see ledgerReadiness() for the why. The default
1265
1281
  // port's file; DARIO_LEDGER_PATH is honoured like the proxy honours it.
1266
1282
  try {
@@ -0,0 +1,40 @@
1
+ /** Source family → target Claude model. */
2
+ export type FamilyFallbackMap = ReadonlyMap<string, string>;
3
+ export interface ParsedFamilyFallback {
4
+ map: Map<string, string>;
5
+ /** One line per entry that was dropped, saying why. */
6
+ errors: string[];
7
+ }
8
+ /**
9
+ * Parse `fable=claude-opus-5-5,sonnet=claude-haiku-5` (or the same pairs from
10
+ * a config object). A source must be a family `modelFamily` knows; a target
11
+ * must name a Claude model of a DIFFERENT known family, so a fallback can never
12
+ * point back at the family it is leaving. Bad entries are dropped and reported,
13
+ * the rest kept. Later entries for the same family win.
14
+ */
15
+ export declare function parseFamilyFallback(spec: string | Record<string, unknown> | null | undefined): ParsedFamilyFallback;
16
+ /**
17
+ * The model to serve `model` as, or null to leave the request alone.
18
+ *
19
+ * `parked(family)` is whether a rate limit keeps every pool seat off that
20
+ * family (`AccountPool.familyRateLimited`), and `servable(family)` whether some seat
21
+ * is eligible for it right now. The switch happens only when the request's own
22
+ * family is parked and the target's is servable: a pool that is merely busy,
23
+ * expiring a token or empty keeps its existing handling, and a target that is
24
+ * parked too gains nothing. One step, never chained.
25
+ *
26
+ * A `[1m]` long-context tag on the request is carried onto the target when the
27
+ * target has none, so the switch does not shrink the context window asked for.
28
+ *
29
+ * A model name that is not printable ASCII (at most 256 characters, no spaces
30
+ * or control characters) is left alone: no real model is named so, and the
31
+ * name the client sent is echoed in the `x-dario-family-fallback` header and
32
+ * the log, where a line break would fail the header (setHeader throws, the
33
+ * request errors) or forge a log line.
34
+ */
35
+ export declare function familyFallbackModel(map: FamilyFallbackMap, model: string, pool: {
36
+ parked: (family: string) => boolean;
37
+ servable: (family: string) => boolean;
38
+ }): string | null;
39
+ /** `fable → claude-opus-5-5, sonnet → claude-haiku-5`, for the startup banner and doctor. */
40
+ export declare function describeFamilyFallback(map: FamilyFallbackMap): string;
@@ -0,0 +1,93 @@
1
+ // Family fallback: a request for a model family every pool seat is parked on
2
+ // is served as another Claude model the pool can still serve, instead of
3
+ // answering 429 or leaving the Claude pool. The case it is for is Fable, which
4
+ // is metered on a seat's included credit rather than its 5h and 7d windows:
5
+ // once that credit is spent the seat still has room for Opus, but every Fable
6
+ // request is parked until the credit resets.
7
+ //
8
+ // Strictly opt-in. Sourced from `--family-fallback=fable=claude-opus-5-5` /
9
+ // `DARIO_FAMILY_FALLBACK` / config `familyFallback`. Pure: the proxy passes in
10
+ // what the pool says, so the decision is testable without one.
11
+ import { KNOWN_FAMILIES, modelFamily } from './pool.js';
12
+ const ONE_M = /\[1m\]$/i;
13
+ /**
14
+ * Parse `fable=claude-opus-5-5,sonnet=claude-haiku-5` (or the same pairs from
15
+ * a config object). A source must be a family `modelFamily` knows; a target
16
+ * must name a Claude model of a DIFFERENT known family, so a fallback can never
17
+ * point back at the family it is leaving. Bad entries are dropped and reported,
18
+ * the rest kept. Later entries for the same family win.
19
+ */
20
+ export function parseFamilyFallback(spec) {
21
+ const map = new Map();
22
+ const errors = [];
23
+ if (spec === null || spec === undefined)
24
+ return { map, errors };
25
+ const pairs = typeof spec === 'string'
26
+ ? spec.split(',').map((s) => s.trim()).filter(Boolean).map((entry) => {
27
+ const eq = entry.indexOf('=');
28
+ return eq < 0 ? [entry, undefined] : [entry.slice(0, eq), entry.slice(eq + 1)];
29
+ })
30
+ : Object.entries(spec);
31
+ for (const [rawFrom, rawTo] of pairs) {
32
+ const from = rawFrom.trim().toLowerCase();
33
+ const to = typeof rawTo === 'string' ? rawTo.trim() : '';
34
+ if (!KNOWN_FAMILIES.includes(from)) {
35
+ errors.push(`"${rawFrom}": not a model family (one of ${KNOWN_FAMILIES.join(', ')})`);
36
+ continue;
37
+ }
38
+ if (!to) {
39
+ errors.push(`"${rawFrom}": no target model (write ${from}=<model>)`);
40
+ continue;
41
+ }
42
+ const toFamily = modelFamily(to);
43
+ if (!toFamily || !/^claude-/i.test(to)) {
44
+ errors.push(`"${rawFrom}=${to}": the target must be a claude-* model of a known family`);
45
+ continue;
46
+ }
47
+ if (toFamily === from) {
48
+ errors.push(`"${rawFrom}=${to}": the target is the same family`);
49
+ continue;
50
+ }
51
+ map.set(from, to);
52
+ }
53
+ return { map, errors };
54
+ }
55
+ /**
56
+ * The model to serve `model` as, or null to leave the request alone.
57
+ *
58
+ * `parked(family)` is whether a rate limit keeps every pool seat off that
59
+ * family (`AccountPool.familyRateLimited`), and `servable(family)` whether some seat
60
+ * is eligible for it right now. The switch happens only when the request's own
61
+ * family is parked and the target's is servable: a pool that is merely busy,
62
+ * expiring a token or empty keeps its existing handling, and a target that is
63
+ * parked too gains nothing. One step, never chained.
64
+ *
65
+ * A `[1m]` long-context tag on the request is carried onto the target when the
66
+ * target has none, so the switch does not shrink the context window asked for.
67
+ *
68
+ * A model name that is not printable ASCII (at most 256 characters, no spaces
69
+ * or control characters) is left alone: no real model is named so, and the
70
+ * name the client sent is echoed in the `x-dario-family-fallback` header and
71
+ * the log, where a line break would fail the header (setHeader throws, the
72
+ * request errors) or forge a log line.
73
+ */
74
+ export function familyFallbackModel(map, model, pool) {
75
+ if (map.size === 0)
76
+ return null;
77
+ if (!/^[\x21-\x7e]{1,256}$/.test(model))
78
+ return null;
79
+ const family = modelFamily(model);
80
+ if (!family)
81
+ return null;
82
+ const target = map.get(family);
83
+ if (!target)
84
+ return null;
85
+ const targetFamily = modelFamily(target);
86
+ if (!targetFamily || !pool.parked(family) || !pool.servable(targetFamily))
87
+ return null;
88
+ return ONE_M.test(model.trim()) && !ONE_M.test(target) ? `${target}[1m]` : target;
89
+ }
90
+ /** `fable → claude-opus-5-5, sonnet → claude-haiku-5`, for the startup banner and doctor. */
91
+ export function describeFamilyFallback(map) {
92
+ return [...map].map(([from, to]) => `${from} → ${to}`).join(', ');
93
+ }
package/dist/pool.d.ts CHANGED
@@ -637,6 +637,23 @@ export declare class AccountPool {
637
637
  }): void;
638
638
  remove(alias: string): boolean;
639
639
  get size(): number;
640
+ /**
641
+ * Whether some seat is eligible for `family` right now: the filter
642
+ * `select()` starts from, asked without picking a seat. The family fallback
643
+ * reads it to switch only to a family the pool can serve.
644
+ */
645
+ canServe(family: string | null, now?: number): boolean;
646
+ /**
647
+ * Whether a rate limit keeps every seat off `family` right now: its own
648
+ * window, or a per-model bucket parked by an earlier 429. Unlike
649
+ * `parkedUntil()`, which counts a seat only while its LAST reading is the
650
+ * rejection, this stays true after the seat has served another family
651
+ * since: an Opus 200 after a Fable credit 429 replaces the reading, but the
652
+ * carried-forward bucket still keeps Fable off the seat. False for an empty
653
+ * pool, and for a seat that is off for another reason (an expiring token,
654
+ * an auth cool-down).
655
+ */
656
+ familyRateLimited(family: string | null, now?: number): boolean;
640
657
  /**
641
658
  * Record an auth failure (401/403/auth_error/permission_error/invalid_grant)
642
659
  * against `alias`. Increments the consecutive-failure counter and stamps
package/dist/pool.js CHANGED
@@ -341,6 +341,14 @@ export function withParkedBuckets(prev, next, now = Date.now()) {
341
341
  */
342
342
  export function isParkedInLiveWindow(account, now = Date.now(), family) {
343
343
  const rl = account.rateLimit;
344
+ // A bucket the seat was parked on, carried past a reading that did not
345
+ // measure it (an Opus 200, or an Opus 429 that named no exhausted window,
346
+ // after a Fable credit 429; see withParkedBuckets): whatever the seat's
347
+ // status says now, the bucket's reset is still ahead and a probe for a
348
+ // family it binds can only 429 again. The bucket-scoped rejection itself is
349
+ // decided below, where a 429 with no stated reset stays probeable.
350
+ if (!isBucketScopedRejection(rl) && familyParkedOnBuckets(rl, family, now))
351
+ return true;
344
352
  // A non-exhausted rejection is never "parked in a live window": its reset
345
353
  // was not this seat's window, and once its cool-down passes asking is the
346
354
  // way back (the all-exhausted branch may probe it even sooner).
@@ -1013,6 +1021,28 @@ export class AccountPool {
1013
1021
  get size() {
1014
1022
  return this.accounts.size;
1015
1023
  }
1024
+ /**
1025
+ * Whether some seat is eligible for `family` right now: the filter
1026
+ * `select()` starts from, asked without picking a seat. The family fallback
1027
+ * reads it to switch only to a family the pool can serve.
1028
+ */
1029
+ canServe(family, now = Date.now()) {
1030
+ return [...this.accounts.values()].some((a) => isAccountEligible(a, now, family));
1031
+ }
1032
+ /**
1033
+ * Whether a rate limit keeps every seat off `family` right now: its own
1034
+ * window, or a per-model bucket parked by an earlier 429. Unlike
1035
+ * `parkedUntil()`, which counts a seat only while its LAST reading is the
1036
+ * rejection, this stays true after the seat has served another family
1037
+ * since: an Opus 200 after a Fable credit 429 replaces the reading, but the
1038
+ * carried-forward bucket still keeps Fable off the seat. False for an empty
1039
+ * pool, and for a seat that is off for another reason (an expiring token,
1040
+ * an auth cool-down).
1041
+ */
1042
+ familyRateLimited(family, now = Date.now()) {
1043
+ const all = [...this.accounts.values()];
1044
+ return all.length > 0 && all.every((a) => accountIneligibility(a, now, family) === 'rate-limited');
1045
+ }
1016
1046
  /**
1017
1047
  * Record an auth failure (401/403/auth_error/permission_error/invalid_grant)
1018
1048
  * against `alias`. Increments the consecutive-failure counter and stamps
@@ -1124,7 +1154,7 @@ export class AccountPool {
1124
1154
  // reported, or cooled, as if they were.
1125
1155
  const until = all.map((a) => {
1126
1156
  if (isParkedInLiveWindow(a, now, family)) {
1127
- return isBucketScopedRejection(a.rateLimit)
1157
+ return familyParkedOnBuckets(a.rateLimit, family, now)
1128
1158
  ? (a.rateLimit.parkedBucketsUntil ?? a.rateLimit.reset * 1000)
1129
1159
  : a.rateLimit.reset * 1000;
1130
1160
  }
@@ -1326,7 +1356,9 @@ export class AccountPool {
1326
1356
  : exhausted
1327
1357
  ? { ...merged, status: 'rejected', exhausted: true, parkedBuckets: undefined, parkedBucketsUntil: undefined }
1328
1358
  : {
1329
- ...merged,
1359
+ // Not a window, so not this seat's buckets either: a bucket parked
1360
+ // before it stays parked, as across any reading that did not measure it.
1361
+ ...withParkedBuckets(account.rateLimit, merged, now),
1330
1362
  status: 'rejected',
1331
1363
  exhausted: false,
1332
1364
  cooldownUntil: now + (snapshot.retryAfterMs ?? NON_WINDOW_REJECTION_COOLDOWN_MS),
package/dist/proxy.d.ts CHANGED
@@ -536,6 +536,17 @@ interface ProxyOptions {
536
536
  * `poolFallback.model`.
537
537
  */
538
538
  poolFallbackModel?: string;
539
+ /**
540
+ * Family fallback (opt-in): source family → target Claude model, as
541
+ * `fable=claude-opus-5-5,...` or an object. When every pool seat is parked
542
+ * for a request's family and some seat can serve the target's, the request
543
+ * is served as the target on the Claude pool, before the pool fallback is
544
+ * considered. Responses carry `x-dario-family-fallback: <requested model>`.
545
+ * Skipped in upstream API-key mode, under --model / --fast-model and for a
546
+ * seat-pinned request. Sourced from `--family-fallback` /
547
+ * `DARIO_FAMILY_FALLBACK` / config `familyFallback`. See family-fallback.ts.
548
+ */
549
+ familyFallback?: string | Record<string, string>;
539
550
  /**
540
551
  * User-defined model aliases, client-visible name (lowercase) → target.
541
552
  * Resolved per request BEFORE provider-prefix parsing (a target may
package/dist/proxy.js CHANGED
@@ -55,6 +55,7 @@ import { codexUsageView, resolveCodexUsagePollMs } from './codex-usage.js';
55
55
  import { seedStaleCodexUsage } from './codex-backend.js';
56
56
  import { route as routeProvider } from './provider-adapter.js';
57
57
  import { selectPoolFallbackModels } from './pool-fallback-tier.js';
58
+ import { parseFamilyFallback, familyFallbackModel, describeFamilyFallback } from './family-fallback.js';
58
59
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS, resolveMaxConcurrent } from './request-queue.js';
59
60
  import { waitForIdle, drainThenClose, shutdownTimers, DEFAULT_SHUTDOWN_GRACE_MS } from './shutdown-drain.js';
60
61
  import { clampTimerMs } from './timer-ms.js';
@@ -1555,6 +1556,15 @@ export async function startProxy(opts = {}) {
1555
1556
  console.warn('[dario] --pool-fallback is set but there is nothing to fall back TO — add a Codex account (`dario codex add …`) or an OpenAI-compat backend (`dario backend add …`). Fallback is inert.');
1556
1557
  }
1557
1558
  }
1559
+ // Family fallback (strictly opt-in, family-fallback.ts). Bad entries are
1560
+ // dropped with a warning; the rest still apply.
1561
+ const familyFallbackParsed = parseFamilyFallback(opts.familyFallback ?? null);
1562
+ for (const e of familyFallbackParsed.errors)
1563
+ console.warn(`[dario] family fallback: dropped ${e}`);
1564
+ const familyFallback = familyFallbackParsed.map;
1565
+ if (familyFallback.size > 0) {
1566
+ console.log(` Family fallback: ${describeFamilyFallback(familyFallback)}, when every seat is parked for the first and some seat can serve the second (marked x-dario-family-fallback)`);
1567
+ }
1558
1568
  // User-defined model aliases (see parseModelAliasSpecs). Resolved by the
1559
1569
  // CLI (config < env < flags, per-key) and applied per request before
1560
1570
  // provider-prefix parsing; also advertised on /v1/models so client model
@@ -4412,6 +4422,33 @@ export async function startProxy(opts = {}) {
4412
4422
  // the fix for dario#1137 — a ChatGPT-subscription-only user has a
4413
4423
  // legitimately empty Claude pool, and the empty-pool 503 used to answer
4414
4424
  // codex-bound requests before the adapter was ever consulted.
4425
+ //
4426
+ // Family fallback first (see family-fallback.ts): a request for a family
4427
+ // every seat is parked on, Fable once its included credit is spent, is
4428
+ // re-pointed at the configured model of a family the pool can serve, so
4429
+ // selection below picks a seat for THAT family. The same in-place swap
4430
+ // as the codex-unavailable path above: `body` rebuilt, `parsedBody`
4431
+ // dropped so the Claude path cannot send the old model.
4432
+ if (familyFallback.size > 0 && !upstreamApiKey && !pinnedAccount && !modelOverride && !fastModelOverride && pool.size > 0) {
4433
+ let fromModel = '';
4434
+ try {
4435
+ fromModel = String((parsedBody ?? JSON.parse(body.toString())).model ?? '');
4436
+ }
4437
+ catch { /* not JSON: nothing to re-point */ }
4438
+ const now = Date.now();
4439
+ const toModel = fromModel ? familyFallbackModel(familyFallback, fromModel, {
4440
+ parked: (f) => pool.familyRateLimited(f, now),
4441
+ servable: (f) => pool.canServe(f, now),
4442
+ }) : null;
4443
+ const swapped = toModel ? buildPoolFallbackBody(body, toModel) : null;
4444
+ if (toModel && swapped) {
4445
+ body = Buffer.from(swapped);
4446
+ parsedBody = null;
4447
+ requestFamily = modelFamily(toModel);
4448
+ res.setHeader('x-dario-family-fallback', fromModel);
4449
+ console.log(`[dario] #${requestCount} every seat is parked for ${fromModel} -> served as ${toModel} (family fallback)`);
4450
+ }
4451
+ }
4415
4452
  if (!selectPoolAccount())
4416
4453
  return;
4417
4454
  // Pool-exhausted fallback dispatch. In OAuth mode poolAccount can only
package/docs/commands.md CHANGED
@@ -43,6 +43,7 @@ This page is the per-flag reference. For environment variables grouped by task
43
43
  | `--no-keys` / `DARIO_KEYS=0`; `--keys-path=<file>` / `DARIO_KEYS_PATH` | Ignore named keys (`~/.dario/keys.json`) so only `DARIO_API_KEY` authenticates; or move the file. See [Named keys](./keys.md). (v6.8) | on, `~/.dario/keys.json` |
44
44
  | `--log-file=<path>` / `DARIO_LOG_FILE` | Append one JSON-ND record per completed request to PATH. Useful for backgrounded proxies where stdout is unobserved (where `--verbose` can't help). Field set: `ts`, `req`, `method`, `path`, `model`, `status`, `latency_ms`, `in_tokens`, `out_tokens`, `cache_read`, `cache_create`, `claim`, `bucket`, `account`, `client`, `preserve_tools`, `stream`, plus `reject` / `error` on failure paths. Secrets scrubbed via the same redactor that `--verbose-bodies` uses; no request bodies. | off |
45
45
  | `--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 |
46
+ | `--family-fallback=<family=model,...>` / `DARIO_FAMILY_FALLBACK` / config `familyFallback` | Strictly opt-in. When a rate limit keeps every pool seat off a model family, serve that family's requests as `<model>` on the Claude pool, while some seat can serve the target's family. Written for Fable: its included credit runs out while the seat still has room for Opus, so `fable=claude-opus-5-5` keeps Fable traffic on the subscription instead of a local 429 until the credit resets. The target must be a `claude-*` model of another family; one step, never chained. Runs before `--pool-fallback`, so a pool with room for the target never leaves the Claude pool. Skipped in upstream API-key mode, under `--model`/`--fast-model` and for a seat-pinned request. A `[1m]` tag on the request carries over. Every switched response carries `x-dario-family-fallback: <requested model>`. Empty flag value disables, overriding env + config. See [Family fallback](./multi-account-pool.md#family-fallback). | off |
46
47
  | `--passthrough-betas=<csv>` / `DARIO_PASSTHROUGH_BETAS` | Beta flags ALWAYS forwarded upstream regardless of CC's captured set or the client's `anthropic-beta` header. Bypasses the billable-beta filter (so `extended-cache-ttl-*` survives if you opt in). Per-account rejection cache still applies — a pinned flag the upstream 400's gets dropped on retry rather than re-sent forever. Use when you know a beta works on your account but isn't in the captured template, or when client traffic should be force-augmented. Empty flag value (`--passthrough-betas=`) clears the env-default. | off |
47
48
  | `--strict-tls` / `DARIO_STRICT_TLS=1` | Refuse to start proxy mode unless runtime classifies as `bun-match` — i.e. the TLS ClientHello matches CC's. See [Wire-fidelity axes](./wire-fidelity.md). (v3.23) | off |
48
49
  | `--pace-min=<ms>` / `DARIO_PACE_MIN_MS` | Minimum inter-request gap in ms. Replaces the legacy hardcoded 500 ms. (v3.24) | `500` |
@@ -54,6 +55,7 @@ This page is the per-flag reference. For environment variables grouped by task
54
55
  | `--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 |
55
56
  | `--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` |
56
57
  | `--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 |
58
+ | `--family-fallback=<family=model,...>` / `DARIO_FAMILY_FALLBACK` / config `familyFallback` | Strictly opt-in. When a rate limit keeps every pool seat off a model family, serve that family's requests as `<model>` on the Claude pool, while some seat can serve the target's family. Written for Fable: its included credit runs out while the seat still has room for Opus, so `fable=claude-opus-5-5` keeps Fable traffic on the subscription instead of a local 429 until the credit resets. The target must be a `claude-*` model of another family; one step, never chained. Runs before `--pool-fallback`, so a pool with room for the target never leaves the Claude pool. Skipped in upstream API-key mode, under `--model`/`--fast-model` and for a seat-pinned request. A `[1m]` tag on the request carries over. Every switched response carries `x-dario-family-fallback: <requested model>`. Empty flag value disables, overriding env + config. See [Family fallback](./multi-account-pool.md#family-fallback). | off |
57
59
  | `--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` |
58
60
  | `--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 |
59
61
  | `DARIO_API_KEY` | If set, all endpoints (except `/health`) require a matching `x-api-key` or `Authorization: Bearer` header. Required when `--host` binds non-loopback. | unset (open) |
@@ -82,6 +82,22 @@ dario backend add openrouter --key=sk-or-... --base-url=https://openrouter.ai/ap
82
82
  dario proxy --pool-fallback=openrouter/anthropic/claude-3.5-sonnet
83
83
  ```
84
84
 
85
+ ## Family fallback
86
+
87
+ `--family-fallback=<family=model,...>` (env `DARIO_FAMILY_FALLBACK`, config `familyFallback`) is for a pool that has room, but not for the family a request asks for. Fable is the case it was written for: it is metered on a seat's included credit (the `7d_oi` bucket), so once that credit is spent every Fable request is refused while the same seat still serves Opus. Without a fallback, Fable requests get `429` until the credit resets.
88
+
89
+ ```bash
90
+ dario proxy --family-fallback=fable=claude-opus-5-5
91
+ ```
92
+
93
+ - **When.** Only when a rate limit keeps every seat off the request's family, AND some seat can serve the target's family right now. A pool that is merely busy, a seat with an expiring token or in auth cool-down, and a target family that is parked too all keep their existing handling.
94
+ - **Where.** On the Claude pool: the request is rebuilt with the target model before a seat is chosen, so selection, stickiness and the template all see the target. It runs before `--pool-fallback`, which then still covers a pool with no room at all.
95
+ - **Never silent.** The response carries `x-dario-family-fallback: <requested model>`, the answer names the model that served it, and the proxy logs each switch.
96
+ - **Not for.** Upstream API-key mode (no pool), a server-wide `--model`/`--fast-model` (which replaces the name anyway) and a seat-pinned request (`x-dario-account`), which asked for that seat as it is.
97
+ - **Shape.** The source is a family (`opus`, `sonnet`, `haiku`, `fable`); the target a `claude-*` model of another family. One step, never chained. Bad entries are dropped with a warning at startup and in `dario doctor`. A `[1m]` tag on the request carries over to a target without one.
98
+
99
+ The first Fable request after the credit runs out still goes upstream and comes back `429`: that answer is what parks Fable on the seat. Every Fable request after it is switched, including after the seat has served Opus since.
100
+
85
101
  ## In-flight 429 failover
86
102
 
87
103
  When a Claude request hits a 429 mid-flight, dario retries the *same request* against a different account before the client sees an error. The client sees one successful response; the pool sees the rejected account go cold until its window resets. Combined with session stickiness, long agent runs survive pool-level exhaustion without dropping user-facing turns.
@@ -108,6 +124,8 @@ curl http://localhost:3456/analytics # per-account / per-model stats, burn ra
108
124
 
109
125
  **When every seat is parked.** A pool whose seats are all `rejected` inside live windows does not probe them again: dario answers the request itself with `429`, `retry-after` set to the earliest reset, `x-dario-upstream-rejection: pool_parked`, and nothing sent upstream. One log line marks the transition (`pool parked: all 6 seats are over their rate-limit windows, earliest resets in 21m`). Before 6.0.35 every such request re-probed the earliest-reset seat, so `rejectedCount` on that seat grew by one per request — a seat reading `rejected_count: 500` next to `request_count: 1` was that, not a seat that needed a re-login. With a `--pool-fallback` armed, the request goes to the fallback instead, as before.
110
126
 
127
+ **A seat parked on one family's bucket stays parked for it.** A seat parked on a per-model bucket counts for the families that bucket binds even after it has served another family since: an Opus `200` (or an Opus `429` that names no exhausted window) after a Fable credit `429` does not make it probeable for Fable again before the bucket resets.
128
+
111
129
  **Spending extra usage while the pool is parked (dario#1282).** The parked answer is decided
112
130
  from the seats' own rate-limit verdicts, before anything is sent. Disarming the overage-guard
113
131
  does **not** change it: the guard is reactive — it reads the `representative-claim` on a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.13.31",
3
+ "version": "6.14.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": {