@askalf/dario 5.5.90 → 6.0.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/README.md +61 -8
- package/dist/analytics.d.ts +2 -0
- package/dist/analytics.js +9 -0
- package/dist/cli.js +67 -13
- package/dist/codex-backend.d.ts +47 -1
- package/dist/codex-backend.js +107 -10
- 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 +300 -44
- package/docs/commands.md +2 -2
- package/docs/multi-account-pool.md +30 -4
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -13,15 +13,16 @@ import { buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuf
|
|
|
13
13
|
import { stampCch, hasCchSeed } from './cch.js';
|
|
14
14
|
import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
|
|
15
15
|
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness } from './pool.js';
|
|
16
|
-
import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS } from './analytics.js';
|
|
16
|
+
import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, CODEX_CLAIM } from './analytics.js';
|
|
17
17
|
import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
|
|
18
18
|
import { notify as osNotify } from './notify.js';
|
|
19
19
|
import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool, mirrorLoginToCredentials } from './accounts.js';
|
|
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';
|
|
24
|
-
import {
|
|
23
|
+
import { forwardToCodex, getCodexModelSlugs, peekCodexModelSlugs, pickCodexFallback, pickClaudeFallback, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
|
|
24
|
+
import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
|
|
25
|
+
import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, 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';
|
|
27
28
|
import { redactSecrets } from './redact.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
|
|
@@ -1303,6 +1324,9 @@ export async function startProxy(opts = {}) {
|
|
|
1303
1324
|
// : null` — that gated the /analytics endpoint, but burn-rate /
|
|
1304
1325
|
// per-request visibility is useful for a pool of one too.
|
|
1305
1326
|
const analytics = new Analytics();
|
|
1327
|
+
// Per-alias request counts for GET /codex — the pool has requestCount per
|
|
1328
|
+
// account; the codex accounts had nothing until now.
|
|
1329
|
+
const codexRequestCounts = new Map();
|
|
1306
1330
|
// Overage-guard (v4.1, dario#288). Resolved from opts with built-in
|
|
1307
1331
|
// defaults (enabled=true, behavior='halt', cooldown=30min, notifyOs=true)
|
|
1308
1332
|
// so an opts-less proxy still gets protection. The notifier is wired
|
|
@@ -1787,6 +1811,58 @@ export async function startProxy(opts = {}) {
|
|
|
1787
1811
|
function checkAuth(req) {
|
|
1788
1812
|
return authenticateRequest(req.headers, apiKeyBuf);
|
|
1789
1813
|
}
|
|
1814
|
+
/**
|
|
1815
|
+
* Serve a pool-exhausted request from the ChatGPT subscription (v6.0.0).
|
|
1816
|
+
*
|
|
1817
|
+
* Returns true when it answered, false when it declined — declining is
|
|
1818
|
+
* silent so the caller can fall through to the api-key backend and then to
|
|
1819
|
+
* the honest 429/503. It never manufactures a reason to fire: no codex
|
|
1820
|
+
* account, an unreadable one, or a `poolFallbackModel` that account does not
|
|
1821
|
+
* list all mean "not mine to serve".
|
|
1822
|
+
*
|
|
1823
|
+
* Works for BOTH wire shapes, which is the point: forge's agents and Claude
|
|
1824
|
+
* Code speak Anthropic, and before v5.5.87 they had nowhere to fail over to
|
|
1825
|
+
* and simply went dark when the Claude pool filled. The substituted model is
|
|
1826
|
+
* announced on `x-dario-pool-fallback` exactly as the api-key path does —
|
|
1827
|
+
* a silently swapped model family is precisely the surprise this project
|
|
1828
|
+
* exists to avoid.
|
|
1829
|
+
*/
|
|
1830
|
+
const tryCodexPoolFallback = async (req, res, body, fallbackModels, shape, why) => {
|
|
1831
|
+
if (fallbackModels.length === 0)
|
|
1832
|
+
return false;
|
|
1833
|
+
if (!(await hasAnyCodexAccount().catch(() => false)))
|
|
1834
|
+
return false;
|
|
1835
|
+
const stored = await selectCodexAccount().catch(() => null);
|
|
1836
|
+
if (!stored)
|
|
1837
|
+
return false;
|
|
1838
|
+
let creds;
|
|
1839
|
+
try {
|
|
1840
|
+
creds = await getFreshCodexAccount(stored);
|
|
1841
|
+
}
|
|
1842
|
+
catch {
|
|
1843
|
+
return false;
|
|
1844
|
+
}
|
|
1845
|
+
const slugs = await getCodexModelSlugs(creds).catch(() => []);
|
|
1846
|
+
const fallbackModel = pickCodexFallback(fallbackModels, slugs);
|
|
1847
|
+
if (!fallbackModel)
|
|
1848
|
+
return false;
|
|
1849
|
+
const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
|
|
1850
|
+
if (!fallbackBody)
|
|
1851
|
+
return false;
|
|
1852
|
+
console.log(`[dario] #${requestCount} ${why} → codex account ${creds.alias} as ${fallbackModel}`);
|
|
1853
|
+
requestCount++;
|
|
1854
|
+
// If an api-key backend could ALSO serve this request, let the subscription
|
|
1855
|
+
// decline a 429/5xx rather than answer with it, and report not-served so the
|
|
1856
|
+
// caller falls through to that backend. This helper's contract has always
|
|
1857
|
+
// said it declines so the caller can continue; it just never exercised the
|
|
1858
|
+
// mechanism it was built on, so a rate-limited subscription ended the chain
|
|
1859
|
+
// with a healthy backend sitting unused beside it.
|
|
1860
|
+
//
|
|
1861
|
+
// With NO next option, do not defer: the real upstream error is more useful
|
|
1862
|
+
// to the client than replacing it with a generic 503.
|
|
1863
|
+
const hasNextOption = openaiBackend !== null && shape === 'openai';
|
|
1864
|
+
return await forwardToCodex(req, res, fallbackBody, creds, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption);
|
|
1865
|
+
};
|
|
1790
1866
|
const server = createServer(async (req, res) => {
|
|
1791
1867
|
if (req.method === 'OPTIONS') {
|
|
1792
1868
|
res.writeHead(204, CORS_HEADERS);
|
|
@@ -2032,6 +2108,29 @@ export async function startProxy(opts = {}) {
|
|
|
2032
2108
|
}
|
|
2033
2109
|
// Analytics endpoint — rolling-window summary + burn-rate snapshot.
|
|
2034
2110
|
// Always-on as of v4 (pre-v4 this was gated to pool mode).
|
|
2111
|
+
// The codex engine, for the admin surface. Key-gated like /accounts. Reads
|
|
2112
|
+
// only what is already on disk and in the model cache: no upstream call,
|
|
2113
|
+
// no token refresh, and no token in the answer — a status read must never
|
|
2114
|
+
// spend or expose a credential.
|
|
2115
|
+
if (urlPath === '/codex' && req.method === 'GET') {
|
|
2116
|
+
const now = Date.now();
|
|
2117
|
+
const stored = await loadAllCodexAccounts();
|
|
2118
|
+
const accounts = stored.map((a) => ({
|
|
2119
|
+
alias: a.alias,
|
|
2120
|
+
expiresAt: a.expiresAt,
|
|
2121
|
+
expiresInMs: Math.max(0, a.expiresAt - now),
|
|
2122
|
+
needsRefresh: codexAccountNeedsRefresh(a),
|
|
2123
|
+
models: peekCodexModelSlugs(a.alias) ?? [],
|
|
2124
|
+
requestCount: codexRequestCounts.get(a.alias) ?? 0,
|
|
2125
|
+
}));
|
|
2126
|
+
res.writeHead(200, JSON_HEADERS);
|
|
2127
|
+
res.end(JSON.stringify({
|
|
2128
|
+
backend: CODEX_BACKEND_BASE_URL,
|
|
2129
|
+
requests: [...codexRequestCounts.values()].reduce((n, c) => n + c, 0),
|
|
2130
|
+
accounts,
|
|
2131
|
+
}));
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2035
2134
|
if (urlPath === '/analytics' && req.method === 'GET') {
|
|
2036
2135
|
res.writeHead(200, JSON_HEADERS);
|
|
2037
2136
|
// `queue` rides along the summary (dario#905): request-queue.ts always
|
|
@@ -2305,6 +2404,29 @@ export async function startProxy(opts = {}) {
|
|
|
2305
2404
|
* null without answering in the pool-exhausted-fallback case, which the
|
|
2306
2405
|
* fallback dispatch right below picks up.
|
|
2307
2406
|
*/
|
|
2407
|
+
// The pool-unavailable 503, in one place. Two distinct empty-selection
|
|
2408
|
+
// cases (#599): the pool has no accounts at all (headless admin
|
|
2409
|
+
// bootstrap — nothing added yet), vs. it has accounts but all are
|
|
2410
|
+
// rate-limited / in auth cool-down. Each gets a truthful, actionable
|
|
2411
|
+
// message so a headless operator isn't told "rate-limited" when they
|
|
2412
|
+
// simply haven't added an account.
|
|
2413
|
+
//
|
|
2414
|
+
// Shared because two callers must agree: the selector, and the dispatch
|
|
2415
|
+
// below for a request the selector DEFERRED but no provider could serve.
|
|
2416
|
+
const writePoolUnavailable = () => {
|
|
2417
|
+
res.writeHead(503, JSON_HEADERS);
|
|
2418
|
+
res.end(JSON.stringify(pool.size === 0
|
|
2419
|
+
? {
|
|
2420
|
+
error: 'No account configured',
|
|
2421
|
+
message: adminEnabled
|
|
2422
|
+
? 'dario is running in admin mode with no account yet. Add one via POST /admin/login/start, then retry.'
|
|
2423
|
+
: 'No accounts available. Run `dario login`, or add accounts with `dario accounts add`.',
|
|
2424
|
+
}
|
|
2425
|
+
: {
|
|
2426
|
+
error: 'No accounts available in pool',
|
|
2427
|
+
message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
|
|
2428
|
+
}));
|
|
2429
|
+
};
|
|
2308
2430
|
const selectPoolAccount = () => {
|
|
2309
2431
|
if (upstreamApiKey) {
|
|
2310
2432
|
// Per-token API-key mode: no OAuth, no pool selection. `poolAccount`
|
|
@@ -2322,26 +2444,25 @@ export async function startProxy(opts = {}) {
|
|
|
2322
2444
|
// openai-compat backend. An EMPTY pool still 503s: that's a setup
|
|
2323
2445
|
// error the operator needs to see, not traffic to quietly re-bill
|
|
2324
2446
|
// somewhere else.
|
|
2325
|
-
|
|
2326
|
-
|
|
2447
|
+
// Defer whenever a fallback is ARMED and the pool has seats that
|
|
2448
|
+
// could be drained. WHICH provider can serve it is decided at the
|
|
2449
|
+
// dispatch below, not here, because answering that needs an await —
|
|
2450
|
+
// a Codex account's model list — and this selector is synchronous.
|
|
2451
|
+
//
|
|
2452
|
+
// Before v6.0.0 this also demanded `openaiBackend !== null && isOpenAI`,
|
|
2453
|
+
// which silently made the entire subscription failover unreachable:
|
|
2454
|
+
// an Anthropic-shape request, or a box with a Codex account and no
|
|
2455
|
+
// api-key backend, 503'd HERE, before the dispatcher ever ran. That is
|
|
2456
|
+
// exactly the deployment this release is about, so the feature was
|
|
2457
|
+
// dead in the configuration it was written for. Caught in review on
|
|
2458
|
+
// dario#1145 — the routing tests all passed, because none of them went
|
|
2459
|
+
// through this selector.
|
|
2460
|
+
//
|
|
2461
|
+
// An EMPTY pool still 503s: that's a setup error the operator needs to
|
|
2462
|
+
// see, not traffic to quietly re-bill somewhere else.
|
|
2463
|
+
const fallbackViable = poolFallbackModels.length > 0 && pool.size > 0;
|
|
2327
2464
|
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
|
-
}));
|
|
2465
|
+
writePoolUnavailable();
|
|
2345
2466
|
return false;
|
|
2346
2467
|
}
|
|
2347
2468
|
}
|
|
@@ -2462,6 +2583,65 @@ export async function startProxy(opts = {}) {
|
|
|
2462
2583
|
}
|
|
2463
2584
|
catch { /* not JSON — fall through */ }
|
|
2464
2585
|
}
|
|
2586
|
+
// Shadow compare (v6.0.0) — the full rationale is in compare.ts. Armed
|
|
2587
|
+
// per request by `x-dario-compare: <model>`, it runs the same prompt past
|
|
2588
|
+
// the other model family BESIDE the real answer and keeps both. Hooked in
|
|
2589
|
+
// here, ahead of routing, so it covers whichever provider ends up serving.
|
|
2590
|
+
//
|
|
2591
|
+
// Nothing below is allowed to depend on it: the tee only observes bytes
|
|
2592
|
+
// already on their way out, the comparison is never awaited by the
|
|
2593
|
+
// request, and every failure path resolves rather than throws.
|
|
2594
|
+
const compareTarget = readCompareTarget(req.headers);
|
|
2595
|
+
if (compareTarget) {
|
|
2596
|
+
const compareShape = isOpenAI ? 'openai' : 'anthropic';
|
|
2597
|
+
// Snapshot the body NOW. `body` is reassigned later by the codex→Claude
|
|
2598
|
+
// fall-through, and the finish callback below closes over it — so a
|
|
2599
|
+
// comparison on a request that failed over would record the SWAPPED
|
|
2600
|
+
// model as `primaryModel` and the swapped payload as the "verbatim"
|
|
2601
|
+
// request. That record would then quietly attribute the primary answer
|
|
2602
|
+
// to the wrong model family, which is worse than having no record: the
|
|
2603
|
+
// whole point of the log is deciding which family did better.
|
|
2604
|
+
const compareRequestBody = Buffer.from(body);
|
|
2605
|
+
const tee = teeResponse(res);
|
|
2606
|
+
res.setHeader(COMPARE_RESULT_HEADER, compareTarget);
|
|
2607
|
+
// Started before the primary is dispatched so the two overlap; holding
|
|
2608
|
+
// it until afterwards would double the wall-clock of a comparison
|
|
2609
|
+
// nobody is waiting on, for no benefit.
|
|
2610
|
+
const running = runCompare({
|
|
2611
|
+
body: compareRequestBody,
|
|
2612
|
+
shape: compareShape,
|
|
2613
|
+
targetModel: compareTarget,
|
|
2614
|
+
corsOrigin,
|
|
2615
|
+
timeoutMs: upstreamTimeoutMs,
|
|
2616
|
+
verbose,
|
|
2617
|
+
});
|
|
2618
|
+
res.on('finish', () => {
|
|
2619
|
+
void running.then((result) => {
|
|
2620
|
+
let request = null;
|
|
2621
|
+
try {
|
|
2622
|
+
request = JSON.parse(compareRequestBody.toString());
|
|
2623
|
+
}
|
|
2624
|
+
catch { /* recorded as null */ }
|
|
2625
|
+
const asObj = (request ?? {});
|
|
2626
|
+
const written = writeCompareRecord({
|
|
2627
|
+
ts: new Date().toISOString(),
|
|
2628
|
+
path: urlPath,
|
|
2629
|
+
shape: compareShape,
|
|
2630
|
+
streaming: asObj.stream === true,
|
|
2631
|
+
primaryModel: typeof asObj.model === 'string' ? asObj.model : '(unknown)',
|
|
2632
|
+
comparedModel: compareTarget,
|
|
2633
|
+
request,
|
|
2634
|
+
primary: tee.captured(),
|
|
2635
|
+
compare: result.side,
|
|
2636
|
+
...(result.skipped ? { skipped: result.skipped } : {}),
|
|
2637
|
+
});
|
|
2638
|
+
if (written)
|
|
2639
|
+
console.log(`[dario] compare vs ${compareTarget} -> ${written}`);
|
|
2640
|
+
else if (result.skipped)
|
|
2641
|
+
console.log(`[dario] compare vs ${compareTarget} skipped: ${result.skipped}`);
|
|
2642
|
+
});
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2465
2645
|
// Multi-provider routing (v3.6.0+). When an OpenAI-compat backend is
|
|
2466
2646
|
// configured and the request is on /v1/chat/completions with a
|
|
2467
2647
|
// GPT-family model (or a forced `openai:` prefix), forward it straight
|
|
@@ -2520,8 +2700,64 @@ export async function startProxy(opts = {}) {
|
|
|
2520
2700
|
console.log(`[dario] #${requestCount} ${req.method} ${urlPath} (model: ${rawModel}) → codex account ${codexCreds.alias}`);
|
|
2521
2701
|
}
|
|
2522
2702
|
requestCount++;
|
|
2523
|
-
|
|
2524
|
-
|
|
2703
|
+
// Symmetric failover (v6.0.0). When the chain nominates a model the
|
|
2704
|
+
// Claude pool can serve and there are seats to serve it, let the
|
|
2705
|
+
// subscription DECLINE a 429/5xx rather than pass it to the client,
|
|
2706
|
+
// and pick the request back up on the Claude path below. Before
|
|
2707
|
+
// this, a rate-limited ChatGPT plan was terminal for a gpt-bound
|
|
2708
|
+
// request even with an idle Claude pool sitting right beside it.
|
|
2709
|
+
const claudeTarget = pickClaudeFallback(poolFallbackModels, codexModels);
|
|
2710
|
+
const canDefer = claudeTarget !== null && pool.size > 0 && !upstreamApiKey;
|
|
2711
|
+
const codexReq = requestCount;
|
|
2712
|
+
const served = await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer,
|
|
2713
|
+
// Before this hook a codex request left no trace: nothing in
|
|
2714
|
+
// /analytics, nothing in the request log, no per-account count.
|
|
2715
|
+
// The dock (and anyone reading /analytics) saw a proxy that
|
|
2716
|
+
// served GPT all day and reported zero of it. A decline (the
|
|
2717
|
+
// request handed to the Claude pool) reports nothing here; the
|
|
2718
|
+
// Claude path records what it then serves.
|
|
2719
|
+
(o) => {
|
|
2720
|
+
codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
|
|
2721
|
+
analytics.record({
|
|
2722
|
+
timestamp: Date.now(),
|
|
2723
|
+
account: o.alias,
|
|
2724
|
+
model: o.model || rawModel || 'codex',
|
|
2725
|
+
inputTokens: o.inputTokens, outputTokens: o.outputTokens,
|
|
2726
|
+
cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
2727
|
+
// No Anthropic rate-limit headers on this path; the claim
|
|
2728
|
+
// names the engine and is subscription billing, so the
|
|
2729
|
+
// overage guard (#288) leaves it alone.
|
|
2730
|
+
claim: CODEX_CLAIM, util5h: 0, util7d: 0, overageUtil: 0,
|
|
2731
|
+
latencyMs: o.latencyMs, status: o.status, isStream: o.stream, isOpenAI,
|
|
2732
|
+
});
|
|
2733
|
+
writeLogLine(logFileStream, {
|
|
2734
|
+
ts: new Date().toISOString(), req: codexReq,
|
|
2735
|
+
method: req.method ?? '', path: urlPath, model: o.model || rawModel || undefined,
|
|
2736
|
+
status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
|
|
2737
|
+
claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, stream: o.stream,
|
|
2738
|
+
});
|
|
2739
|
+
});
|
|
2740
|
+
if (served)
|
|
2741
|
+
return;
|
|
2742
|
+
const swapped = buildPoolFallbackBody(body, claudeTarget);
|
|
2743
|
+
if (!swapped) {
|
|
2744
|
+
res.writeHead(503, { 'Content-Type': 'application/json', ...SECURITY_HEADERS });
|
|
2745
|
+
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.' } }));
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
console.log(`[dario] #${requestCount} codex unavailable -> claude pool as ${claudeTarget}`);
|
|
2749
|
+
// Copy so the type matches the ArrayBuffer-backed buffer this
|
|
2750
|
+
// handler threads through (Buffer.concat's), as the other in-place
|
|
2751
|
+
// body rewrites above already do.
|
|
2752
|
+
body = Buffer.from(swapped);
|
|
2753
|
+
// The Claude path prefers this cached parse over `body`. Leaving it
|
|
2754
|
+
// stale would send the OLD model upstream while every log line and
|
|
2755
|
+
// the response header claimed the swap happened — a failure that
|
|
2756
|
+
// reads as a success, which is the exact bug class this release
|
|
2757
|
+
// spent its whole review budget hunting.
|
|
2758
|
+
parsedBody = null;
|
|
2759
|
+
res.setHeader('x-dario-pool-fallback', claudeTarget);
|
|
2760
|
+
// fall through to Claude's turn below
|
|
2525
2761
|
}
|
|
2526
2762
|
if (rawModel && openaiBackend && decision.provider === 'openai') {
|
|
2527
2763
|
if (verbose) {
|
|
@@ -2548,14 +2784,20 @@ export async function startProxy(opts = {}) {
|
|
|
2548
2784
|
// response carries `x-dario-pool-fallback` — a substituted model must
|
|
2549
2785
|
// never be silent. GPT-bound requests never reach here (the routing
|
|
2550
2786
|
// block above already forwarded them; they don't need the pool).
|
|
2551
|
-
if (!upstreamApiKey && !poolAccount &&
|
|
2787
|
+
if (!upstreamApiKey && !poolAccount && await tryCodexPoolFallback(req, res, body, poolFallbackModels, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted')) {
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
// `isOpenAI` is REQUIRED here and was not, before v6.0.0 — the selector's
|
|
2791
|
+
// own isOpenAI check was the only thing keeping Anthropic-shape requests
|
|
2792
|
+
// out of this branch. Relaxing the selector without moving that guard down
|
|
2793
|
+
// would forward a /v1/messages request to an openai-compat backend and
|
|
2794
|
+
// hand the client an OpenAI-shaped response for a Messages request. This
|
|
2795
|
+
// route still has no reverse translation; the codex route above does,
|
|
2796
|
+
// which is why it takes both shapes and this one does not.
|
|
2797
|
+
if (!upstreamApiKey && !poolAccount && poolFallbackModel && openaiBackend && isOpenAI) {
|
|
2552
2798
|
const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
|
|
2553
2799
|
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
|
-
}));
|
|
2800
|
+
writePoolUnavailable();
|
|
2559
2801
|
return;
|
|
2560
2802
|
}
|
|
2561
2803
|
console.log(`[dario] #${requestCount} pool exhausted — /v1/chat/completions → ${openaiBackend.name} as ${poolFallbackModel}`);
|
|
@@ -2563,6 +2805,17 @@ export async function startProxy(opts = {}) {
|
|
|
2563
2805
|
await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, upstreamTimeoutMs, verbose);
|
|
2564
2806
|
return;
|
|
2565
2807
|
}
|
|
2808
|
+
// Deferred at selection because a fallback was armed, but nothing could
|
|
2809
|
+
// actually serve it — no Codex account lists the model, and either there
|
|
2810
|
+
// is no api-key backend or this is the wrong wire shape for one. Answer
|
|
2811
|
+
// with the truth the selector used to give. Without this the request
|
|
2812
|
+
// would fall through to the Claude path with no account and an empty
|
|
2813
|
+
// bearer token, turning a clean 503 into a confusing upstream 401.
|
|
2814
|
+
if (!upstreamApiKey && !poolAccount) {
|
|
2815
|
+
console.log(`[dario] #${requestCount} pool exhausted and no fallback provider could serve ${poolFallbackModels.join(', ') || '(none configured)'}`);
|
|
2816
|
+
writePoolUnavailable();
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2566
2819
|
// Parse body once, apply OpenAI translation, model override, and sanitization
|
|
2567
2820
|
let finalBody = body.length > 0 ? body : undefined;
|
|
2568
2821
|
let ccToolMap = null;
|
|
@@ -3429,6 +3682,9 @@ export async function startProxy(opts = {}) {
|
|
|
3429
3682
|
// bytes — the Anthropic translation went into finalBody, never
|
|
3430
3683
|
// back into body. Marked via x-dario-pool-fallback, same as the
|
|
3431
3684
|
// selection-time path.
|
|
3685
|
+
if (await tryCodexPoolFallback(req, res, body, poolFallbackModels, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)')) {
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
3432
3688
|
if (isOpenAI && poolFallbackModel && openaiBackend) {
|
|
3433
3689
|
const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
|
|
3434
3690
|
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.1",
|
|
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": {
|