@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 +21 -1
- package/dist/config-file.d.ts +7 -0
- package/dist/config-file.js +9 -0
- package/dist/doctor-core.js +16 -0
- package/dist/family-fallback.d.ts +40 -0
- package/dist/family-fallback.js +93 -0
- package/dist/pool.d.ts +17 -0
- package/dist/pool.js +34 -2
- package/dist/proxy.d.ts +11 -0
- package/dist/proxy.js +37 -0
- package/docs/commands.md +2 -0
- package/docs/multi-account-pool.md +18 -0
- package/package.json +1 -1
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
|
package/dist/config-file.d.ts
CHANGED
|
@@ -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
|
package/dist/config-file.js
CHANGED
|
@@ -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)) {
|
package/dist/doctor-core.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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": {
|