@askalf/dario 6.0.33 → 6.0.35
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 +56 -63
- package/dist/accounts.d.ts +18 -0
- package/dist/accounts.js +14 -0
- package/dist/admin-api.d.ts +10 -0
- package/dist/admin-api.js +12 -1
- package/dist/analytics.d.ts +41 -1
- package/dist/analytics.js +65 -2
- package/dist/cli.js +100 -1
- package/dist/doctor-core.d.ts +15 -0
- package/dist/doctor-core.js +34 -0
- package/dist/pool-sync.d.ts +114 -0
- package/dist/pool-sync.js +200 -0
- package/dist/pool.d.ts +79 -0
- package/dist/pool.js +156 -15
- package/dist/proxy.d.ts +14 -0
- package/dist/proxy.js +174 -14
- package/dist/request-queue.d.ts +34 -4
- package/dist/request-queue.js +57 -11
- package/dist/tui/tabs/hits.js +2 -0
- package/dist/upstream-rejection.d.ts +6 -0
- package/dist/upstream-rejection.js +6 -0
- package/docs/admin-api.md +5 -1
- package/docs/configuration.md +10 -0
- package/docs/drift-monitor.md +2 -1
- package/docs/integrations/openclaw-walkthrough.md +2 -2
- package/docs/multi-account-pool.md +29 -1
- package/docs/multi-instance.md +27 -6
- package/package.json +18 -12
package/dist/proxy.js
CHANGED
|
@@ -12,13 +12,14 @@ import { darioVersion } from './version.js';
|
|
|
12
12
|
import { buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
|
|
13
13
|
import { stampCch, hasCchSeed } from './cch.js';
|
|
14
14
|
import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
|
|
15
|
-
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, describeRateLimitSnapshot } from './pool.js';
|
|
16
|
-
import {
|
|
15
|
+
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, describeRateLimitSnapshot, windowPeers, distinctWindows, accountAction } from './pool.js';
|
|
16
|
+
import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
|
|
17
|
+
import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
|
|
17
18
|
import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
|
|
18
19
|
import { notify as osNotify } from './notify.js';
|
|
19
20
|
import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
|
|
20
21
|
import { resolveSeatPin, SEAT_PIN_HEADER, SEAT_PIN_TOKEN_HEADER } from './seat-pin.js';
|
|
21
|
-
import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool, mirrorLoginToCredentials } from './accounts.js';
|
|
22
|
+
import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool, mirrorLoginToCredentials, withObservedOrganization } from './accounts.js';
|
|
22
23
|
import { handleAdminRequest } from './admin-api.js';
|
|
23
24
|
import { createTokenBucket } from './rate-limit.js';
|
|
24
25
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
@@ -32,7 +33,7 @@ import { selectPoolFallbackModels } from './pool-fallback-tier.js';
|
|
|
32
33
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
|
|
33
34
|
import { redactSecrets } from './redact.js';
|
|
34
35
|
import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, retryModelCatalogNow, isSuspendedModel } from './model-catalog.js';
|
|
35
|
-
import { classifyUpstreamRejection, diagnosticSnippet } from './upstream-rejection.js';
|
|
36
|
+
import { classifyUpstreamRejection, diagnosticSnippet, POOL_PARKED } from './upstream-rejection.js';
|
|
36
37
|
import { ProviderCooldowns, canAttempt, allProvidersCooled, cooldownRetryAfterMs, parseRetryAfterMs, ALL_PROVIDERS_RATE_LIMITED, } from './provider-cooldown.js';
|
|
37
38
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
38
39
|
const DEFAULT_PORT = 3456;
|
|
@@ -1328,9 +1329,45 @@ export async function startProxy(opts = {}) {
|
|
|
1328
1329
|
const accountsList = await loadAllAccounts();
|
|
1329
1330
|
const poolStrategy = resolvePoolStrategy(opts.poolStrategy);
|
|
1330
1331
|
const pool = new AccountPool(poolStrategy);
|
|
1332
|
+
// Two aliases reporting one window are one subscription counted twice
|
|
1333
|
+
// (dario#1244). Said once per pair, when the second reading arrives; the
|
|
1334
|
+
// listings carry it permanently as `sharesWindowWith`.
|
|
1335
|
+
const announcedWindowPairs = new Set();
|
|
1336
|
+
const announceWindowPeers = (alias) => {
|
|
1337
|
+
const now = Date.now();
|
|
1338
|
+
const seat = pool.get(alias);
|
|
1339
|
+
if (!seat)
|
|
1340
|
+
return;
|
|
1341
|
+
for (const peer of windowPeers(pool.all(), now).get(alias) ?? []) {
|
|
1342
|
+
const pair = [alias, peer].sort().join('|');
|
|
1343
|
+
if (announcedWindowPairs.has(pair))
|
|
1344
|
+
continue;
|
|
1345
|
+
announcedWindowPairs.add(pair);
|
|
1346
|
+
const windows = distinctWindows(pool.all(), now);
|
|
1347
|
+
console.error(`[dario] seats "${alias}" and "${peer}" report the same ${seat.rateLimit.claim} window (resets ${new Date(seat.rateLimit.reset * 1000).toISOString()}) — one subscription under two aliases; the pool has ${windows} distinct window${windows === 1 ? '' : 's'} across ${pool.size} seats`);
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
// Shared pool state across instances (docs/multi-instance.md): opt-in,
|
|
1351
|
+
// rides the refresh-lock service, fails open. Off → byte-identical to before.
|
|
1352
|
+
const lockUrl = process.env['DARIO_REFRESH_LOCK_URL'];
|
|
1353
|
+
const poolSync = opts.poolSharedState && lockUrl
|
|
1354
|
+
? new PoolSync(pool, {
|
|
1355
|
+
baseUrl: lockUrl,
|
|
1356
|
+
token: process.env['DARIO_REFRESH_LOCK_TOKEN'] ?? '',
|
|
1357
|
+
intervalMs: opts.poolSharedStateIntervalMs ?? DEFAULT_POOL_SYNC_INTERVAL_MS,
|
|
1358
|
+
log: (line) => console.error(line),
|
|
1359
|
+
})
|
|
1360
|
+
: null;
|
|
1361
|
+
if (opts.poolSharedState && !poolSync) {
|
|
1362
|
+
console.error('[dario] --pool-shared-state needs DARIO_REFRESH_LOCK_URL (the lock service carries the shared state) — running with this instance\'s own state');
|
|
1363
|
+
}
|
|
1331
1364
|
if (poolStrategy !== 'headroom') {
|
|
1332
1365
|
console.log(` Pool strategy: ${poolStrategy} (new conversations fill the alphabetically-first seat, spill at the 2% floor)`);
|
|
1333
1366
|
}
|
|
1367
|
+
if (poolSync) {
|
|
1368
|
+
console.log(` Pool shared state: on (instance ${poolSync.instance}, via ${lockUrl}, pulling peers every ${poolSync.intervalMs}ms; fails open)`);
|
|
1369
|
+
poolSync.start();
|
|
1370
|
+
}
|
|
1334
1371
|
// Per-model rate-limit bucket families seen during this proxy run. First-
|
|
1335
1372
|
// sight is logged once when verbose so a new Anthropic bucket (e.g. an
|
|
1336
1373
|
// eventual `7d_opus`) doesn't slip past unnoticed. Pure observability —
|
|
@@ -1398,6 +1435,7 @@ export async function startProxy(opts = {}) {
|
|
|
1398
1435
|
deviceId: acc.deviceId,
|
|
1399
1436
|
accountUuid: acc.accountUuid,
|
|
1400
1437
|
grantedAt: acc.grantedAt,
|
|
1438
|
+
organizationId: acc.organizationId,
|
|
1401
1439
|
});
|
|
1402
1440
|
}
|
|
1403
1441
|
// Startup self-heal (dario#790): eagerly refresh any account whose access
|
|
@@ -1418,7 +1456,9 @@ export async function startProxy(opts = {}) {
|
|
|
1418
1456
|
const saved = await loadAccount(acc.alias);
|
|
1419
1457
|
if (!saved)
|
|
1420
1458
|
return;
|
|
1421
|
-
|
|
1459
|
+
// The refresh's write carries the organization the seat was
|
|
1460
|
+
// observed on (dario#1244) — the one write that touches the record.
|
|
1461
|
+
const refreshed = await refreshAccountToken(withObservedOrganization(saved, acc.organizationId));
|
|
1422
1462
|
pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
|
|
1423
1463
|
// Mirror a refreshed `login` token back to credentials.json so the
|
|
1424
1464
|
// legacy file (and `dario doctor`) tracks the pool store (#808).
|
|
@@ -1470,7 +1510,9 @@ export async function startProxy(opts = {}) {
|
|
|
1470
1510
|
const saved = await loadAccount(acc.alias);
|
|
1471
1511
|
if (!saved)
|
|
1472
1512
|
continue;
|
|
1473
|
-
|
|
1513
|
+
// The refresh's write carries the organization the seat was
|
|
1514
|
+
// observed on (dario#1244) — the one write that touches the record.
|
|
1515
|
+
const refreshed = await refreshAccountToken(withObservedOrganization(saved, acc.organizationId));
|
|
1474
1516
|
pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
|
|
1475
1517
|
// Mirror a refreshed `login` token back to credentials.json so the
|
|
1476
1518
|
// legacy file (and `dario doctor`) tracks the pool store (#808).
|
|
@@ -1515,6 +1557,7 @@ export async function startProxy(opts = {}) {
|
|
|
1515
1557
|
deviceId: acc.deviceId,
|
|
1516
1558
|
accountUuid: acc.accountUuid,
|
|
1517
1559
|
grantedAt: acc.grantedAt,
|
|
1560
|
+
organizationId: acc.organizationId,
|
|
1518
1561
|
});
|
|
1519
1562
|
}
|
|
1520
1563
|
}
|
|
@@ -1612,10 +1655,14 @@ export async function startProxy(opts = {}) {
|
|
|
1612
1655
|
overlayTemplateHeaderValues(staticHeaders, CC_TEMPLATE.header_values);
|
|
1613
1656
|
}
|
|
1614
1657
|
let requestCount = 0;
|
|
1658
|
+
// dario#1244: the "pool parked" line is logged on the transition into the
|
|
1659
|
+
// state, not on every request that arrives while it holds.
|
|
1660
|
+
let poolParkedAnnounced = false;
|
|
1615
1661
|
const queue = new RequestQueue({
|
|
1616
1662
|
maxConcurrent: opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT,
|
|
1617
1663
|
maxQueued: opts.maxQueued ?? DEFAULT_MAX_QUEUED,
|
|
1618
1664
|
queueTimeoutMs: opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS,
|
|
1665
|
+
maxConcurrentPerConsumer: opts.maxConcurrentPerConsumer ?? 0,
|
|
1619
1666
|
});
|
|
1620
1667
|
const upstreamTimeoutMs = opts.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS;
|
|
1621
1668
|
// Cache context-1m beta availability. Set false once per account after the
|
|
@@ -2093,6 +2140,7 @@ export async function startProxy(opts = {}) {
|
|
|
2093
2140
|
// just persisted metadata — the same snapshot GET /accounts exposes.
|
|
2094
2141
|
poolStatus: () => {
|
|
2095
2142
|
const snapNow = Date.now();
|
|
2143
|
+
const peers = windowPeers(pool.all(), snapNow);
|
|
2096
2144
|
const snap = new Map();
|
|
2097
2145
|
for (const a of pool.all()) {
|
|
2098
2146
|
snap.set(a.alias, {
|
|
@@ -2105,9 +2153,13 @@ export async function startProxy(opts = {}) {
|
|
|
2105
2153
|
...rateLimitWindow(a.rateLimit, snapNow),
|
|
2106
2154
|
claim: a.rateLimit.claim,
|
|
2107
2155
|
status: reportedAccountStatus(a, snapNow),
|
|
2156
|
+
action: accountAction(a, snapNow),
|
|
2108
2157
|
requestCount: a.requestCount,
|
|
2109
2158
|
rejectedCount: a.rejectedCount,
|
|
2110
2159
|
lastRejectedAt: a.lastRejectedAt ?? null,
|
|
2160
|
+
organizationId: a.organizationId ?? null,
|
|
2161
|
+
sharesWindowWith: peers.get(a.alias) ?? [],
|
|
2162
|
+
readingFrom: a.adoptedFrom ?? null,
|
|
2111
2163
|
// Raw streak, not just the cooldown boolean: a single 401 also
|
|
2112
2164
|
// shows `auth-cooldown` for 60s, indistinguishable from a
|
|
2113
2165
|
// genuinely dead refresh token by that field alone. The magnitude
|
|
@@ -2179,6 +2231,7 @@ export async function startProxy(opts = {}) {
|
|
|
2179
2231
|
// the `dario accounts` CLI, not HTTP.
|
|
2180
2232
|
if (urlPath === '/accounts' && req.method === 'GET') {
|
|
2181
2233
|
const now = Date.now();
|
|
2234
|
+
const peers = windowPeers(pool.all(), now);
|
|
2182
2235
|
const accounts = pool.all().map(a => {
|
|
2183
2236
|
const inCooldown = isInAuthCooldown(a, now);
|
|
2184
2237
|
const cooldownMs = inCooldown && a.lastAuthFailureAt
|
|
@@ -2209,11 +2262,21 @@ export async function startProxy(opts = {}) {
|
|
|
2209
2262
|
...rateLimitWindow(a.rateLimit, now),
|
|
2210
2263
|
claim: a.rateLimit.claim,
|
|
2211
2264
|
status: reportedAccountStatus(a, now),
|
|
2265
|
+
// The one-word next step: none · wait · regrant (dario#1244).
|
|
2266
|
+
action: accountAction(a, now),
|
|
2212
2267
|
requestCount: a.requestCount,
|
|
2213
2268
|
// 429s answered — the attempts requestCount does not count, so a
|
|
2214
2269
|
// parked seat no longer reads as one that was never called.
|
|
2215
2270
|
rejectedCount: a.rejectedCount,
|
|
2216
2271
|
lastRejectedAt: a.lastRejectedAt ?? null,
|
|
2272
|
+
// Which organization the token belongs to, and which other seats
|
|
2273
|
+
// report the same live window — one subscription under several
|
|
2274
|
+
// aliases (dario#1244).
|
|
2275
|
+
organizationId: a.organizationId ?? null,
|
|
2276
|
+
sharesWindowWith: peers.get(a.alias) ?? [],
|
|
2277
|
+
// Whose reading this is: a peer instance's id (shared pool state)
|
|
2278
|
+
// or null for this instance's own.
|
|
2279
|
+
readingFrom: a.adoptedFrom ?? null,
|
|
2217
2280
|
expiresInMs: Math.max(0, a.expiresAt - now),
|
|
2218
2281
|
// Refresh-token grant age (refresh-grant.ts): the wall a token
|
|
2219
2282
|
// refresh cannot move. null fields = grant date unknown.
|
|
@@ -2236,6 +2299,11 @@ export async function startProxy(opts = {}) {
|
|
|
2236
2299
|
mode: 'pool',
|
|
2237
2300
|
...pool.status(),
|
|
2238
2301
|
stickyBindings: pool.stickyCount(),
|
|
2302
|
+
// Windows the pool really has: each measured window once, each
|
|
2303
|
+
// unmeasured seat as its own.
|
|
2304
|
+
distinctWindows: distinctWindows(pool.all(), now),
|
|
2305
|
+
// Shared pool state (pool-sync.ts) — null when off.
|
|
2306
|
+
sharedState: poolSync ? poolSync.status() : null,
|
|
2239
2307
|
accounts,
|
|
2240
2308
|
}));
|
|
2241
2309
|
return;
|
|
@@ -2462,12 +2530,19 @@ export async function startProxy(opts = {}) {
|
|
|
2462
2530
|
res.end(JSON.stringify(buildHaltErrorBody(state)));
|
|
2463
2531
|
return;
|
|
2464
2532
|
}
|
|
2533
|
+
// Who this request is for (dario#1244 follow-up). An `x-dario-consumer`
|
|
2534
|
+
// header names the consumer for both the per-consumer concurrency cap and
|
|
2535
|
+
// attribution. Without one, attribution falls back to a hash of the
|
|
2536
|
+
// body's user id once the body is parsed; the cap needs the name before
|
|
2537
|
+
// the slot is taken, so only the header gates.
|
|
2538
|
+
const consumerFromHeaders = consumerFromHeader(req.headers[CONSUMER_HEADER]);
|
|
2539
|
+
let consumer = consumerFromHeaders;
|
|
2465
2540
|
// Proxy to Anthropic (with concurrency control). The bounded queue
|
|
2466
2541
|
// replaces the v3.30.x-and-earlier unbounded semaphore — dario#80. A
|
|
2467
2542
|
// queue-full condition returns an explicit 429 with a `"queue-full"`
|
|
2468
2543
|
// marker in the body; a queue-timeout returns 504 with `"queue-timeout"`.
|
|
2469
2544
|
try {
|
|
2470
|
-
await queue.acquire();
|
|
2545
|
+
await queue.acquire(consumerFromHeaders);
|
|
2471
2546
|
}
|
|
2472
2547
|
catch (err) {
|
|
2473
2548
|
if (err instanceof QueueFullError) {
|
|
@@ -2556,6 +2631,31 @@ export async function startProxy(opts = {}) {
|
|
|
2556
2631
|
//
|
|
2557
2632
|
// Shared because two callers must agree: the selector, and the dispatch
|
|
2558
2633
|
// below for a request the selector DEFERRED but no provider could serve.
|
|
2634
|
+
/**
|
|
2635
|
+
* Every seat in the pool is parked inside a live window (dario#1244).
|
|
2636
|
+
* The old fallback re-probed the earliest-reset seat on every request:
|
|
2637
|
+
* one upstream round trip per request that could only 429,
|
|
2638
|
+
* `rejectedCount` climbing by one each time — 500 on one seat inside a
|
|
2639
|
+
* single window on the reporter's gateway — and the client waiting on a
|
|
2640
|
+
* verdict dario already held. Answer it here: 429, `retry-after` at the
|
|
2641
|
+
* earliest reset, the marker in the same channel the other rejection
|
|
2642
|
+
* classes use, and nothing sent upstream.
|
|
2643
|
+
*/
|
|
2644
|
+
const writePoolParked = (untilMs) => {
|
|
2645
|
+
const retryAfterSec = Math.max(1, Math.ceil((untilMs - Date.now()) / 1000));
|
|
2646
|
+
res.writeHead(429, {
|
|
2647
|
+
...JSON_HEADERS,
|
|
2648
|
+
'retry-after': String(retryAfterSec),
|
|
2649
|
+
'x-dario-upstream-rejection': POOL_PARKED,
|
|
2650
|
+
});
|
|
2651
|
+
res.end(JSON.stringify({
|
|
2652
|
+
error: {
|
|
2653
|
+
type: 'rate_limit_error',
|
|
2654
|
+
message: `All ${pool.size} pool seat${pool.size === 1 ? '' : 's'} are over their rate-limit windows; the earliest resets in ${retryAfterSec}s. Nothing was sent upstream.`,
|
|
2655
|
+
},
|
|
2656
|
+
reason: POOL_PARKED,
|
|
2657
|
+
}));
|
|
2658
|
+
};
|
|
2559
2659
|
const writePoolUnavailable = () => {
|
|
2560
2660
|
res.writeHead(503, JSON_HEADERS);
|
|
2561
2661
|
res.end(JSON.stringify(pool.size === 0
|
|
@@ -2669,6 +2769,20 @@ export async function startProxy(opts = {}) {
|
|
|
2669
2769
|
return true;
|
|
2670
2770
|
}
|
|
2671
2771
|
poolAccount = pool.select();
|
|
2772
|
+
if (poolAccount)
|
|
2773
|
+
poolParkedAnnounced = false;
|
|
2774
|
+
// Every seat parked inside a live window (dario#1244): cool the
|
|
2775
|
+
// provider to the earliest reset so a fallback chain sees the Claude
|
|
2776
|
+
// half as what it is, say so once, and — unless a fallback is armed —
|
|
2777
|
+
// answer the client here instead of spending a probe that can only 429.
|
|
2778
|
+
const parkedUntil = poolAccount ? null : pool.parkedUntil();
|
|
2779
|
+
if (parkedUntil !== null) {
|
|
2780
|
+
providerCooldowns.note('claude', parkedUntil - Date.now());
|
|
2781
|
+
if (!poolParkedAnnounced) {
|
|
2782
|
+
poolParkedAnnounced = true;
|
|
2783
|
+
console.error(`[dario] #${requestCount} pool parked: all ${pool.size} seats are over their rate-limit windows, earliest resets in ${Math.max(1, Math.ceil((parkedUntil - Date.now()) / 60000))}m — answering 429 locally until then, nothing sent upstream`);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2672
2786
|
if (!poolAccount) {
|
|
2673
2787
|
// Pool-exhausted fallback: when armed, the pool HAS accounts (all
|
|
2674
2788
|
// drained / cooling), and the client speaks OpenAI shape, defer —
|
|
@@ -2694,7 +2808,10 @@ export async function startProxy(opts = {}) {
|
|
|
2694
2808
|
// see, not traffic to quietly re-bill somewhere else.
|
|
2695
2809
|
const fallbackViable = poolFallbackModels.length > 0 && pool.size > 0;
|
|
2696
2810
|
if (!fallbackViable) {
|
|
2697
|
-
|
|
2811
|
+
if (parkedUntil !== null)
|
|
2812
|
+
writePoolParked(parkedUntil);
|
|
2813
|
+
else
|
|
2814
|
+
writePoolUnavailable();
|
|
2698
2815
|
return false;
|
|
2699
2816
|
}
|
|
2700
2817
|
}
|
|
@@ -2757,8 +2874,11 @@ export async function startProxy(opts = {}) {
|
|
|
2757
2874
|
const v = JSON.parse(text);
|
|
2758
2875
|
if (v === null || typeof v !== 'object' || Array.isArray(v))
|
|
2759
2876
|
invalid = 'request body must be a JSON object';
|
|
2760
|
-
else
|
|
2877
|
+
else {
|
|
2761
2878
|
parsedBody = v;
|
|
2879
|
+
if (!consumer)
|
|
2880
|
+
consumer = consumerFromBody(parsedBody);
|
|
2881
|
+
}
|
|
2762
2882
|
}
|
|
2763
2883
|
catch (err) {
|
|
2764
2884
|
invalid = `request body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -3055,6 +3175,7 @@ export async function startProxy(opts = {}) {
|
|
|
3055
3175
|
codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
|
|
3056
3176
|
analytics.record({
|
|
3057
3177
|
timestamp: Date.now(),
|
|
3178
|
+
consumer,
|
|
3058
3179
|
account: o.alias,
|
|
3059
3180
|
model: o.model || rawModel || 'codex',
|
|
3060
3181
|
inputTokens: o.inputTokens, outputTokens: o.outputTokens,
|
|
@@ -3072,13 +3193,13 @@ export async function startProxy(opts = {}) {
|
|
|
3072
3193
|
method: req.method ?? '', path: urlPath, model: o.model || rawModel || undefined,
|
|
3073
3194
|
status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
|
|
3074
3195
|
cache_read: o.cacheReadTokens, cache_create: o.cacheCreateTokens,
|
|
3075
|
-
claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, stream: o.stream,
|
|
3196
|
+
claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, consumer, stream: o.stream,
|
|
3076
3197
|
});
|
|
3077
3198
|
if (verbose)
|
|
3078
3199
|
console.log(formatUsageLogLine(codexReq, {
|
|
3079
3200
|
inputTokens: o.inputTokens, outputTokens: o.outputTokens,
|
|
3080
3201
|
cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
|
|
3081
|
-
}));
|
|
3202
|
+
}, consumer));
|
|
3082
3203
|
},
|
|
3083
3204
|
// Cool codex on a rate limit only — a 5xx or an unreachable backend
|
|
3084
3205
|
// is an outage, and parking a provider for that would keep it out
|
|
@@ -3222,6 +3343,13 @@ export async function startProxy(opts = {}) {
|
|
|
3222
3343
|
// would fall through to the Claude path with no account and an empty
|
|
3223
3344
|
// bearer token, turning a clean 503 into a confusing upstream 401.
|
|
3224
3345
|
if (!upstreamApiKey && !poolAccount) {
|
|
3346
|
+
// A fallback was armed but nothing could serve, and the pool itself is
|
|
3347
|
+
// parked: the exact reset beats a cool-down estimate (dario#1244).
|
|
3348
|
+
const parkedNow = pool.parkedUntil();
|
|
3349
|
+
if (parkedNow !== null) {
|
|
3350
|
+
writePoolParked(parkedNow);
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3225
3353
|
// A chain where every entry is cooling is a rate limit, not a
|
|
3226
3354
|
// misconfiguration — say so in the machine-readable way, once.
|
|
3227
3355
|
if (allProvidersCooled(poolFallbackModels.length > 0 ? ['codex', 'claude'] : ['claude'], providerCooldowns)) {
|
|
@@ -3332,7 +3460,20 @@ export async function startProxy(opts = {}) {
|
|
|
3332
3460
|
// Rotating off mid-session costs cache-create on every turn.
|
|
3333
3461
|
stickyKey = computeStickyKey(userMsg);
|
|
3334
3462
|
if (stickyKey && !pinnedAccount) {
|
|
3463
|
+
// Shared state (pool-sync.ts): a conversation a peer instance
|
|
3464
|
+
// already bound lands on the same seat here, so its prompt
|
|
3465
|
+
// cache is read rather than rewritten. Only consulted when this
|
|
3466
|
+
// instance holds no binding of its own; a binding made here is
|
|
3467
|
+
// published for the peers.
|
|
3468
|
+
const hadLocalBinding = pool.stickyAliasFor(stickyKey) !== null;
|
|
3469
|
+
if (poolSync && !hadLocalBinding) {
|
|
3470
|
+
const peerAlias = await poolSync.lookupSticky(stickyKey);
|
|
3471
|
+
if (peerAlias)
|
|
3472
|
+
pool.rebindSticky(stickyKey, peerAlias);
|
|
3473
|
+
}
|
|
3335
3474
|
const preferred = pool.selectSticky(stickyKey, modelFamily(requestModel));
|
|
3475
|
+
if (poolSync && preferred && !hadLocalBinding)
|
|
3476
|
+
poolSync.bindSticky(stickyKey, preferred.alias);
|
|
3336
3477
|
if (preferred && preferred.alias !== poolAccount?.alias) {
|
|
3337
3478
|
poolAccount = preferred;
|
|
3338
3479
|
accessToken = preferred.accessToken;
|
|
@@ -3746,6 +3887,14 @@ export async function startProxy(opts = {}) {
|
|
|
3746
3887
|
else {
|
|
3747
3888
|
pool.updateRateLimits(poolAccount.alias, snapshot);
|
|
3748
3889
|
}
|
|
3890
|
+
// Which organization answered (dario#1244). Held in the pool at
|
|
3891
|
+
// once; written to the seat's record by its next token refresh —
|
|
3892
|
+
// the write that already exists — so nothing here races a refresh.
|
|
3893
|
+
const organizationId = upstream.headers.get('anthropic-organization-id');
|
|
3894
|
+
if (organizationId)
|
|
3895
|
+
pool.noteOrganization(poolAccount.alias, organizationId);
|
|
3896
|
+
announceWindowPeers(poolAccount.alias);
|
|
3897
|
+
poolSync?.reportSeat(poolAccount.alias);
|
|
3749
3898
|
// First-sight detector for per-model rate-limit buckets. Anthropic
|
|
3750
3899
|
// ships these unannounced — e.g. `7d_sonnet-utilization` appeared
|
|
3751
3900
|
// around 2026-04-25 — and verbose-mode users want a heads-up the
|
|
@@ -4021,6 +4170,7 @@ export async function startProxy(opts = {}) {
|
|
|
4021
4170
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
4022
4171
|
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
4023
4172
|
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
4173
|
+
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
4024
4174
|
peekedBody = null;
|
|
4025
4175
|
continue dispatchLoop;
|
|
4026
4176
|
}
|
|
@@ -4068,6 +4218,7 @@ export async function startProxy(opts = {}) {
|
|
|
4068
4218
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
4069
4219
|
analytics.record({
|
|
4070
4220
|
timestamp: Date.now(),
|
|
4221
|
+
consumer,
|
|
4071
4222
|
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
4072
4223
|
model: requestModel,
|
|
4073
4224
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
@@ -4121,6 +4272,7 @@ export async function startProxy(opts = {}) {
|
|
|
4121
4272
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
4122
4273
|
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
4123
4274
|
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
4275
|
+
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
4124
4276
|
continue dispatchLoop;
|
|
4125
4277
|
}
|
|
4126
4278
|
// No peer available — forward the saved generic-403 bytes when the
|
|
@@ -4154,6 +4306,7 @@ export async function startProxy(opts = {}) {
|
|
|
4154
4306
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
4155
4307
|
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
4156
4308
|
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
4309
|
+
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
4157
4310
|
continue dispatchLoop;
|
|
4158
4311
|
}
|
|
4159
4312
|
}
|
|
@@ -4189,6 +4342,7 @@ export async function startProxy(opts = {}) {
|
|
|
4189
4342
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
4190
4343
|
analytics.record({
|
|
4191
4344
|
timestamp: Date.now(),
|
|
4345
|
+
consumer,
|
|
4192
4346
|
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
4193
4347
|
model: requestModel,
|
|
4194
4348
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
@@ -4446,6 +4600,7 @@ export async function startProxy(opts = {}) {
|
|
|
4446
4600
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
4447
4601
|
analytics.record({
|
|
4448
4602
|
timestamp: Date.now(),
|
|
4603
|
+
consumer,
|
|
4449
4604
|
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
4450
4605
|
model: requestModel,
|
|
4451
4606
|
inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
|
|
@@ -4465,6 +4620,7 @@ export async function startProxy(opts = {}) {
|
|
|
4465
4620
|
claim: poolAccount?.rateLimit.claim,
|
|
4466
4621
|
bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
|
|
4467
4622
|
account: poolAccount?.alias,
|
|
4623
|
+
consumer,
|
|
4468
4624
|
client: detectedClientForLog,
|
|
4469
4625
|
preserve_tools: preserveToolsEffective,
|
|
4470
4626
|
stream: true,
|
|
@@ -4473,7 +4629,7 @@ export async function startProxy(opts = {}) {
|
|
|
4473
4629
|
console.log(formatUsageLogLine(requestCount, {
|
|
4474
4630
|
inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
|
|
4475
4631
|
cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
|
|
4476
|
-
}));
|
|
4632
|
+
}, consumer));
|
|
4477
4633
|
}
|
|
4478
4634
|
else {
|
|
4479
4635
|
// Buffer and forward
|
|
@@ -4512,6 +4668,7 @@ export async function startProxy(opts = {}) {
|
|
|
4512
4668
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
4513
4669
|
analytics.record({
|
|
4514
4670
|
timestamp: Date.now(),
|
|
4671
|
+
consumer,
|
|
4515
4672
|
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
4516
4673
|
model: bufferedUsage.model || requestModel,
|
|
4517
4674
|
inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
|
|
@@ -4533,12 +4690,13 @@ export async function startProxy(opts = {}) {
|
|
|
4533
4690
|
claim: poolAccount?.rateLimit.claim,
|
|
4534
4691
|
bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
|
|
4535
4692
|
account: poolAccount?.alias,
|
|
4693
|
+
consumer,
|
|
4536
4694
|
client: detectedClientForLog,
|
|
4537
4695
|
preserve_tools: preserveToolsEffective,
|
|
4538
4696
|
stream: false,
|
|
4539
4697
|
});
|
|
4540
4698
|
if (verbose && bufferedUsage)
|
|
4541
|
-
console.log(formatUsageLogLine(requestCount, bufferedUsage));
|
|
4699
|
+
console.log(formatUsageLogLine(requestCount, bufferedUsage, consumer));
|
|
4542
4700
|
if (verbose)
|
|
4543
4701
|
console.log(`[dario] #${requestCount} ${upstream.status}`);
|
|
4544
4702
|
}
|
|
@@ -4550,6 +4708,7 @@ export async function startProxy(opts = {}) {
|
|
|
4550
4708
|
ts: new Date().toISOString(), req: requestCount,
|
|
4551
4709
|
method: req.method ?? '', path: urlPath,
|
|
4552
4710
|
model: requestModel || undefined,
|
|
4711
|
+
consumer,
|
|
4553
4712
|
client: detectedClientForLog,
|
|
4554
4713
|
preserve_tools: preserveToolsEffective,
|
|
4555
4714
|
};
|
|
@@ -4590,7 +4749,7 @@ export async function startProxy(opts = {}) {
|
|
|
4590
4749
|
clearTimeout(upstreamTimeout);
|
|
4591
4750
|
if (onClientClose !== null)
|
|
4592
4751
|
req.off('close', onClientClose);
|
|
4593
|
-
queue.release();
|
|
4752
|
+
queue.release(consumerFromHeaders);
|
|
4594
4753
|
}
|
|
4595
4754
|
});
|
|
4596
4755
|
server.on('error', async (err) => {
|
|
@@ -4819,6 +4978,7 @@ export async function startProxy(opts = {}) {
|
|
|
4819
4978
|
console.log('\n[dario] Shutting down...');
|
|
4820
4979
|
clearInterval(presenceInterval);
|
|
4821
4980
|
clearInterval(refreshInterval);
|
|
4981
|
+
poolSync?.stop();
|
|
4822
4982
|
if (logFileStream)
|
|
4823
4983
|
logFileStream.end();
|
|
4824
4984
|
// Flush tokens first (best-effort, bounded), then close the server. The
|
package/dist/request-queue.d.ts
CHANGED
|
@@ -52,6 +52,10 @@ export interface QueueState {
|
|
|
52
52
|
*/
|
|
53
53
|
export interface QueueSnapshot extends QueueState {
|
|
54
54
|
stalledSince: number | null;
|
|
55
|
+
/** Per-consumer in-flight ceiling (`--max-concurrent-per-consumer`); 0 = off. */
|
|
56
|
+
maxConcurrentPerConsumer: number;
|
|
57
|
+
/** Distinct consumers with a request in flight right now. */
|
|
58
|
+
consumersActive: number;
|
|
55
59
|
}
|
|
56
60
|
export type AdmitDecision = {
|
|
57
61
|
action: 'admit';
|
|
@@ -63,6 +67,14 @@ export type AdmitDecision = {
|
|
|
63
67
|
};
|
|
64
68
|
/** Pure admission decision — no side effects, no clock dep. */
|
|
65
69
|
export declare function decideAdmit(state: QueueState): AdmitDecision;
|
|
70
|
+
/**
|
|
71
|
+
* Pure per-consumer gate (dario#1244 follow-up — a team gateway where one
|
|
72
|
+
* heavy user could hold every slot). A consumer already holding `cap` slots
|
|
73
|
+
* waits even when the queue has room: `enqueue` if it does, `reject` if
|
|
74
|
+
* not. Returns null when the gate does not apply (cap off, or the consumer
|
|
75
|
+
* is under it), so `decideAdmit` decides as before.
|
|
76
|
+
*/
|
|
77
|
+
export declare function decideConsumerAdmit(activeForConsumer: number, cap: number, state: QueueState): AdmitDecision | null;
|
|
66
78
|
/** Pure timeout check — separated so tests can pass an explicit clock. */
|
|
67
79
|
export declare function isQueueEntryExpired(enqueuedAt: number, now: number, timeoutMs: number): boolean;
|
|
68
80
|
export declare class QueueFullError extends Error {
|
|
@@ -75,6 +87,13 @@ export interface RequestQueueOptions {
|
|
|
75
87
|
maxConcurrent?: number;
|
|
76
88
|
maxQueued?: number;
|
|
77
89
|
queueTimeoutMs?: number;
|
|
90
|
+
/**
|
|
91
|
+
* In-flight ceiling per consumer (see `acquire(consumer)`). 0 / unset =
|
|
92
|
+
* off. A consumer at its cap waits in the queue; its waiters never block
|
|
93
|
+
* another consumer's — `release` admits the first waiter whose consumer
|
|
94
|
+
* is under the cap, not the first waiter.
|
|
95
|
+
*/
|
|
96
|
+
maxConcurrentPerConsumer?: number;
|
|
78
97
|
/**
|
|
79
98
|
* Whether timeout timers are `unref`'d so they don't by themselves keep
|
|
80
99
|
* the Node event loop alive. Default `true` — appropriate for the proxy,
|
|
@@ -95,8 +114,10 @@ export declare class RequestQueue {
|
|
|
95
114
|
readonly maxConcurrent: number;
|
|
96
115
|
readonly maxQueued: number;
|
|
97
116
|
readonly queueTimeoutMs: number;
|
|
117
|
+
readonly maxConcurrentPerConsumer: number;
|
|
98
118
|
readonly unrefTimers: boolean;
|
|
99
119
|
private active;
|
|
120
|
+
private activeByConsumer;
|
|
100
121
|
private queue;
|
|
101
122
|
private readonly now;
|
|
102
123
|
private stalledSince;
|
|
@@ -111,15 +132,24 @@ export declare class RequestQueue {
|
|
|
111
132
|
* `release()` refreshes it, by clearing first (see there).
|
|
112
133
|
*/
|
|
113
134
|
private updateStall;
|
|
135
|
+
/** A consumer is under its cap when there is no cap, no consumer, or room. */
|
|
136
|
+
private underCap;
|
|
137
|
+
private admit;
|
|
114
138
|
/**
|
|
115
139
|
* Acquire a concurrency slot. Resolves when admitted; throws
|
|
116
140
|
* `QueueFullError` when the queue is at its `maxQueued` cap, throws
|
|
117
141
|
* `QueueTimeoutError` when a queued request waited longer than
|
|
118
|
-
* `queueTimeoutMs`.
|
|
142
|
+
* `queueTimeoutMs`. `consumer` names who the request is for: with a
|
|
143
|
+
* per-consumer cap set, a consumer at its cap waits even while slots are
|
|
144
|
+
* free, and `release(consumer)` must be called with the same name.
|
|
145
|
+
*/
|
|
146
|
+
acquire(consumer?: string): Promise<void>;
|
|
147
|
+
/**
|
|
148
|
+
* Release a slot. The first queued entry whose consumer is under its cap is
|
|
149
|
+
* admitted — FIFO among the admissible, so a capped consumer's waiters do
|
|
150
|
+
* not hold up anyone else's; they get in when that consumer releases.
|
|
119
151
|
*/
|
|
120
|
-
|
|
121
|
-
/** Release a slot. The next queued entry (if any) is admitted in FIFO order. */
|
|
122
|
-
release(): void;
|
|
152
|
+
release(consumer?: string): void;
|
|
123
153
|
/** Snapshot of queue state — exposed for /health + /analytics + tests. */
|
|
124
154
|
snapshot(): QueueSnapshot;
|
|
125
155
|
}
|