@askalf/dario 6.15.0 → 6.17.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 +19 -21
- package/dist/analytics-ui.html +835 -0
- package/dist/analytics.d.ts +26 -1
- package/dist/analytics.js +51 -3
- package/dist/body-identity.d.ts +25 -0
- package/dist/body-identity.js +68 -0
- package/dist/cc-template.js +3 -14
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +44 -7
- package/dist/codex-backend.js +12 -4
- package/dist/config-file.d.ts +1 -1
- package/dist/config-file.js +2 -2
- package/dist/credits-rung.d.ts +72 -0
- package/dist/credits-rung.js +133 -0
- package/dist/doctor-core.d.ts +2 -0
- package/dist/doctor-core.js +47 -10
- package/dist/donuts.d.ts +18 -17
- package/dist/donuts.js +46 -54
- package/dist/ledger.d.ts +20 -0
- package/dist/ledger.js +40 -1
- package/dist/metrics.js +2 -1
- package/dist/midstream.js +6 -2
- package/dist/openai-backend.js +8 -3
- package/dist/pool.d.ts +18 -1
- package/dist/pool.js +31 -10
- package/dist/proxy.d.ts +14 -1
- package/dist/proxy.js +351 -105
- package/dist/relaunch-signals.d.ts +35 -0
- package/dist/relaunch-signals.js +41 -0
- package/dist/stream-drain.d.ts +4 -3
- package/dist/stream-drain.js +4 -3
- package/dist/tui/tabs/config.js +2 -2
- package/dist/upstream-deadline.d.ts +25 -0
- package/dist/upstream-deadline.js +76 -0
- package/docs/analytics.md +10 -8
- package/docs/api-equivalent-spend.md +9 -2
- package/docs/commands.md +1 -1
- package/docs/configuration.md +1 -0
- package/docs/faq.md +5 -0
- package/docs/multi-account-pool.md +3 -1
- package/docs/pool-keys-and-analytics.md +2 -0
- package/docs/two-plans.md +2 -0
- package/docs/who-its-for.md +2 -2
- package/package.json +8 -4
package/dist/proxy.js
CHANGED
|
@@ -13,15 +13,16 @@ import { getServingProbe } from './serving-probe.js';
|
|
|
13
13
|
import { darioVersion } from './version.js';
|
|
14
14
|
import { CC_TOOL_DEFINITIONS_UNADVERTISABLE, CC_TEMPLATE_PROMPT_BYTES, resolveMaxTokens, buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, UnsupportedClientToolError, CLIENT_TOOL_MCP_PREFIX, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
|
|
15
15
|
import { stampCch, hasCchSeed } from './cch.js';
|
|
16
|
+
import { rebindBodyIdentity } from './body-identity.js';
|
|
16
17
|
import { foldTiming, timingHeaders, timingLogFields } from './timing.js';
|
|
17
18
|
import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
|
|
18
19
|
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, activeParkedBuckets, reconcilePoolAccounts, resolvePoolStrategy, resolvePoolHeadroomFloor, parsePoolHeadroomFloor, describePoolStrategy, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail } from './pool.js';
|
|
19
20
|
import { backfillIdentity } from './accounts.js';
|
|
20
21
|
import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
|
|
21
|
-
import { Analytics, billingBucketFromClaim, costOfTokensFailClosed, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
|
|
22
|
+
import { Analytics, billingBucketFromClaim, costOfTokensFailClosed, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM, CREDITS_CLAIM } from './analytics.js';
|
|
22
23
|
import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
|
|
23
24
|
import { renderPrometheus } from './metrics.js';
|
|
24
|
-
import { renderSpendDonuts, renderAnalyticsView,
|
|
25
|
+
import { renderSpendDonuts, renderAnalyticsView, analyticsUiPage } from './donuts.js';
|
|
25
26
|
import { KeyStore, keyAllowsModel, resolveKeysPath, looksLikeNamedKey, budgetVerdict, budgetHeaders, requestBudgetReservation, addReservation, subtractReservation, EMPTY_RESERVATION } from './keys.js';
|
|
26
27
|
import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
|
|
27
28
|
import { notify as osNotify } from './notify.js';
|
|
@@ -55,10 +56,12 @@ import { codexUsageView, resolveCodexUsagePollMs } from './codex-usage.js';
|
|
|
55
56
|
import { seedStaleCodexUsage } from './codex-backend.js';
|
|
56
57
|
import { route as routeProvider } from './provider-adapter.js';
|
|
57
58
|
import { selectPoolFallbackModels } from './pool-fallback-tier.js';
|
|
59
|
+
import { ACCOUNT_KEY_CREDITS, creditsBeforePlain, creditsEntries, creditsFallbackMarker, creditsHeaders, describeCreditsRung, pickCreditsTarget, withoutCreditsEntries } from './credits-rung.js';
|
|
58
60
|
import { parseFamilyFallback, familyFallbackModel, describeFamilyFallback } from './family-fallback.js';
|
|
59
61
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS, resolveMaxConcurrent } from './request-queue.js';
|
|
60
62
|
import { waitForIdle, drainThenClose, shutdownTimers, DEFAULT_SHUTDOWN_GRACE_MS } from './shutdown-drain.js';
|
|
61
63
|
import { clampTimerMs } from './timer-ms.js';
|
|
64
|
+
import { upstreamDeadline } from './upstream-deadline.js';
|
|
62
65
|
import { redactSecrets } from './redact.js';
|
|
63
66
|
import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, retryModelCatalogNow, isSuspendedModel } from './model-catalog.js';
|
|
64
67
|
import { classifyUpstreamRejection, diagnosticSnippet, POOL_PARKED } from './upstream-rejection.js';
|
|
@@ -66,7 +69,9 @@ import { ProviderCooldowns, canAttempt, allProvidersCooled, cooldownRetryAfterMs
|
|
|
66
69
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
67
70
|
const DEFAULT_PORT = 3456;
|
|
68
71
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts, prevents abuse
|
|
69
|
-
|
|
72
|
+
// Until the response streams: the whole wait. While a 2xx streams: the longest
|
|
73
|
+
// gap between two chunks (src/upstream-deadline.ts). 5 min, as in the Anthropic SDK.
|
|
74
|
+
const UPSTREAM_TIMEOUT_MS = 300_000;
|
|
70
75
|
const BODY_READ_TIMEOUT_MS = 30_000; // 30s — prevents slow-loris on body reads
|
|
71
76
|
const DEFAULT_HOST = '127.0.0.1';
|
|
72
77
|
// A host is "loopback" if it's one of the well-known localhost literals.
|
|
@@ -1345,6 +1350,9 @@ export async function startProxy(opts = {}) {
|
|
|
1345
1350
|
const upstreamFetch = chaosCut && chaosState ? chaosCutFetch(opts.fetchImpl ?? fetch, chaosCut, chaosState) : (opts.fetchImpl ?? fetch);
|
|
1346
1351
|
const codexFetch = chaosCut && chaosState ? chaosCutFetch(fetch, chaosCut, chaosState) : fetch;
|
|
1347
1352
|
const upstreamApiKey = (opts.upstreamApiKey ?? process.env.ANTHROPIC_UPSTREAM_API_KEY ?? '').trim();
|
|
1353
|
+
// The credits rung's key (credits-rung.ts). Read once here; whether it is
|
|
1354
|
+
// ever used depends on the chain naming a `credits:<model>` entry.
|
|
1355
|
+
const creditsApiKey = (opts.creditsApiKey ?? process.env.DARIO_CREDITS_API_KEY ?? '').trim();
|
|
1348
1356
|
if (upstreamApiKey)
|
|
1349
1357
|
console.error('[dario] upstream auth: per-token API key (x-api-key) — OAuth/Max + account pool bypassed');
|
|
1350
1358
|
else if (ignoreCcCredentials())
|
|
@@ -1551,17 +1559,37 @@ export async function startProxy(opts = {}) {
|
|
|
1551
1559
|
}
|
|
1552
1560
|
};
|
|
1553
1561
|
if (poolFallbackModel) {
|
|
1562
|
+
// The rungs in the order they are asked: the subscription first, then the
|
|
1563
|
+
// metered rungs (the credits key and the openai-compat backend) in the
|
|
1564
|
+
// chain's own order. A `credits:` entry is not a model the other rungs
|
|
1565
|
+
// can serve, so their model is the first plain entry.
|
|
1566
|
+
const plainFallbackModel = withoutCreditsEntries(poolFallbackModels)[0] ?? null;
|
|
1567
|
+
const creditsLine = describeCreditsRung(poolFallbackModels, creditsApiKey.length > 0);
|
|
1568
|
+
const creditsArmed = creditsLine !== null && creditsApiKey.length > 0;
|
|
1554
1569
|
const targets = [];
|
|
1555
|
-
if (startupCodexAliases.length > 0)
|
|
1556
|
-
targets.push(`codex subscription (${startupCodexAliases.join(', ')})
|
|
1557
|
-
|
|
1558
|
-
|
|
1570
|
+
if (startupCodexAliases.length > 0 && plainFallbackModel)
|
|
1571
|
+
targets.push(`codex subscription (${startupCodexAliases.join(', ')}) as ${plainFallbackModel}, both wire shapes`);
|
|
1572
|
+
const metered = [];
|
|
1573
|
+
if (openaiBackend && plainFallbackModel)
|
|
1574
|
+
metered.push(`${openaiBackend.name} as ${plainFallbackModel}, OpenAI shape only`);
|
|
1575
|
+
if (creditsArmed) {
|
|
1576
|
+
if (creditsBeforePlain(poolFallbackModels))
|
|
1577
|
+
metered.unshift(creditsLine);
|
|
1578
|
+
else
|
|
1579
|
+
metered.push(creditsLine);
|
|
1580
|
+
}
|
|
1581
|
+
targets.push(...metered);
|
|
1559
1582
|
if (targets.length > 0) {
|
|
1560
|
-
console.log(` Pool fallback: exhausted-pool requests → ${targets.join(', then ')}
|
|
1583
|
+
console.log(` Pool fallback: exhausted-pool requests → ${targets.join(', then ')} (marked x-dario-pool-fallback)`);
|
|
1561
1584
|
}
|
|
1562
1585
|
else {
|
|
1563
|
-
console.warn('[dario] --pool-fallback is set but there is nothing to fall back TO
|
|
1586
|
+
console.warn('[dario] --pool-fallback is set but there is nothing to fall back TO: add a Codex account (`dario codex add …`), an OpenAI-compat backend (`dario backend add …`), or a credits:<model> entry with DARIO_CREDITS_API_KEY set. Fallback is inert.');
|
|
1564
1587
|
}
|
|
1588
|
+
if (creditsLine && !creditsArmed)
|
|
1589
|
+
console.warn(`[dario] --pool-fallback ${creditsLine}`);
|
|
1590
|
+
}
|
|
1591
|
+
else if (creditsApiKey) {
|
|
1592
|
+
console.warn('[dario] DARIO_CREDITS_API_KEY is set but no --pool-fallback entry names credits:<model>, so nothing spends it');
|
|
1565
1593
|
}
|
|
1566
1594
|
// Family fallback (strictly opt-in, family-fallback.ts). Bad entries are
|
|
1567
1595
|
// dropped with a warning; the rest still apply.
|
|
@@ -2475,7 +2503,9 @@ export async function startProxy(opts = {}) {
|
|
|
2475
2503
|
//
|
|
2476
2504
|
// With NO next option and no peer, do not defer: the real upstream error is
|
|
2477
2505
|
// more useful to the client than replacing it with a generic 503.
|
|
2478
|
-
|
|
2506
|
+
// An armed credits rung is a next option on both wire shapes.
|
|
2507
|
+
const hasNextOption = (openaiBackend !== null && shape === 'openai')
|
|
2508
|
+
|| (creditsApiKey.length > 0 && creditsEntries(fallbackModels).length > 0);
|
|
2479
2509
|
// The next seat that could serve one of these fallback models, excluding
|
|
2480
2510
|
// everything already tried. `peek` is the cached read, so the scan costs no
|
|
2481
2511
|
// upstream call; a seat whose model list is unknown is still worth a try.
|
|
@@ -2573,21 +2603,37 @@ export async function startProxy(opts = {}) {
|
|
|
2573
2603
|
* bug structurally impossible to reintroduce: a future third call site gets
|
|
2574
2604
|
* this by construction, not by remembering to copy six lines correctly.
|
|
2575
2605
|
*/
|
|
2576
|
-
const attemptPoolFallbackOn429 = async (req, res, body, isOpenAI, attempted, timing
|
|
2577
|
-
|
|
2578
|
-
|
|
2606
|
+
const attemptPoolFallbackOn429 = async (req, res, body, isOpenAI, attempted, timing,
|
|
2607
|
+
/**
|
|
2608
|
+
* Re-arm THIS request on the credits rung (credits-rung.ts): returns true
|
|
2609
|
+
* once the request is re-pointed at the key, and the caller re-enters its
|
|
2610
|
+
* dispatch loop. Absent on a site that cannot re-dispatch.
|
|
2611
|
+
*/
|
|
2612
|
+
armCredits) => {
|
|
2613
|
+
const chain = selectPoolFallbackForBody(body);
|
|
2614
|
+
if (await tryCodexPoolFallback(req, res, body, chain, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)', attempted, timing)) {
|
|
2615
|
+
return 'served';
|
|
2579
2616
|
}
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2617
|
+
// The metered rungs, in the chain's own order. The credits rung is never
|
|
2618
|
+
// asked twice in one request: a 429 from the key falls through to the
|
|
2619
|
+
// openai-compat backend or to the honest 429.
|
|
2620
|
+
const plainModel = withoutCreditsEntries(chain)[0] ?? null;
|
|
2621
|
+
const creditsFirst = creditsBeforePlain(chain);
|
|
2622
|
+
const tryCredits = () => armCredits !== undefined && !attempted.has('credits') && armCredits();
|
|
2623
|
+
if (creditsFirst && tryCredits())
|
|
2624
|
+
return 'credits';
|
|
2625
|
+
if (isOpenAI && plainModel && openaiBackend) {
|
|
2626
|
+
const fallbackBody = buildPoolFallbackBody(body, plainModel);
|
|
2583
2627
|
if (fallbackBody) {
|
|
2584
|
-
console.log(`[dario] #${requestCount} pool exhausted mid-flight (429, no peer) → ${openaiBackend.name} as ${
|
|
2628
|
+
console.log(`[dario] #${requestCount} pool exhausted mid-flight (429, no peer) → ${openaiBackend.name} as ${plainModel}`);
|
|
2585
2629
|
requestCount++;
|
|
2586
|
-
await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback':
|
|
2587
|
-
return
|
|
2630
|
+
await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': plainModel }, upstreamTimeoutMs, verbose);
|
|
2631
|
+
return 'served';
|
|
2588
2632
|
}
|
|
2589
2633
|
}
|
|
2590
|
-
|
|
2634
|
+
if (!creditsFirst && tryCredits())
|
|
2635
|
+
return 'credits';
|
|
2636
|
+
return 'none';
|
|
2591
2637
|
};
|
|
2592
2638
|
const server = createServer(async (req, res) => {
|
|
2593
2639
|
// The request's first stamp (src/timing.ts): every split below is
|
|
@@ -2825,11 +2871,12 @@ export async function startProxy(opts = {}) {
|
|
|
2825
2871
|
if (handled)
|
|
2826
2872
|
return;
|
|
2827
2873
|
}
|
|
2828
|
-
// The dashboard
|
|
2829
|
-
//
|
|
2874
|
+
// The dashboard page carries no data, so it needs no credential: it asks
|
|
2875
|
+
// for the token, then reads /analytics, /accounts, /codex, /health, the
|
|
2876
|
+
// ledger and the keys with it and tails /analytics/stream (donuts.ts).
|
|
2830
2877
|
if (urlPath === '/analytics/ui' && req.method === 'GET') {
|
|
2831
2878
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS });
|
|
2832
|
-
res.end(
|
|
2879
|
+
res.end(analyticsUiPage());
|
|
2833
2880
|
return;
|
|
2834
2881
|
}
|
|
2835
2882
|
// Read-only analytics token: accepted on the read-only surfaces only, and
|
|
@@ -3009,7 +3056,11 @@ export async function startProxy(opts = {}) {
|
|
|
3009
3056
|
// `queue` rides along the summary (dario#905): request-queue.ts always
|
|
3010
3057
|
// documented snapshot() as "exposed for /analytics", but it was never
|
|
3011
3058
|
// actually wired in, so slot exhaustion was invisible from outside.
|
|
3012
|
-
|
|
3059
|
+
// `overage` is the halt state for every reader of this route (the
|
|
3060
|
+
// dashboard, dario#1734): the stream replays a halt only while one is
|
|
3061
|
+
// in force and the analytics token cannot read /admin/resume, so a
|
|
3062
|
+
// viewer that saw the halt and missed the resume resyncs from here.
|
|
3063
|
+
res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), overage: { halted: overageGuard.isHalted(), state: overageGuard.state() }, lifetime: ledger ? ledger.summary() : null, budgets: keyBudgetsSnapshot() }));
|
|
3013
3064
|
return;
|
|
3014
3065
|
}
|
|
3015
3066
|
// Prometheus text exposition of the same state (dario#1341). A view, not
|
|
@@ -3048,7 +3099,9 @@ export async function startProxy(opts = {}) {
|
|
|
3048
3099
|
return;
|
|
3049
3100
|
}
|
|
3050
3101
|
// The ledger's per-day table, for anyone charting it. `lifetime` on
|
|
3051
|
-
// /analytics is the summary; this is the data behind it.
|
|
3102
|
+
// /analytics is the summary; this is the data behind it. `priced` is the
|
|
3103
|
+
// same rows as USD at each day's rate (ledger.ts), so a reader, the
|
|
3104
|
+
// dashboard first, never needs a price list of its own.
|
|
3052
3105
|
if (urlPath === '/analytics/ledger' && req.method === 'GET') {
|
|
3053
3106
|
if (!ledger) {
|
|
3054
3107
|
res.writeHead(404, JSON_HEADERS);
|
|
@@ -3056,7 +3109,7 @@ export async function startProxy(opts = {}) {
|
|
|
3056
3109
|
return;
|
|
3057
3110
|
}
|
|
3058
3111
|
res.writeHead(200, JSON_HEADERS);
|
|
3059
|
-
res.end(JSON.stringify({ path: ledger.path, ...ledger.snapshot() }));
|
|
3112
|
+
res.end(JSON.stringify({ path: ledger.path, ...ledger.snapshot(), priced: ledger.priced() }));
|
|
3060
3113
|
return;
|
|
3061
3114
|
}
|
|
3062
3115
|
// Analytics live stream — SSE of new RequestRecord JSON, one event
|
|
@@ -3368,6 +3421,17 @@ export async function startProxy(opts = {}) {
|
|
|
3368
3421
|
// form gives the declared type without seeding a narrowing.
|
|
3369
3422
|
let poolAccount = null;
|
|
3370
3423
|
let accessToken = '';
|
|
3424
|
+
// Set when the credits rung (credits-rung.ts) is serving this request:
|
|
3425
|
+
// the Claude path then runs on the key instead of a seat. The same `as`
|
|
3426
|
+
// form as poolAccount, for the same narrowing reason.
|
|
3427
|
+
let creditsRung = null;
|
|
3428
|
+
// The account an analytics record names: the seat, the credits rung, or
|
|
3429
|
+
// the api-key slot. A function because a mid-flight 429 can move the
|
|
3430
|
+
// request from a seat to the credits rung after the first attempt.
|
|
3431
|
+
const accountKeyNow = () => creditsRung ? ACCOUNT_KEY_CREDITS : (poolAccount?.alias ?? ACCOUNT_KEY_APIKEY);
|
|
3432
|
+
// The claim a record carries: Anthropic sends none on an API key
|
|
3433
|
+
// request, so the credits rung stamps its own, as the codex engine does.
|
|
3434
|
+
const claimNow = (rl) => creditsRung ? CREDITS_CLAIM : rl.claim;
|
|
3371
3435
|
// Providers this ONE request has already asked. Paired with the
|
|
3372
3436
|
// process-wide cool-downs: the set stops a revisit inside the request,
|
|
3373
3437
|
// the cool-downs stop the next request repeating the discovery.
|
|
@@ -3571,7 +3635,11 @@ export async function startProxy(opts = {}) {
|
|
|
3571
3635
|
//
|
|
3572
3636
|
// An EMPTY pool still 503s: that's a setup error the operator needs to
|
|
3573
3637
|
// see, not traffic to quietly re-bill somewhere else.
|
|
3574
|
-
|
|
3638
|
+
// A chain whose only entries are credits rungs without the key is
|
|
3639
|
+
// inert and defers nothing: the honest 429/503 stands.
|
|
3640
|
+
const chainCanServe = withoutCreditsEntries(poolFallbackModels).length > 0
|
|
3641
|
+
|| (creditsApiKey.length > 0 && creditsEntries(poolFallbackModels).length > 0);
|
|
3642
|
+
const fallbackViable = chainCanServe && pool.size > 0;
|
|
3575
3643
|
if (!fallbackViable) {
|
|
3576
3644
|
if (parkedUntil !== null)
|
|
3577
3645
|
writePoolParked(parkedUntil);
|
|
@@ -3697,7 +3765,9 @@ export async function startProxy(opts = {}) {
|
|
|
3697
3765
|
const onClose = () => { if (!res.writableEnded)
|
|
3698
3766
|
abort.abort(); };
|
|
3699
3767
|
res.on('close', onClose);
|
|
3700
|
-
|
|
3768
|
+
// The loopback leg is timed like the request it replaces: until
|
|
3769
|
+
// headers, then by the gap between chunks of a 2xx body.
|
|
3770
|
+
const timer = upstreamDeadline(upstreamTimeoutMs, () => abort.abort());
|
|
3701
3771
|
try {
|
|
3702
3772
|
const leg = await fetch(`${loopbackBase}${isOpenAI ? '/v1/chat/completions' : '/v1/messages'}`, {
|
|
3703
3773
|
method: 'POST',
|
|
@@ -3714,9 +3784,12 @@ export async function startProxy(opts = {}) {
|
|
|
3714
3784
|
responseHeaders['x-dario-family-fallback'] = fromModel;
|
|
3715
3785
|
res.writeHead(leg.status, responseHeaders);
|
|
3716
3786
|
if (leg.body) {
|
|
3787
|
+
if (leg.ok)
|
|
3788
|
+
timer.streaming();
|
|
3717
3789
|
const reader = leg.body.getReader();
|
|
3718
3790
|
while (true) {
|
|
3719
3791
|
const { done, value } = await reader.read();
|
|
3792
|
+
timer.touch();
|
|
3720
3793
|
if (done)
|
|
3721
3794
|
break;
|
|
3722
3795
|
if (!res.write(value))
|
|
@@ -3737,7 +3810,7 @@ export async function startProxy(opts = {}) {
|
|
|
3737
3810
|
}
|
|
3738
3811
|
}
|
|
3739
3812
|
finally {
|
|
3740
|
-
|
|
3813
|
+
timer.clear();
|
|
3741
3814
|
res.off('close', onClose);
|
|
3742
3815
|
}
|
|
3743
3816
|
return true;
|
|
@@ -4547,6 +4620,30 @@ export async function startProxy(opts = {}) {
|
|
|
4547
4620
|
}
|
|
4548
4621
|
if (!selectPoolAccount())
|
|
4549
4622
|
return;
|
|
4623
|
+
// The credits rung (credits-rung.ts): re-point THIS request at the key
|
|
4624
|
+
// as the first `credits:<model>` entry the catalog can serve. False when
|
|
4625
|
+
// the chain names no such entry, the key is unset, the request is pinned
|
|
4626
|
+
// to a seat, or the rung already answered this request. The response is
|
|
4627
|
+
// marked here, before any byte of the answer, and the Claude path below
|
|
4628
|
+
// then runs on the key: `x-api-key`, the client's own body, no template.
|
|
4629
|
+
const armCreditsRung = (why) => {
|
|
4630
|
+
if (!creditsApiKey || creditsRung || pinnedAccount || attemptedProviders.has('credits'))
|
|
4631
|
+
return false;
|
|
4632
|
+
const target = pickCreditsTarget(selectPoolFallbackForBody(body), getCachedBases(), (m) => resolveClaudeAlias(applyModelAlias(m, modelAliases) ?? m));
|
|
4633
|
+
if (!target)
|
|
4634
|
+
return false;
|
|
4635
|
+
attemptedProviders.add('credits');
|
|
4636
|
+
creditsRung = target;
|
|
4637
|
+
poolAccount = null;
|
|
4638
|
+
accessToken = '';
|
|
4639
|
+
requestModel = target.model;
|
|
4640
|
+
requestFamily = modelFamily(target.model);
|
|
4641
|
+
if (target.effort)
|
|
4642
|
+
requestEffort = target.effort;
|
|
4643
|
+
res.setHeader('x-dario-pool-fallback', creditsFallbackMarker(target.model));
|
|
4644
|
+
console.log(`[dario] #${requestCount} ${why} → API credits as ${target.model}${target.effort ? ` (effort: ${target.effort})` : ''} (x-api-key)`);
|
|
4645
|
+
return true;
|
|
4646
|
+
};
|
|
4550
4647
|
// Pool-exhausted fallback dispatch. In OAuth mode poolAccount can only
|
|
4551
4648
|
// be null here when the selection above deferred to this path (armed
|
|
4552
4649
|
// fallback + drained pool + OpenAI-shape request): swap the model and
|
|
@@ -4574,19 +4671,34 @@ export async function startProxy(opts = {}) {
|
|
|
4574
4671
|
// that was ~2% of dario's own CPU on every request that never took the
|
|
4575
4672
|
// branch (scripts/bench-overhead.mjs profile, v6.9.2). Resolved only
|
|
4576
4673
|
// when the branch can be taken.
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4674
|
+
//
|
|
4675
|
+
// The metered rungs come after the subscription, in the chain's own
|
|
4676
|
+
// order: the credits rung (both wire shapes, since it rides the Claude
|
|
4677
|
+
// path) and the openai-compat backend (OpenAI shape only). A `credits:`
|
|
4678
|
+
// entry is no model for that backend, so its model is the first plain
|
|
4679
|
+
// entry.
|
|
4680
|
+
if (!upstreamApiKey && !poolAccount && !pinnedAccount) {
|
|
4681
|
+
const chainForBody = selectPoolFallbackForBody(body);
|
|
4682
|
+
const plainModel = withoutCreditsEntries(chainForBody)[0] ?? null;
|
|
4683
|
+
const openaiCanServe = openaiBackend !== null && isOpenAI && plainModel !== null;
|
|
4684
|
+
const creditsFirst = creditsBeforePlain(chainForBody);
|
|
4685
|
+
if (creditsFirst && armCreditsRung('pool exhausted')) {
|
|
4686
|
+
// Served below, on the key.
|
|
4687
|
+
}
|
|
4688
|
+
else if (openaiCanServe) {
|
|
4689
|
+
const fallbackBody = buildPoolFallbackBody(body, plainModel);
|
|
4690
|
+
if (!fallbackBody) {
|
|
4691
|
+
writePoolUnavailable();
|
|
4692
|
+
return;
|
|
4693
|
+
}
|
|
4694
|
+
console.log(`[dario] #${requestCount} pool exhausted, /v1/chat/completions → ${openaiBackend.name} as ${plainModel}`);
|
|
4695
|
+
requestCount++;
|
|
4696
|
+
await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': plainModel }, upstreamTimeoutMs, verbose);
|
|
4584
4697
|
return;
|
|
4585
4698
|
}
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
return;
|
|
4699
|
+
else if (!creditsFirst) {
|
|
4700
|
+
armCreditsRung('pool exhausted');
|
|
4701
|
+
}
|
|
4590
4702
|
}
|
|
4591
4703
|
// Deferred at selection because a fallback was armed, but nothing could
|
|
4592
4704
|
// actually serve it — no Codex account lists the model, and either there
|
|
@@ -4594,7 +4706,7 @@ export async function startProxy(opts = {}) {
|
|
|
4594
4706
|
// with the truth the selector used to give. Without this the request
|
|
4595
4707
|
// would fall through to the Claude path with no account and an empty
|
|
4596
4708
|
// bearer token, turning a clean 503 into a confusing upstream 401.
|
|
4597
|
-
if (!upstreamApiKey && !poolAccount) {
|
|
4709
|
+
if (!upstreamApiKey && !poolAccount && !creditsRung) {
|
|
4598
4710
|
// A fallback was armed but nothing could serve, and the pool itself is
|
|
4599
4711
|
// parked: the exact reset beats a cool-down estimate (dario#1244).
|
|
4600
4712
|
const parkedNow = pool.parkedUntil(Date.now(), requestFamily);
|
|
@@ -4631,6 +4743,10 @@ export async function startProxy(opts = {}) {
|
|
|
4631
4743
|
// Outbound session id resolved once — either inside the template build
|
|
4632
4744
|
// (so body metadata matches) or below for passthrough (no body build).
|
|
4633
4745
|
let preBodySessionId;
|
|
4746
|
+
// True once the template build has written dario's metadata.user_id
|
|
4747
|
+
// into finalBody. Only such a body names the selected account, so only
|
|
4748
|
+
// it is rebound when a failover switches accounts (see switchAccount).
|
|
4749
|
+
let bodyIdentityOwned = false;
|
|
4634
4750
|
// Request context for hybrid-mode field injection (#33). Built once
|
|
4635
4751
|
// per request from incoming headers so the reverse mapper can fill
|
|
4636
4752
|
// client-declared fields like `sessionId` that CC's schema doesn't
|
|
@@ -4645,7 +4761,49 @@ export async function startProxy(opts = {}) {
|
|
|
4645
4761
|
userId: req.headers['x-user-id'],
|
|
4646
4762
|
timestamp: new Date().toISOString(),
|
|
4647
4763
|
} : undefined;
|
|
4648
|
-
|
|
4764
|
+
// The credits rung sends the client's own body as a plain API request:
|
|
4765
|
+
// sanitized, translated when OpenAI-shaped, the model swapped for the
|
|
4766
|
+
// rung's, the effort the entry named applied when the client set none.
|
|
4767
|
+
// No Claude Code template, identity or cch stamp: those make a request
|
|
4768
|
+
// look like Claude Code's for the OAuth gate, and an API key request
|
|
4769
|
+
// would be billed for every byte of them for nothing.
|
|
4770
|
+
const thinCreditsBody = (target) => {
|
|
4771
|
+
try {
|
|
4772
|
+
const parsed = JSON.parse(body.toString());
|
|
4773
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
4774
|
+
return null;
|
|
4775
|
+
const client = parsed;
|
|
4776
|
+
sanitizeMessages(client, opts.preserveOrchestrationTags);
|
|
4777
|
+
const r = (isOpenAI ? openaiToAnthropic(client, target.model) : { ...client, model: target.model });
|
|
4778
|
+
r.model = stripContext1mTag(typeof r.model === 'string' ? r.model : target.model);
|
|
4779
|
+
const effort = requestEffort ?? opts.effort;
|
|
4780
|
+
if (effort) {
|
|
4781
|
+
const oc = (r.output_config && typeof r.output_config === 'object' && !Array.isArray(r.output_config))
|
|
4782
|
+
? { ...r.output_config }
|
|
4783
|
+
: {};
|
|
4784
|
+
if (typeof oc.effort !== 'string') {
|
|
4785
|
+
oc.effort = effort;
|
|
4786
|
+
r.output_config = oc;
|
|
4787
|
+
}
|
|
4788
|
+
}
|
|
4789
|
+
return Buffer.from(JSON.stringify(r));
|
|
4790
|
+
}
|
|
4791
|
+
catch {
|
|
4792
|
+
return null;
|
|
4793
|
+
}
|
|
4794
|
+
};
|
|
4795
|
+
if (creditsRung) {
|
|
4796
|
+
const thin = thinCreditsBody(creditsRung);
|
|
4797
|
+
if (!thin) {
|
|
4798
|
+
console.log(`[dario] #${requestCount} credits rung: the request body could not be re-pointed at ${creditsRung.model}`);
|
|
4799
|
+
writePoolUnavailable();
|
|
4800
|
+
return;
|
|
4801
|
+
}
|
|
4802
|
+
finalBody = thin;
|
|
4803
|
+
// The client's own tool names go out and come back untouched.
|
|
4804
|
+
preserveToolsEffective = true;
|
|
4805
|
+
}
|
|
4806
|
+
else if (body.length > 0) {
|
|
4649
4807
|
try {
|
|
4650
4808
|
// Reuse the object parsed for provider-prefix detection above — the same
|
|
4651
4809
|
// bytes, so re-parsing is wasted work on large bodies (#642-audit). Falls
|
|
@@ -4876,6 +5034,7 @@ export async function startProxy(opts = {}) {
|
|
|
4876
5034
|
for (const key of Object.keys(r))
|
|
4877
5035
|
delete r[key];
|
|
4878
5036
|
Object.assign(r, ccBody);
|
|
5037
|
+
bodyIdentityOwned = true;
|
|
4879
5038
|
// `X[1m]` is a client-side LABEL, never a valid wire id — real CC
|
|
4880
5039
|
// sends the BASE model id plus the `context-1m-2025-08-07` beta
|
|
4881
5040
|
// (live capture 2026-06-09, CC v2.1.170 with --model
|
|
@@ -4964,7 +5123,12 @@ export async function startProxy(opts = {}) {
|
|
|
4964
5123
|
// Beta headers
|
|
4965
5124
|
const clientBeta = req.headers['anthropic-beta'];
|
|
4966
5125
|
let beta;
|
|
4967
|
-
if (
|
|
5126
|
+
if (creditsRung) {
|
|
5127
|
+
// A plain API request: the client's betas minus the OAuth flag go out
|
|
5128
|
+
// in creditsHeaders below; nothing of the template's set applies.
|
|
5129
|
+
beta = '';
|
|
5130
|
+
}
|
|
5131
|
+
else if (passthrough || isCountTokens) {
|
|
4968
5132
|
// Passthrough (and thin count_tokens): only add oauth beta,
|
|
4969
5133
|
// forward client betas as-is — no template beta set.
|
|
4970
5134
|
beta = 'oauth-2025-04-20';
|
|
@@ -4978,7 +5142,7 @@ export async function startProxy(opts = {}) {
|
|
|
4978
5142
|
// context-1m requires Extra Usage — if it 400s, we auto-retry without
|
|
4979
5143
|
// it, and cache the rejection so subsequent requests on this account
|
|
4980
5144
|
// skip context-1m entirely (dario#36).
|
|
4981
|
-
const acctKey =
|
|
5145
|
+
const acctKey = accountKeyNow();
|
|
4982
5146
|
const skipContext1m = context1mUnavailable.has(acctKey);
|
|
4983
5147
|
// Model-conditional betas (fable fallback-credit; [1m] context-1m),
|
|
4984
5148
|
// mirroring real CC — see betaForModel. betaWithoutContext1m still
|
|
@@ -5036,7 +5200,7 @@ export async function startProxy(opts = {}) {
|
|
|
5036
5200
|
// (below) does not re-pace on the peer: the retry is the rare path, and
|
|
5037
5201
|
// holding it for a floor would stack a second wait on a request that
|
|
5038
5202
|
// has already been refused once.
|
|
5039
|
-
const seatClock = pacingClocks.seat(
|
|
5203
|
+
const seatClock = pacingClocks.seat(accountKeyNow());
|
|
5040
5204
|
const nowForPacing = Date.now();
|
|
5041
5205
|
const pacingDelay = computePacingDelay(nowForPacing, seatClock.lastRequestTime, pacingCfg);
|
|
5042
5206
|
const thinkDelay = thinkTimeEnabled
|
|
@@ -5082,12 +5246,16 @@ export async function startProxy(opts = {}) {
|
|
|
5082
5246
|
const forwardedIdentity = (passthrough || genuineCCRequest)
|
|
5083
5247
|
? forwardClientCCIdentityHeaders(req.headers)
|
|
5084
5248
|
: {};
|
|
5085
|
-
const
|
|
5249
|
+
const clientAnthropicVersion = passthrough ? (req.headers['anthropic-version'] || '2023-06-01') : '2023-06-01';
|
|
5250
|
+
// On the credits rung the outbound set is a plain API client's
|
|
5251
|
+
// (credits-rung.ts): the key, the version, the client's betas minus the
|
|
5252
|
+
// OAuth flag. Nothing of Claude Code's identity or session.
|
|
5253
|
+
const headers = creditsRung ? creditsHeaders(creditsApiKey, clientBeta, clientAnthropicVersion) : {
|
|
5086
5254
|
...staticHeaders,
|
|
5087
5255
|
...forwardedIdentity,
|
|
5088
5256
|
...upstreamAuthHeaders(upstreamApiKey, accessToken),
|
|
5089
5257
|
'x-claude-code-session-id': outboundSessionId,
|
|
5090
|
-
'anthropic-version':
|
|
5258
|
+
'anthropic-version': clientAnthropicVersion,
|
|
5091
5259
|
'anthropic-beta': beta,
|
|
5092
5260
|
// Prefer the client's own when passthrough forwarded one: a genuine CC
|
|
5093
5261
|
// request already carries a real request id, and synthesising over it
|
|
@@ -5101,17 +5269,19 @@ export async function startProxy(opts = {}) {
|
|
|
5101
5269
|
// upstream fetch so Anthropic stops generating (and billing) a
|
|
5102
5270
|
// response nobody will read. With `--drain-on-close` set, we
|
|
5103
5271
|
// instead keep the reader spinning to consume the full SSE — see
|
|
5104
|
-
// src/stream-drain.ts for the fingerprint rationale. The
|
|
5105
|
-
//
|
|
5106
|
-
//
|
|
5272
|
+
// src/stream-drain.ts for the fingerprint rationale. The upstream
|
|
5273
|
+
// timeout shares the same controller, so a hung upstream still gets
|
|
5274
|
+
// cut off regardless of drain mode: it caps the wait until headers,
|
|
5275
|
+
// then the gap between chunks once a 2xx streams.
|
|
5107
5276
|
const upstreamAbort = new AbortController();
|
|
5108
5277
|
let clientDisconnected = false;
|
|
5109
|
-
|
|
5278
|
+
const deadline = upstreamDeadline(upstreamTimeoutMs, () => {
|
|
5110
5279
|
if (!upstreamAbort.signal.aborted) {
|
|
5111
5280
|
upstreamAbortReason = 'timeout';
|
|
5112
5281
|
upstreamAbort.abort();
|
|
5113
5282
|
}
|
|
5114
|
-
}
|
|
5283
|
+
});
|
|
5284
|
+
upstreamTimeout = deadline;
|
|
5115
5285
|
onClientClose = () => {
|
|
5116
5286
|
const action = decideOnClientClose(res.writableEnded, upstreamAbort.signal.aborted, drainOnClose);
|
|
5117
5287
|
if (action === 'abort') {
|
|
@@ -5157,12 +5327,56 @@ export async function startProxy(opts = {}) {
|
|
|
5157
5327
|
// combination with headroom -- opus-4-1 needs 2.
|
|
5158
5328
|
const MAX_RECOVERY_PASSES = 4;
|
|
5159
5329
|
let recoveryPasses = 0;
|
|
5330
|
+
// Move the request to a peer seat: auth, session header, the body's
|
|
5331
|
+
// metadata.user_id and the sticky binding all name `next`. The session
|
|
5332
|
+
// id is resolved once so the header and the body carry the same value
|
|
5333
|
+
// (the same rule as the first build), and the body's device_id and
|
|
5334
|
+
// account_uuid are next's, not the rejected seat's. A body dario did not
|
|
5335
|
+
// build (passthrough, count_tokens) is the client's and is left alone.
|
|
5336
|
+
const switchAccount = (next) => {
|
|
5337
|
+
triedAliases.add(next.alias);
|
|
5338
|
+
poolAccount = next;
|
|
5339
|
+
accessToken = next.accessToken;
|
|
5340
|
+
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5341
|
+
const sessionId = resolveOutboundSession(next, clientSessionKey).sessionId;
|
|
5342
|
+
headers['x-claude-code-session-id'] = sessionId;
|
|
5343
|
+
if (bodyIdentityOwned) {
|
|
5344
|
+
finalBody = rebindBodyIdentity(finalBody, { deviceId: next.identity.deviceId, accountUuid: next.identity.accountUuid, sessionId }, process.env.DARIO_CCH !== 'random' ? cliVersion : null);
|
|
5345
|
+
}
|
|
5346
|
+
pool.rebindSticky(stickyKey, next.alias);
|
|
5347
|
+
poolSync?.bindSticky(stickyKey, next.alias);
|
|
5348
|
+
};
|
|
5349
|
+
// Mid-flight move to the credits rung (credits-rung.ts): a 429 with no
|
|
5350
|
+
// peer seat left, and the chain names a credits entry the key can serve.
|
|
5351
|
+
// The request is rebuilt as a plain API request and the headers are
|
|
5352
|
+
// replaced in place, because the loop head re-derives the outbound set
|
|
5353
|
+
// from `headers` on every pass; the caller then re-enters the loop.
|
|
5354
|
+
const rearmOnCredits = () => {
|
|
5355
|
+
if (!armCreditsRung('pool exhausted mid-flight (429, no peer)'))
|
|
5356
|
+
return false;
|
|
5357
|
+
const thin = thinCreditsBody(creditsRung);
|
|
5358
|
+
if (!thin) {
|
|
5359
|
+
console.log(`[dario] #${requestCount} credits rung: the request body could not be re-pointed at ${creditsRung.model}`);
|
|
5360
|
+
creditsRung = null;
|
|
5361
|
+
res.removeHeader('x-dario-pool-fallback');
|
|
5362
|
+
return false;
|
|
5363
|
+
}
|
|
5364
|
+
finalBody = thin;
|
|
5365
|
+
ccToolMap = null;
|
|
5366
|
+
preserveToolsEffective = true;
|
|
5367
|
+
bodyIdentityOwned = false;
|
|
5368
|
+
for (const k of Object.keys(headers))
|
|
5369
|
+
delete headers[k];
|
|
5370
|
+
Object.assign(headers, creditsHeaders(creditsApiKey, clientBeta, clientAnthropicVersion));
|
|
5371
|
+
return true;
|
|
5372
|
+
};
|
|
5160
5373
|
dispatchLoop: while (true) {
|
|
5161
5374
|
// Reorder outbound headers to match CC's captured header sequence
|
|
5162
5375
|
// when the live template recorded one. No-op on bundled-only installs.
|
|
5163
5376
|
// Skipped in passthrough mode — passthrough means "don't shape the
|
|
5164
5377
|
// request to look like CC," and reordering is a form of shaping.
|
|
5165
|
-
|
|
5378
|
+
// Skipped on the credits rung too: a plain API request has no CC order.
|
|
5379
|
+
const outboundHeaders = (passthrough || creditsRung) ? headers : orderHeadersForOutbound(headers);
|
|
5166
5380
|
fetchStartedAt ??= Date.now();
|
|
5167
5381
|
upstream = await upstreamFetch(targetBase, {
|
|
5168
5382
|
method: req.method ?? 'POST',
|
|
@@ -5252,7 +5466,7 @@ export async function startProxy(opts = {}) {
|
|
|
5252
5466
|
}
|
|
5253
5467
|
}
|
|
5254
5468
|
if (betaRejectedFlags.length > 0 && recoveryPasses < MAX_RECOVERY_PASSES) {
|
|
5255
|
-
const acctKey =
|
|
5469
|
+
const acctKey = accountKeyNow();
|
|
5256
5470
|
let set = unavailableBetas.get(acctKey);
|
|
5257
5471
|
if (!set) {
|
|
5258
5472
|
set = new Set();
|
|
@@ -5465,7 +5679,7 @@ export async function startProxy(opts = {}) {
|
|
|
5465
5679
|
else if (isLongContextError && recoveryPasses < MAX_RECOVERY_PASSES) {
|
|
5466
5680
|
// Cache the rejection so future requests on this account skip
|
|
5467
5681
|
// context-1m up front instead of re-paying the 400/429 round-trip.
|
|
5468
|
-
const acctKey =
|
|
5682
|
+
const acctKey = accountKeyNow();
|
|
5469
5683
|
const firstRejection = !context1mUnavailable.has(acctKey);
|
|
5470
5684
|
context1mUnavailable.add(acctKey);
|
|
5471
5685
|
if (verbose && firstRejection)
|
|
@@ -5496,13 +5710,7 @@ export async function startProxy(opts = {}) {
|
|
|
5496
5710
|
if (poolAccount) {
|
|
5497
5711
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
5498
5712
|
if (nextAccount) {
|
|
5499
|
-
|
|
5500
|
-
poolAccount = nextAccount;
|
|
5501
|
-
accessToken = nextAccount.accessToken;
|
|
5502
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5503
|
-
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
5504
|
-
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
5505
|
-
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
5713
|
+
switchAccount(nextAccount);
|
|
5506
5714
|
peekedBody = null;
|
|
5507
5715
|
continue dispatchLoop;
|
|
5508
5716
|
}
|
|
@@ -5520,12 +5728,20 @@ export async function startProxy(opts = {}) {
|
|
|
5520
5728
|
// The Claude half is out of quota for this request AND for the next
|
|
5521
5729
|
// one — cool it here, honouring the upstream `retry-after` when it
|
|
5522
5730
|
// sent one, so the following request does not re-walk the chain.
|
|
5523
|
-
if (await familyFallbackOn429(upstream))
|
|
5731
|
+
if (!creditsRung && await familyFallbackOn429(upstream))
|
|
5524
5732
|
return;
|
|
5525
5733
|
attemptedProviders.add('claude');
|
|
5526
|
-
|
|
5527
|
-
if (!
|
|
5528
|
-
|
|
5734
|
+
// A 429 from the credits key is the key's limit, not the pool's.
|
|
5735
|
+
if (!creditsRung)
|
|
5736
|
+
providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
|
|
5737
|
+
if (!pinnedAccount) {
|
|
5738
|
+
const outcome = await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt }, rearmOnCredits);
|
|
5739
|
+
if (outcome === 'served')
|
|
5740
|
+
return;
|
|
5741
|
+
if (outcome === 'credits') {
|
|
5742
|
+
peekedBody = null;
|
|
5743
|
+
continue dispatchLoop;
|
|
5744
|
+
}
|
|
5529
5745
|
}
|
|
5530
5746
|
if (allProvidersCooled(['codex', 'claude'], providerCooldowns)) {
|
|
5531
5747
|
writeAllProvidersRateLimited(['codex', 'claude']);
|
|
@@ -5553,10 +5769,10 @@ export async function startProxy(opts = {}) {
|
|
|
5553
5769
|
analytics.record({
|
|
5554
5770
|
timestamp: Date.now(),
|
|
5555
5771
|
consumer,
|
|
5556
|
-
account:
|
|
5772
|
+
account: accountKeyNow(),
|
|
5557
5773
|
model: requestModel,
|
|
5558
5774
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
5559
|
-
claim: rl
|
|
5775
|
+
claim: claimNow(rl), util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
5560
5776
|
latencyMs: Date.now() - startTime, status: 429, isStream: false, isOpenAI, timing: timingNow(),
|
|
5561
5777
|
});
|
|
5562
5778
|
}
|
|
@@ -5580,6 +5796,9 @@ export async function startProxy(opts = {}) {
|
|
|
5580
5796
|
}
|
|
5581
5797
|
const rejection = classifyUpstreamRejection(upstream.status, peekedBody);
|
|
5582
5798
|
responseHeaders['x-dario-upstream-rejection'] = rejection.marker;
|
|
5799
|
+
if (creditsRung) {
|
|
5800
|
+
console.error(`[dario] #${requestCount} credits rung refused (${upstream.status}, ${rejection.marker}) as ${creditsRung.model}: the key may be out of credits or not linked to the plan's organization; the upstream error is forwarded`);
|
|
5801
|
+
}
|
|
5583
5802
|
requestCount++;
|
|
5584
5803
|
res.writeHead(upstream.status, responseHeaders);
|
|
5585
5804
|
res.end(peekedBody);
|
|
@@ -5593,6 +5812,12 @@ export async function startProxy(opts = {}) {
|
|
|
5593
5812
|
// headers, so headroom math sees a healthy idle account. Mark the
|
|
5594
5813
|
// cool-down here, try the next-best account, fall through to the
|
|
5595
5814
|
// normal forwarding only if no peer is available.
|
|
5815
|
+
// On the credits rung there is no seat to cool and no peer to try: a
|
|
5816
|
+
// 401/403 from the key is the key's own answer and is forwarded as is
|
|
5817
|
+
// (the forwarder below reuses the body the classifier read).
|
|
5818
|
+
if (creditsRung && (upstream.status === 401 || upstream.status === 403)) {
|
|
5819
|
+
console.error(`[dario] #${requestCount} credits rung refused (${upstream.status}) as ${creditsRung.model}: the key was not accepted (invalid, revoked, or without permission for the model); the upstream error is forwarded`);
|
|
5820
|
+
}
|
|
5596
5821
|
if (poolAccount && (upstream.status === 401 || upstream.status === 403)) {
|
|
5597
5822
|
pool.markAuthFailure(poolAccount.alias);
|
|
5598
5823
|
if (verbose) {
|
|
@@ -5600,13 +5825,7 @@ export async function startProxy(opts = {}) {
|
|
|
5600
5825
|
}
|
|
5601
5826
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
5602
5827
|
if (nextAccount) {
|
|
5603
|
-
|
|
5604
|
-
poolAccount = nextAccount;
|
|
5605
|
-
accessToken = nextAccount.accessToken;
|
|
5606
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5607
|
-
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
5608
|
-
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
5609
|
-
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
5828
|
+
switchAccount(nextAccount);
|
|
5610
5829
|
continue dispatchLoop;
|
|
5611
5830
|
}
|
|
5612
5831
|
// No peer available — forward the saved generic-403 bytes when the
|
|
@@ -5634,13 +5853,7 @@ export async function startProxy(opts = {}) {
|
|
|
5634
5853
|
if (poolAccount) {
|
|
5635
5854
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
5636
5855
|
if (nextAccount) {
|
|
5637
|
-
|
|
5638
|
-
poolAccount = nextAccount;
|
|
5639
|
-
accessToken = nextAccount.accessToken;
|
|
5640
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5641
|
-
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
5642
|
-
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
5643
|
-
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
5856
|
+
switchAccount(nextAccount);
|
|
5644
5857
|
continue dispatchLoop;
|
|
5645
5858
|
}
|
|
5646
5859
|
}
|
|
@@ -5650,12 +5863,21 @@ export async function startProxy(opts = {}) {
|
|
|
5650
5863
|
// into body. See attemptPoolFallbackOn429's doc comment for why this
|
|
5651
5864
|
// is one shared function rather than an inline copy.
|
|
5652
5865
|
// Same bookkeeping as the other mid-flight site — see there.
|
|
5653
|
-
if (await familyFallbackOn429(upstream))
|
|
5866
|
+
if (!creditsRung && await familyFallbackOn429(upstream))
|
|
5654
5867
|
return;
|
|
5655
5868
|
attemptedProviders.add('claude');
|
|
5656
|
-
|
|
5657
|
-
if (!
|
|
5658
|
-
|
|
5869
|
+
// A 429 from the credits key is the key's limit, not the pool's.
|
|
5870
|
+
if (!creditsRung)
|
|
5871
|
+
providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
|
|
5872
|
+
if (!pinnedAccount) {
|
|
5873
|
+
const outcome = await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt }, rearmOnCredits);
|
|
5874
|
+
if (outcome === 'served')
|
|
5875
|
+
return;
|
|
5876
|
+
if (outcome === 'credits') {
|
|
5877
|
+
// This site has not read the 429's body; release it before the retry.
|
|
5878
|
+
void upstream.body?.cancel().catch(() => { });
|
|
5879
|
+
continue dispatchLoop;
|
|
5880
|
+
}
|
|
5659
5881
|
}
|
|
5660
5882
|
if (allProvidersCooled(['codex', 'claude'], providerCooldowns)) {
|
|
5661
5883
|
writeAllProvidersRateLimited(['codex', 'claude']);
|
|
@@ -5679,10 +5901,10 @@ export async function startProxy(opts = {}) {
|
|
|
5679
5901
|
analytics.record({
|
|
5680
5902
|
timestamp: Date.now(),
|
|
5681
5903
|
consumer,
|
|
5682
|
-
account:
|
|
5904
|
+
account: accountKeyNow(),
|
|
5683
5905
|
model: requestModel,
|
|
5684
5906
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
5685
|
-
claim: rl
|
|
5907
|
+
claim: claimNow(rl), util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
5686
5908
|
latencyMs: Date.now() - startTime, status: 429, isStream: false, isOpenAI, timing: timingNow(),
|
|
5687
5909
|
});
|
|
5688
5910
|
}
|
|
@@ -5716,6 +5938,11 @@ export async function startProxy(opts = {}) {
|
|
|
5716
5938
|
responseHeaders[key] = value;
|
|
5717
5939
|
}
|
|
5718
5940
|
}
|
|
5941
|
+
// A classified rejection that reached the plain forwarder (no seat to
|
|
5942
|
+
// fail over from) is marked the way the seat branches mark theirs.
|
|
5943
|
+
if (peekedBody !== null && upstream.status >= 400) {
|
|
5944
|
+
responseHeaders['x-dario-upstream-rejection'] = classifyUpstreamRejection(upstream.status, peekedBody).marker;
|
|
5945
|
+
}
|
|
5719
5946
|
requestCount++;
|
|
5720
5947
|
// Log billing classification on first request or in verbose mode.
|
|
5721
5948
|
//
|
|
@@ -5732,7 +5959,10 @@ export async function startProxy(opts = {}) {
|
|
|
5732
5959
|
const billingClaim = upstream.headers.get('anthropic-ratelimit-unified-representative-claim');
|
|
5733
5960
|
const overageUtil = upstream.headers.get('anthropic-ratelimit-unified-overage-utilization');
|
|
5734
5961
|
if (requestCount === 1 || verbose) {
|
|
5735
|
-
if (
|
|
5962
|
+
if (creditsRung) {
|
|
5963
|
+
console.log(`[dario] #${requestCount} billing: credits (${CREDITS_CLAIM}, x-api-key as ${creditsRung.model})`);
|
|
5964
|
+
}
|
|
5965
|
+
else if (billingClaim) {
|
|
5736
5966
|
let overagePct;
|
|
5737
5967
|
if (overageUtil !== null) {
|
|
5738
5968
|
overagePct = `${Math.round(parseFloat(overageUtil) * 100)}%`;
|
|
@@ -5839,11 +6069,17 @@ export async function startProxy(opts = {}) {
|
|
|
5839
6069
|
// parking this handler forever and leaking its queue slot — the
|
|
5840
6070
|
// dario#905 wedge. See waitForClientDrain in src/stream-drain.ts.
|
|
5841
6071
|
const waitForDrain = () => waitForClientDrain(res, upstreamAbort.signal);
|
|
6072
|
+
// A 2xx stream is timed by the gap between chunks from here, so a
|
|
6073
|
+
// long answer is not cut while it still streams. An error body keeps
|
|
6074
|
+
// the headers-phase cap.
|
|
6075
|
+
if (upstream.status >= 200 && upstream.status < 300)
|
|
6076
|
+
deadline.streaming();
|
|
5842
6077
|
try {
|
|
5843
6078
|
let buffer = '';
|
|
5844
6079
|
const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
|
|
5845
6080
|
while (true) {
|
|
5846
6081
|
const { done, value } = await reader.read();
|
|
6082
|
+
deadline.touch();
|
|
5847
6083
|
if (done) {
|
|
5848
6084
|
upstreamDoneAt = Date.now();
|
|
5849
6085
|
break;
|
|
@@ -5973,19 +6209,19 @@ export async function startProxy(opts = {}) {
|
|
|
5973
6209
|
// would read, and using their (often zero) output_tokens would
|
|
5974
6210
|
// pin think time to baseMs+jitter on the next request needlessly.
|
|
5975
6211
|
if (upstream.status >= 200 && upstream.status < 300) {
|
|
5976
|
-
pacingClocks.noteResponse(
|
|
6212
|
+
pacingClocks.noteResponse(accountKeyNow(), Date.now(), streamOutputTokens);
|
|
5977
6213
|
}
|
|
5978
6214
|
{
|
|
5979
6215
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
5980
6216
|
analytics.record({
|
|
5981
6217
|
timestamp: Date.now(),
|
|
5982
6218
|
consumer,
|
|
5983
|
-
account:
|
|
6219
|
+
account: accountKeyNow(),
|
|
5984
6220
|
model: requestModel,
|
|
5985
6221
|
inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
|
|
5986
6222
|
cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
|
|
5987
6223
|
thinkingTokens: Math.round(streamThinkingChars / 4),
|
|
5988
|
-
claim: rl
|
|
6224
|
+
claim: claimNow(rl), util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
5989
6225
|
latencyMs: Date.now() - startTime, status: upstream.status, isStream: true, isOpenAI, timing: timingNow(),
|
|
5990
6226
|
continuation: continuationOf(guard, requestDepth),
|
|
5991
6227
|
});
|
|
@@ -5997,9 +6233,9 @@ export async function startProxy(opts = {}) {
|
|
|
5997
6233
|
status: upstream.status, latency_ms: Date.now() - startTime, ...timingLogFields(timingNow()),
|
|
5998
6234
|
in_tokens: streamInputTokens, out_tokens: streamOutputTokens,
|
|
5999
6235
|
cache_read: streamCacheReadTokens, cache_create: streamCacheCreateTokens,
|
|
6000
|
-
claim: poolAccount?.rateLimit.claim,
|
|
6001
|
-
bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
|
|
6002
|
-
account: poolAccount?.alias,
|
|
6236
|
+
claim: creditsRung ? CREDITS_CLAIM : poolAccount?.rateLimit.claim,
|
|
6237
|
+
bucket: creditsRung ? 'credits' : (poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined),
|
|
6238
|
+
account: creditsRung ? ACCOUNT_KEY_CREDITS : poolAccount?.alias,
|
|
6003
6239
|
consumer,
|
|
6004
6240
|
client: detectedClientForLog,
|
|
6005
6241
|
preserve_tools: preserveToolsEffective,
|
|
@@ -6014,8 +6250,13 @@ export async function startProxy(opts = {}) {
|
|
|
6014
6250
|
}, consumer));
|
|
6015
6251
|
}
|
|
6016
6252
|
else {
|
|
6017
|
-
// Buffer and forward
|
|
6018
|
-
|
|
6253
|
+
// Buffer and forward. A 400/402/403 body the rejection classifier
|
|
6254
|
+
// already read is in peekedBody and the stream is consumed; reading
|
|
6255
|
+
// it again would hand the client an empty body with the right
|
|
6256
|
+
// status. Only responses no branch above handled reach here: a
|
|
6257
|
+
// non-billing 403 with no seat to fail over from (the credits rung,
|
|
6258
|
+
// ANTHROPIC_UPSTREAM_API_KEY), a generic 400 or 402.
|
|
6259
|
+
let responseBody = peekedBody ?? await upstream.text();
|
|
6019
6260
|
upstreamDoneAt = Date.now();
|
|
6020
6261
|
// Reverse tool name mapping so client sees original names
|
|
6021
6262
|
if (ccToolMap)
|
|
@@ -6043,7 +6284,7 @@ export async function startProxy(opts = {}) {
|
|
|
6043
6284
|
// tokens when the body wasn't JSON or had no usage block — base +
|
|
6044
6285
|
// jitter still apply but the per-token component is 0.
|
|
6045
6286
|
if (upstream.status >= 200 && upstream.status < 300) {
|
|
6046
|
-
pacingClocks.noteResponse(
|
|
6287
|
+
pacingClocks.noteResponse(accountKeyNow(), Date.now(), bufferedUsage?.outputTokens ?? 0);
|
|
6047
6288
|
}
|
|
6048
6289
|
if (bufferedUsage) {
|
|
6049
6290
|
try {
|
|
@@ -6051,12 +6292,12 @@ export async function startProxy(opts = {}) {
|
|
|
6051
6292
|
analytics.record({
|
|
6052
6293
|
timestamp: Date.now(),
|
|
6053
6294
|
consumer,
|
|
6054
|
-
account:
|
|
6295
|
+
account: accountKeyNow(),
|
|
6055
6296
|
model: bufferedUsage.model || requestModel,
|
|
6056
6297
|
inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
|
|
6057
6298
|
cacheReadTokens: bufferedUsage.cacheReadTokens, cacheCreateTokens: bufferedUsage.cacheCreateTokens,
|
|
6058
6299
|
thinkingTokens: bufferedUsage.thinkingTokens,
|
|
6059
|
-
claim: rl
|
|
6300
|
+
claim: claimNow(rl), util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
6060
6301
|
latencyMs: Date.now() - startTime, status: upstream.status, isStream: false, isOpenAI, timing: timingNow(),
|
|
6061
6302
|
});
|
|
6062
6303
|
}
|
|
@@ -6069,9 +6310,9 @@ export async function startProxy(opts = {}) {
|
|
|
6069
6310
|
status: upstream.status, latency_ms: Date.now() - startTime, ...timingLogFields(timingNow()),
|
|
6070
6311
|
in_tokens: bufferedUsage?.inputTokens, out_tokens: bufferedUsage?.outputTokens,
|
|
6071
6312
|
cache_read: bufferedUsage?.cacheReadTokens, cache_create: bufferedUsage?.cacheCreateTokens,
|
|
6072
|
-
claim: poolAccount?.rateLimit.claim,
|
|
6073
|
-
bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
|
|
6074
|
-
account: poolAccount?.alias,
|
|
6313
|
+
claim: creditsRung ? CREDITS_CLAIM : poolAccount?.rateLimit.claim,
|
|
6314
|
+
bucket: creditsRung ? 'credits' : (poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined),
|
|
6315
|
+
account: creditsRung ? ACCOUNT_KEY_CREDITS : poolAccount?.alias,
|
|
6075
6316
|
consumer,
|
|
6076
6317
|
client: detectedClientForLog,
|
|
6077
6318
|
preserve_tools: preserveToolsEffective,
|
|
@@ -6128,7 +6369,7 @@ export async function startProxy(opts = {}) {
|
|
|
6128
6369
|
// setup happens after the body-read phase, so on fast-path errors
|
|
6129
6370
|
// (413, body read timeout) these may still be null — guard accordingly.
|
|
6130
6371
|
if (upstreamTimeout !== null)
|
|
6131
|
-
|
|
6372
|
+
upstreamTimeout.clear();
|
|
6132
6373
|
if (onClientClose !== null)
|
|
6133
6374
|
req.off('close', onClientClose);
|
|
6134
6375
|
releaseQueueSlot();
|
|
@@ -6421,8 +6662,13 @@ export async function startProxy(opts = {}) {
|
|
|
6421
6662
|
catch { /* best-effort flush — never block shutdown on it */ }
|
|
6422
6663
|
}));
|
|
6423
6664
|
};
|
|
6424
|
-
// Graceful shutdown
|
|
6665
|
+
// Graceful shutdown. Runs once: a Ctrl+C in a terminal reaches the Bun child
|
|
6666
|
+
// directly and again through the relaunch wrapper's forwarding.
|
|
6667
|
+
let shuttingDown = false;
|
|
6425
6668
|
const shutdown = () => {
|
|
6669
|
+
if (shuttingDown)
|
|
6670
|
+
return;
|
|
6671
|
+
shuttingDown = true;
|
|
6426
6672
|
console.log('\n[dario] Shutting down...');
|
|
6427
6673
|
clearInterval(presenceInterval);
|
|
6428
6674
|
clearInterval(refreshInterval);
|