@askalf/dario 5.2.5 → 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 +1 -0
- package/dist/cli.js +71 -2
- package/dist/config-file.d.ts +26 -0
- package/dist/config-file.js +28 -0
- package/dist/live-fingerprint.d.ts +1 -1
- package/dist/live-fingerprint.js +1 -1
- package/dist/pool.d.ts +28 -0
- package/dist/pool.js +53 -0
- package/dist/proxy.d.ts +56 -0
- package/dist/proxy.js +172 -22
- package/dist/tui/tabs/config.js +2 -0
- package/package.json +1 -1
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>
|
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
|
package/dist/config-file.d.ts
CHANGED
|
@@ -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;
|
package/dist/config-file.js
CHANGED
|
@@ -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.
|
|
285
|
+
readonly maxTested: "2.1.212";
|
|
286
286
|
};
|
|
287
287
|
/**
|
|
288
288
|
* Compare two dotted-numeric version strings. Returns negative if `a<b`,
|
package/dist/live-fingerprint.js
CHANGED
|
@@ -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.
|
|
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
|
@@ -12,7 +12,7 @@ import { darioVersion } from './version.js';
|
|
|
12
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1918
|
-
//
|
|
1919
|
-
//
|
|
1920
|
-
//
|
|
1921
|
-
//
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
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
|
|
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;
|
|
@@ -2927,6 +3061,22 @@ export async function startProxy(opts = {}) {
|
|
|
2927
3061
|
continue dispatchLoop;
|
|
2928
3062
|
}
|
|
2929
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
|
+
}
|
|
2930
3080
|
const errBody = await upstream.text().catch(() => '');
|
|
2931
3081
|
const enriched = enrich429(errBody, upstream.headers);
|
|
2932
3082
|
const responseHeaders = {
|
package/dist/tui/tabs/config.js
CHANGED
|
@@ -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.
|
|
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": {
|