@askalf/dario 5.5.89 → 6.0.0
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 +61 -8
- package/dist/cli.js +67 -13
- package/dist/codex-backend.d.ts +44 -1
- package/dist/codex-backend.js +100 -16
- package/dist/compare.d.ts +110 -0
- package/dist/compare.js +210 -0
- package/dist/config-file.d.ts +6 -5
- package/dist/doctor.d.ts +25 -0
- package/dist/doctor.js +71 -0
- package/dist/provider-adapter.d.ts +31 -0
- package/dist/provider-adapter.js +36 -14
- package/dist/proxy.d.ts +15 -9
- package/dist/proxy.js +244 -42
- package/docs/commands.md +2 -2
- package/docs/multi-account-pool.md +30 -4
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -20,7 +20,8 @@ import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncL
|
|
|
20
20
|
import { handleAdminRequest } from './admin-api.js';
|
|
21
21
|
import { createTokenBucket } from './rate-limit.js';
|
|
22
22
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
23
|
-
import { forwardToCodex, getCodexModelSlugs, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
|
|
23
|
+
import { forwardToCodex, getCodexModelSlugs, pickCodexFallback, pickClaudeFallback, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
|
|
24
|
+
import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
|
|
24
25
|
import { listCodexAccountAliases, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount } from './codex-accounts.js';
|
|
25
26
|
import { route as routeProvider } from './provider-adapter.js';
|
|
26
27
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
|
|
@@ -1234,20 +1235,40 @@ export async function startProxy(opts = {}) {
|
|
|
1234
1235
|
console.log(` Codex accounts: ${startupCodexAliases.join(', ')} → ${CODEX_BACKEND_BASE_URL}`);
|
|
1235
1236
|
}
|
|
1236
1237
|
// Pool-exhausted fallback (strictly opt-in). When the Claude pool can't
|
|
1237
|
-
// serve — every seat rate-limited or in auth cool-down —
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1238
|
+
// serve — every seat rate-limited or in auth cool-down — the request is
|
|
1239
|
+
// re-pointed at whichever provider can serve `poolFallbackModel` instead
|
|
1240
|
+
// of surfacing the 429/503:
|
|
1241
|
+
//
|
|
1242
|
+
// • a stored Codex/ChatGPT subscription that LISTS that model, on
|
|
1243
|
+
// EITHER wire shape. This is the v6.0.0 change and the one that
|
|
1244
|
+
// matters for a deployment whose providers are both subscriptions:
|
|
1245
|
+
// failover now costs nothing per token, needs no API key, and covers
|
|
1246
|
+
// Anthropic-shape clients (Claude Code, agent runtimes) that used to
|
|
1247
|
+
// have nowhere to go and simply went dark when the pool filled.
|
|
1248
|
+
// • otherwise the openai-compat backend, OpenAI shape only — that route
|
|
1249
|
+
// still has no Messages translation.
|
|
1250
|
+
//
|
|
1251
|
+
// Every substituted response carries `x-dario-pool-fallback: <model>` — a
|
|
1252
|
+
// silently swapped model is the kind of surprise this project exists to
|
|
1253
|
+
// avoid.
|
|
1254
|
+
// The value may name a chain — see pickCodexFallback/pickClaudeFallback.
|
|
1255
|
+
// `poolFallbackModel` stays the FIRST entry so every pre-6.0 reference and
|
|
1256
|
+
// every single-value config keeps its exact previous meaning.
|
|
1257
|
+
const poolFallbackModels = ((opts.poolFallbackModel ?? '').trim() || '')
|
|
1258
|
+
.split(',').map((m) => m.trim()).filter(Boolean);
|
|
1259
|
+
const poolFallbackModel = poolFallbackModels[0] ?? null;
|
|
1260
|
+
if (poolFallbackModel) {
|
|
1261
|
+
const targets = [];
|
|
1262
|
+
if (startupCodexAliases.length > 0)
|
|
1263
|
+
targets.push(`codex subscription (${startupCodexAliases.join(', ')}) — both wire shapes`);
|
|
1264
|
+
if (openaiBackend)
|
|
1265
|
+
targets.push(`${openaiBackend.name} — OpenAI shape only`);
|
|
1266
|
+
if (targets.length > 0) {
|
|
1267
|
+
console.log(` Pool fallback: exhausted-pool requests → ${targets.join(', then ')} as ${poolFallbackModel} (marked x-dario-pool-fallback)`);
|
|
1268
|
+
}
|
|
1269
|
+
else {
|
|
1270
|
+
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.');
|
|
1271
|
+
}
|
|
1251
1272
|
}
|
|
1252
1273
|
// User-defined model aliases (see parseModelAliasSpecs). Resolved by the
|
|
1253
1274
|
// CLI (config < env < flags, per-key) and applied per request before
|
|
@@ -1787,6 +1808,58 @@ export async function startProxy(opts = {}) {
|
|
|
1787
1808
|
function checkAuth(req) {
|
|
1788
1809
|
return authenticateRequest(req.headers, apiKeyBuf);
|
|
1789
1810
|
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Serve a pool-exhausted request from the ChatGPT subscription (v6.0.0).
|
|
1813
|
+
*
|
|
1814
|
+
* Returns true when it answered, false when it declined — declining is
|
|
1815
|
+
* silent so the caller can fall through to the api-key backend and then to
|
|
1816
|
+
* the honest 429/503. It never manufactures a reason to fire: no codex
|
|
1817
|
+
* account, an unreadable one, or a `poolFallbackModel` that account does not
|
|
1818
|
+
* list all mean "not mine to serve".
|
|
1819
|
+
*
|
|
1820
|
+
* Works for BOTH wire shapes, which is the point: forge's agents and Claude
|
|
1821
|
+
* Code speak Anthropic, and before v5.5.87 they had nowhere to fail over to
|
|
1822
|
+
* and simply went dark when the Claude pool filled. The substituted model is
|
|
1823
|
+
* announced on `x-dario-pool-fallback` exactly as the api-key path does —
|
|
1824
|
+
* a silently swapped model family is precisely the surprise this project
|
|
1825
|
+
* exists to avoid.
|
|
1826
|
+
*/
|
|
1827
|
+
const tryCodexPoolFallback = async (req, res, body, fallbackModels, shape, why) => {
|
|
1828
|
+
if (fallbackModels.length === 0)
|
|
1829
|
+
return false;
|
|
1830
|
+
if (!(await hasAnyCodexAccount().catch(() => false)))
|
|
1831
|
+
return false;
|
|
1832
|
+
const stored = await selectCodexAccount().catch(() => null);
|
|
1833
|
+
if (!stored)
|
|
1834
|
+
return false;
|
|
1835
|
+
let creds;
|
|
1836
|
+
try {
|
|
1837
|
+
creds = await getFreshCodexAccount(stored);
|
|
1838
|
+
}
|
|
1839
|
+
catch {
|
|
1840
|
+
return false;
|
|
1841
|
+
}
|
|
1842
|
+
const slugs = await getCodexModelSlugs(creds).catch(() => []);
|
|
1843
|
+
const fallbackModel = pickCodexFallback(fallbackModels, slugs);
|
|
1844
|
+
if (!fallbackModel)
|
|
1845
|
+
return false;
|
|
1846
|
+
const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
|
|
1847
|
+
if (!fallbackBody)
|
|
1848
|
+
return false;
|
|
1849
|
+
console.log(`[dario] #${requestCount} ${why} → codex account ${creds.alias} as ${fallbackModel}`);
|
|
1850
|
+
requestCount++;
|
|
1851
|
+
// If an api-key backend could ALSO serve this request, let the subscription
|
|
1852
|
+
// decline a 429/5xx rather than answer with it, and report not-served so the
|
|
1853
|
+
// caller falls through to that backend. This helper's contract has always
|
|
1854
|
+
// said it declines so the caller can continue; it just never exercised the
|
|
1855
|
+
// mechanism it was built on, so a rate-limited subscription ended the chain
|
|
1856
|
+
// with a healthy backend sitting unused beside it.
|
|
1857
|
+
//
|
|
1858
|
+
// With NO next option, do not defer: the real upstream error is more useful
|
|
1859
|
+
// to the client than replacing it with a generic 503.
|
|
1860
|
+
const hasNextOption = openaiBackend !== null && shape === 'openai';
|
|
1861
|
+
return await forwardToCodex(req, res, fallbackBody, creds, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption);
|
|
1862
|
+
};
|
|
1790
1863
|
const server = createServer(async (req, res) => {
|
|
1791
1864
|
if (req.method === 'OPTIONS') {
|
|
1792
1865
|
res.writeHead(204, CORS_HEADERS);
|
|
@@ -2305,6 +2378,29 @@ export async function startProxy(opts = {}) {
|
|
|
2305
2378
|
* null without answering in the pool-exhausted-fallback case, which the
|
|
2306
2379
|
* fallback dispatch right below picks up.
|
|
2307
2380
|
*/
|
|
2381
|
+
// The pool-unavailable 503, in one place. Two distinct empty-selection
|
|
2382
|
+
// cases (#599): the pool has no accounts at all (headless admin
|
|
2383
|
+
// bootstrap — nothing added yet), vs. it has accounts but all are
|
|
2384
|
+
// rate-limited / in auth cool-down. Each gets a truthful, actionable
|
|
2385
|
+
// message so a headless operator isn't told "rate-limited" when they
|
|
2386
|
+
// simply haven't added an account.
|
|
2387
|
+
//
|
|
2388
|
+
// Shared because two callers must agree: the selector, and the dispatch
|
|
2389
|
+
// below for a request the selector DEFERRED but no provider could serve.
|
|
2390
|
+
const writePoolUnavailable = () => {
|
|
2391
|
+
res.writeHead(503, JSON_HEADERS);
|
|
2392
|
+
res.end(JSON.stringify(pool.size === 0
|
|
2393
|
+
? {
|
|
2394
|
+
error: 'No account configured',
|
|
2395
|
+
message: adminEnabled
|
|
2396
|
+
? 'dario is running in admin mode with no account yet. Add one via POST /admin/login/start, then retry.'
|
|
2397
|
+
: 'No accounts available. Run `dario login`, or add accounts with `dario accounts add`.',
|
|
2398
|
+
}
|
|
2399
|
+
: {
|
|
2400
|
+
error: 'No accounts available in pool',
|
|
2401
|
+
message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
|
|
2402
|
+
}));
|
|
2403
|
+
};
|
|
2308
2404
|
const selectPoolAccount = () => {
|
|
2309
2405
|
if (upstreamApiKey) {
|
|
2310
2406
|
// Per-token API-key mode: no OAuth, no pool selection. `poolAccount`
|
|
@@ -2322,26 +2418,25 @@ export async function startProxy(opts = {}) {
|
|
|
2322
2418
|
// openai-compat backend. An EMPTY pool still 503s: that's a setup
|
|
2323
2419
|
// error the operator needs to see, not traffic to quietly re-bill
|
|
2324
2420
|
// somewhere else.
|
|
2325
|
-
|
|
2326
|
-
|
|
2421
|
+
// Defer whenever a fallback is ARMED and the pool has seats that
|
|
2422
|
+
// could be drained. WHICH provider can serve it is decided at the
|
|
2423
|
+
// dispatch below, not here, because answering that needs an await —
|
|
2424
|
+
// a Codex account's model list — and this selector is synchronous.
|
|
2425
|
+
//
|
|
2426
|
+
// Before v6.0.0 this also demanded `openaiBackend !== null && isOpenAI`,
|
|
2427
|
+
// which silently made the entire subscription failover unreachable:
|
|
2428
|
+
// an Anthropic-shape request, or a box with a Codex account and no
|
|
2429
|
+
// api-key backend, 503'd HERE, before the dispatcher ever ran. That is
|
|
2430
|
+
// exactly the deployment this release is about, so the feature was
|
|
2431
|
+
// dead in the configuration it was written for. Caught in review on
|
|
2432
|
+
// dario#1145 — the routing tests all passed, because none of them went
|
|
2433
|
+
// through this selector.
|
|
2434
|
+
//
|
|
2435
|
+
// An EMPTY pool still 503s: that's a setup error the operator needs to
|
|
2436
|
+
// see, not traffic to quietly re-bill somewhere else.
|
|
2437
|
+
const fallbackViable = poolFallbackModels.length > 0 && pool.size > 0;
|
|
2327
2438
|
if (!fallbackViable) {
|
|
2328
|
-
|
|
2329
|
-
// at all (headless admin bootstrap — nothing added yet), vs. it has
|
|
2330
|
-
// accounts but all are rate-limited / in auth cool-down. Give each a
|
|
2331
|
-
// truthful, actionable message so a headless operator isn't told
|
|
2332
|
-
// "rate-limited" when they simply haven't added an account.
|
|
2333
|
-
res.writeHead(503, JSON_HEADERS);
|
|
2334
|
-
res.end(JSON.stringify(pool.size === 0
|
|
2335
|
-
? {
|
|
2336
|
-
error: 'No account configured',
|
|
2337
|
-
message: adminEnabled
|
|
2338
|
-
? 'dario is running in admin mode with no account yet. Add one via POST /admin/login/start, then retry.'
|
|
2339
|
-
: 'No accounts available. Run `dario login`, or add accounts with `dario accounts add`.',
|
|
2340
|
-
}
|
|
2341
|
-
: {
|
|
2342
|
-
error: 'No accounts available in pool',
|
|
2343
|
-
message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
|
|
2344
|
-
}));
|
|
2439
|
+
writePoolUnavailable();
|
|
2345
2440
|
return false;
|
|
2346
2441
|
}
|
|
2347
2442
|
}
|
|
@@ -2462,6 +2557,65 @@ export async function startProxy(opts = {}) {
|
|
|
2462
2557
|
}
|
|
2463
2558
|
catch { /* not JSON — fall through */ }
|
|
2464
2559
|
}
|
|
2560
|
+
// Shadow compare (v6.0.0) — the full rationale is in compare.ts. Armed
|
|
2561
|
+
// per request by `x-dario-compare: <model>`, it runs the same prompt past
|
|
2562
|
+
// the other model family BESIDE the real answer and keeps both. Hooked in
|
|
2563
|
+
// here, ahead of routing, so it covers whichever provider ends up serving.
|
|
2564
|
+
//
|
|
2565
|
+
// Nothing below is allowed to depend on it: the tee only observes bytes
|
|
2566
|
+
// already on their way out, the comparison is never awaited by the
|
|
2567
|
+
// request, and every failure path resolves rather than throws.
|
|
2568
|
+
const compareTarget = readCompareTarget(req.headers);
|
|
2569
|
+
if (compareTarget) {
|
|
2570
|
+
const compareShape = isOpenAI ? 'openai' : 'anthropic';
|
|
2571
|
+
// Snapshot the body NOW. `body` is reassigned later by the codex→Claude
|
|
2572
|
+
// fall-through, and the finish callback below closes over it — so a
|
|
2573
|
+
// comparison on a request that failed over would record the SWAPPED
|
|
2574
|
+
// model as `primaryModel` and the swapped payload as the "verbatim"
|
|
2575
|
+
// request. That record would then quietly attribute the primary answer
|
|
2576
|
+
// to the wrong model family, which is worse than having no record: the
|
|
2577
|
+
// whole point of the log is deciding which family did better.
|
|
2578
|
+
const compareRequestBody = Buffer.from(body);
|
|
2579
|
+
const tee = teeResponse(res);
|
|
2580
|
+
res.setHeader(COMPARE_RESULT_HEADER, compareTarget);
|
|
2581
|
+
// Started before the primary is dispatched so the two overlap; holding
|
|
2582
|
+
// it until afterwards would double the wall-clock of a comparison
|
|
2583
|
+
// nobody is waiting on, for no benefit.
|
|
2584
|
+
const running = runCompare({
|
|
2585
|
+
body: compareRequestBody,
|
|
2586
|
+
shape: compareShape,
|
|
2587
|
+
targetModel: compareTarget,
|
|
2588
|
+
corsOrigin,
|
|
2589
|
+
timeoutMs: upstreamTimeoutMs,
|
|
2590
|
+
verbose,
|
|
2591
|
+
});
|
|
2592
|
+
res.on('finish', () => {
|
|
2593
|
+
void running.then((result) => {
|
|
2594
|
+
let request = null;
|
|
2595
|
+
try {
|
|
2596
|
+
request = JSON.parse(compareRequestBody.toString());
|
|
2597
|
+
}
|
|
2598
|
+
catch { /* recorded as null */ }
|
|
2599
|
+
const asObj = (request ?? {});
|
|
2600
|
+
const written = writeCompareRecord({
|
|
2601
|
+
ts: new Date().toISOString(),
|
|
2602
|
+
path: urlPath,
|
|
2603
|
+
shape: compareShape,
|
|
2604
|
+
streaming: asObj.stream === true,
|
|
2605
|
+
primaryModel: typeof asObj.model === 'string' ? asObj.model : '(unknown)',
|
|
2606
|
+
comparedModel: compareTarget,
|
|
2607
|
+
request,
|
|
2608
|
+
primary: tee.captured(),
|
|
2609
|
+
compare: result.side,
|
|
2610
|
+
...(result.skipped ? { skipped: result.skipped } : {}),
|
|
2611
|
+
});
|
|
2612
|
+
if (written)
|
|
2613
|
+
console.log(`[dario] compare vs ${compareTarget} -> ${written}`);
|
|
2614
|
+
else if (result.skipped)
|
|
2615
|
+
console.log(`[dario] compare vs ${compareTarget} skipped: ${result.skipped}`);
|
|
2616
|
+
});
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2465
2619
|
// Multi-provider routing (v3.6.0+). When an OpenAI-compat backend is
|
|
2466
2620
|
// configured and the request is on /v1/chat/completions with a
|
|
2467
2621
|
// GPT-family model (or a forced `openai:` prefix), forward it straight
|
|
@@ -2520,8 +2674,36 @@ export async function startProxy(opts = {}) {
|
|
|
2520
2674
|
console.log(`[dario] #${requestCount} ${req.method} ${urlPath} (model: ${rawModel}) → codex account ${codexCreds.alias}`);
|
|
2521
2675
|
}
|
|
2522
2676
|
requestCount++;
|
|
2523
|
-
|
|
2524
|
-
|
|
2677
|
+
// Symmetric failover (v6.0.0). When the chain nominates a model the
|
|
2678
|
+
// Claude pool can serve and there are seats to serve it, let the
|
|
2679
|
+
// subscription DECLINE a 429/5xx rather than pass it to the client,
|
|
2680
|
+
// and pick the request back up on the Claude path below. Before
|
|
2681
|
+
// this, a rate-limited ChatGPT plan was terminal for a gpt-bound
|
|
2682
|
+
// request even with an idle Claude pool sitting right beside it.
|
|
2683
|
+
const claudeTarget = pickClaudeFallback(poolFallbackModels, codexModels);
|
|
2684
|
+
const canDefer = claudeTarget !== null && pool.size > 0 && !upstreamApiKey;
|
|
2685
|
+
const served = await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer);
|
|
2686
|
+
if (served)
|
|
2687
|
+
return;
|
|
2688
|
+
const swapped = buildPoolFallbackBody(body, claudeTarget);
|
|
2689
|
+
if (!swapped) {
|
|
2690
|
+
res.writeHead(503, { 'Content-Type': 'application/json', ...SECURITY_HEADERS });
|
|
2691
|
+
res.end(JSON.stringify({ error: { type: 'upstream_unavailable', message: 'Codex backend unavailable and the request body could not be re-pointed at the Claude pool.' } }));
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
console.log(`[dario] #${requestCount} codex unavailable -> claude pool as ${claudeTarget}`);
|
|
2695
|
+
// Copy so the type matches the ArrayBuffer-backed buffer this
|
|
2696
|
+
// handler threads through (Buffer.concat's), as the other in-place
|
|
2697
|
+
// body rewrites above already do.
|
|
2698
|
+
body = Buffer.from(swapped);
|
|
2699
|
+
// The Claude path prefers this cached parse over `body`. Leaving it
|
|
2700
|
+
// stale would send the OLD model upstream while every log line and
|
|
2701
|
+
// the response header claimed the swap happened — a failure that
|
|
2702
|
+
// reads as a success, which is the exact bug class this release
|
|
2703
|
+
// spent its whole review budget hunting.
|
|
2704
|
+
parsedBody = null;
|
|
2705
|
+
res.setHeader('x-dario-pool-fallback', claudeTarget);
|
|
2706
|
+
// fall through to Claude's turn below
|
|
2525
2707
|
}
|
|
2526
2708
|
if (rawModel && openaiBackend && decision.provider === 'openai') {
|
|
2527
2709
|
if (verbose) {
|
|
@@ -2548,14 +2730,20 @@ export async function startProxy(opts = {}) {
|
|
|
2548
2730
|
// response carries `x-dario-pool-fallback` — a substituted model must
|
|
2549
2731
|
// never be silent. GPT-bound requests never reach here (the routing
|
|
2550
2732
|
// block above already forwarded them; they don't need the pool).
|
|
2551
|
-
if (!upstreamApiKey && !poolAccount &&
|
|
2733
|
+
if (!upstreamApiKey && !poolAccount && await tryCodexPoolFallback(req, res, body, poolFallbackModels, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted')) {
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
// `isOpenAI` is REQUIRED here and was not, before v6.0.0 — the selector's
|
|
2737
|
+
// own isOpenAI check was the only thing keeping Anthropic-shape requests
|
|
2738
|
+
// out of this branch. Relaxing the selector without moving that guard down
|
|
2739
|
+
// would forward a /v1/messages request to an openai-compat backend and
|
|
2740
|
+
// hand the client an OpenAI-shaped response for a Messages request. This
|
|
2741
|
+
// route still has no reverse translation; the codex route above does,
|
|
2742
|
+
// which is why it takes both shapes and this one does not.
|
|
2743
|
+
if (!upstreamApiKey && !poolAccount && poolFallbackModel && openaiBackend && isOpenAI) {
|
|
2552
2744
|
const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
|
|
2553
2745
|
if (!fallbackBody) {
|
|
2554
|
-
|
|
2555
|
-
res.end(JSON.stringify({
|
|
2556
|
-
error: 'No accounts available in pool',
|
|
2557
|
-
message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
|
|
2558
|
-
}));
|
|
2746
|
+
writePoolUnavailable();
|
|
2559
2747
|
return;
|
|
2560
2748
|
}
|
|
2561
2749
|
console.log(`[dario] #${requestCount} pool exhausted — /v1/chat/completions → ${openaiBackend.name} as ${poolFallbackModel}`);
|
|
@@ -2563,6 +2751,17 @@ export async function startProxy(opts = {}) {
|
|
|
2563
2751
|
await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, upstreamTimeoutMs, verbose);
|
|
2564
2752
|
return;
|
|
2565
2753
|
}
|
|
2754
|
+
// Deferred at selection because a fallback was armed, but nothing could
|
|
2755
|
+
// actually serve it — no Codex account lists the model, and either there
|
|
2756
|
+
// is no api-key backend or this is the wrong wire shape for one. Answer
|
|
2757
|
+
// with the truth the selector used to give. Without this the request
|
|
2758
|
+
// would fall through to the Claude path with no account and an empty
|
|
2759
|
+
// bearer token, turning a clean 503 into a confusing upstream 401.
|
|
2760
|
+
if (!upstreamApiKey && !poolAccount) {
|
|
2761
|
+
console.log(`[dario] #${requestCount} pool exhausted and no fallback provider could serve ${poolFallbackModels.join(', ') || '(none configured)'}`);
|
|
2762
|
+
writePoolUnavailable();
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2566
2765
|
// Parse body once, apply OpenAI translation, model override, and sanitization
|
|
2567
2766
|
let finalBody = body.length > 0 ? body : undefined;
|
|
2568
2767
|
let ccToolMap = null;
|
|
@@ -3429,6 +3628,9 @@ export async function startProxy(opts = {}) {
|
|
|
3429
3628
|
// bytes — the Anthropic translation went into finalBody, never
|
|
3430
3629
|
// back into body. Marked via x-dario-pool-fallback, same as the
|
|
3431
3630
|
// selection-time path.
|
|
3631
|
+
if (await tryCodexPoolFallback(req, res, body, poolFallbackModels, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)')) {
|
|
3632
|
+
return;
|
|
3633
|
+
}
|
|
3432
3634
|
if (isOpenAI && poolFallbackModel && openaiBackend) {
|
|
3433
3635
|
const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
|
|
3434
3636
|
if (fallbackBody) {
|
package/docs/commands.md
CHANGED
|
@@ -37,7 +37,7 @@ This page is the per-flag reference. For environment variables grouped by task
|
|
|
37
37
|
| `--verbose` / `-v` | Log every request (one line per request — method + path + billing bucket) | off |
|
|
38
38
|
| `--verbose=2` / `-vv` / `DARIO_LOG_BODIES=1` | Also dump the outbound request body (redacted: bearer tokens, `sk-ant-*` keys, JWTs stripped; capped at 8KB). For wire-level client-compat debugging. | off |
|
|
39
39
|
| `--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 |
|
|
40
|
-
| `--pool-fallback=<
|
|
40
|
+
| `--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 |
|
|
41
41
|
| `--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 |
|
|
42
42
|
| `--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 |
|
|
43
43
|
| `--pace-min=<ms>` / `DARIO_PACE_MIN_MS` | Minimum inter-request gap in ms. Replaces the legacy hardcoded 500 ms. (v3.24) | `500` |
|
|
@@ -48,7 +48,7 @@ This page is the per-flag reference. For environment variables grouped by task
|
|
|
48
48
|
| `--session-max-age=<ms>` / `DARIO_SESSION_MAX_AGE_MS` | Hard ceiling on a session-id's lifetime regardless of activity. (v3.28) | off |
|
|
49
49
|
| `--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 |
|
|
50
50
|
| `--pool-strategy=<headroom\|fill-first>` / `DARIO_POOL_STRATEGY` | Where new conversations land in a multi-account pool. `headroom` spreads them to the seat with the most slack; `fill-first` concentrates them on the alphabetically-first eligible seat until it drains to the 2% floor, then spills to the next — primary/backup semantics, alias naming (`1-main`, `2-overflow`) picks the fill order. Sticky bindings behave identically under both. See [Multi-account pool](./multi-account-pool.md#routing-strategy). | `headroom` |
|
|
51
|
-
| `--pool-fallback=<
|
|
51
|
+
| `--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 |
|
|
52
52
|
| `--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` |
|
|
53
53
|
| `--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 |
|
|
54
54
|
| `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) |
|
|
@@ -41,13 +41,39 @@ Multi-turn agent sessions pin to one account for the life of the conversation, s
|
|
|
41
41
|
|
|
42
42
|
## Pool-exhausted fallback
|
|
43
43
|
|
|
44
|
-
`--pool-fallback=<
|
|
44
|
+
`--pool-fallback=<models>` (env `DARIO_POOL_FALLBACK`, config `poolFallback.model`) is a strictly opt-in escape hatch for when a provider can't serve. A request the Claude pool can't take — at selection time, or after a mid-flight 429 with no peer left — is served as the nominated model by whichever provider can, instead of returning the 429/503.
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
The value may be a **chain**, read left to right, each provider taking the first entry it can actually serve:
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
```bash
|
|
49
|
+
dario proxy --pool-fallback=gpt-5.6-sol,claude-sonnet-5
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- Claude pool drained → served as `gpt-5.6-sol` from your ChatGPT subscription.
|
|
53
|
+
- Subscription rate-limited or down → handed back to the Claude pool as `claude-sonnet-5`.
|
|
54
|
+
|
|
55
|
+
Neither subscription hitting its ceiling can take the deployment down on its own. A single-entry chain is one-way and behaves exactly as it did before v6.0.0.
|
|
56
|
+
|
|
57
|
+
**Since v6.0.0 a subscription is a first-class failover target**, on both wire shapes. Before that the only target was an api-key backend on `/v1/chat/completions`, which made failover inert for anyone whose second provider is a ChatGPT plan — and left Anthropic-shape clients (Claude Code, the Anthropic SDKs, agent runtimes) with nowhere to go at all.
|
|
58
|
+
|
|
59
|
+
Deliberate limits:
|
|
60
|
+
|
|
61
|
+
- **Only a 429 or 5xx fails over.** A 400 surfaces to the client. A bad request that fails over just reproduces itself on the other provider and buries the real cause.
|
|
62
|
+
- **The Claude entry must be a real `claude-*` id.** "Not a codex slug" would also match a typo or a model meant for a third provider, and swapping that in trades a recoverable 429 for an unrecoverable 404. Anything that doesn't look like an Anthropic model is ignored — failing closed. A `--model-alias` is not accepted here; name the real id.
|
|
63
|
+
- **The api-key backend is still OpenAI-shape only.** There is no Messages translation on that route. A Codex account has one, which is why it is preferred.
|
|
64
|
+
- **Never silent.** Every substituted response carries `x-dario-pool-fallback: <model>`. A quietly swapped model is exactly the surprise this project exists to avoid.
|
|
50
65
|
- **Empty pool still errors.** A pool with zero accounts is a setup mistake (`dario login` never ran); that returns the usual 503 rather than silently re-billing every request to another provider.
|
|
66
|
+
- **Strictly opt-in.** Without the flag, a drained pool returns its honest 429/503.
|
|
67
|
+
|
|
68
|
+
`dario doctor` reports which state you are actually in — including *armed but INERT*, meaning a fallback is configured with no provider able to serve it:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
[ OK ] Failover symmetric: gpt-5.6-sol → claude-sonnet-5, across 1 Codex account
|
|
72
|
+
[WARN] Failover armed (gpt-5.6-sol) but INERT — no Codex account and no backend
|
|
73
|
+
to fall back to. Add one: `dario add altman`
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
An api-key backend still works as a target, on the OpenAI path:
|
|
51
77
|
|
|
52
78
|
```bash
|
|
53
79
|
dario backend add openrouter --key=sk-or-... --base-url=https://openrouter.ai/api/v1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
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": {
|