@askalf/dario 6.0.32 → 6.0.34

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/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 } from './pool.js';
16
- import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, CODEX_CLAIM } from './analytics.js';
15
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, describeRateLimitSnapshot, windowPeers, distinctWindows } 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';
@@ -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
- const refreshed = await refreshAccountToken(saved);
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
- const refreshed = await refreshAccountToken(saved);
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
  }
@@ -1616,6 +1659,7 @@ export async function startProxy(opts = {}) {
1616
1659
  maxConcurrent: opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT,
1617
1660
  maxQueued: opts.maxQueued ?? DEFAULT_MAX_QUEUED,
1618
1661
  queueTimeoutMs: opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS,
1662
+ maxConcurrentPerConsumer: opts.maxConcurrentPerConsumer ?? 0,
1619
1663
  });
1620
1664
  const upstreamTimeoutMs = opts.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS;
1621
1665
  // Cache context-1m beta availability. Set false once per account after the
@@ -2093,6 +2137,7 @@ export async function startProxy(opts = {}) {
2093
2137
  // just persisted metadata — the same snapshot GET /accounts exposes.
2094
2138
  poolStatus: () => {
2095
2139
  const snapNow = Date.now();
2140
+ const peers = windowPeers(pool.all(), snapNow);
2096
2141
  const snap = new Map();
2097
2142
  for (const a of pool.all()) {
2098
2143
  snap.set(a.alias, {
@@ -2102,9 +2147,15 @@ export async function startProxy(opts = {}) {
2102
2147
  // surface documents itself as reporting the same snapshot, so it
2103
2148
  // must not be the one place a stale reading still looks current.
2104
2149
  ...utilFreshness(a.rateLimit, snapNow),
2150
+ ...rateLimitWindow(a.rateLimit, snapNow),
2105
2151
  claim: a.rateLimit.claim,
2106
2152
  status: reportedAccountStatus(a, snapNow),
2107
2153
  requestCount: a.requestCount,
2154
+ rejectedCount: a.rejectedCount,
2155
+ lastRejectedAt: a.lastRejectedAt ?? null,
2156
+ organizationId: a.organizationId ?? null,
2157
+ sharesWindowWith: peers.get(a.alias) ?? [],
2158
+ readingFrom: a.adoptedFrom ?? null,
2108
2159
  // Raw streak, not just the cooldown boolean: a single 401 also
2109
2160
  // shows `auth-cooldown` for 60s, indistinguishable from a
2110
2161
  // genuinely dead refresh token by that field alone. The magnitude
@@ -2176,6 +2227,7 @@ export async function startProxy(opts = {}) {
2176
2227
  // the `dario accounts` CLI, not HTTP.
2177
2228
  if (urlPath === '/accounts' && req.method === 'GET') {
2178
2229
  const now = Date.now();
2230
+ const peers = windowPeers(pool.all(), now);
2179
2231
  const accounts = pool.all().map(a => {
2180
2232
  const inCooldown = isInAuthCooldown(a, now);
2181
2233
  const cooldownMs = inCooldown && a.lastAuthFailureAt
@@ -2201,9 +2253,24 @@ export async function startProxy(opts = {}) {
2201
2253
  util5h: a.rateLimit.util5h,
2202
2254
  util7d: a.rateLimit.util7d,
2203
2255
  ...utilFreshness(a.rateLimit, now),
2256
+ // When that window rolls (dario#1244): for a rejected seat, when
2257
+ // the rejection lifts. Milliseconds, like expiresInMs.
2258
+ ...rateLimitWindow(a.rateLimit, now),
2204
2259
  claim: a.rateLimit.claim,
2205
2260
  status: reportedAccountStatus(a, now),
2206
2261
  requestCount: a.requestCount,
2262
+ // 429s answered — the attempts requestCount does not count, so a
2263
+ // parked seat no longer reads as one that was never called.
2264
+ rejectedCount: a.rejectedCount,
2265
+ lastRejectedAt: a.lastRejectedAt ?? null,
2266
+ // Which organization the token belongs to, and which other seats
2267
+ // report the same live window — one subscription under several
2268
+ // aliases (dario#1244).
2269
+ organizationId: a.organizationId ?? null,
2270
+ sharesWindowWith: peers.get(a.alias) ?? [],
2271
+ // Whose reading this is: a peer instance's id (shared pool state)
2272
+ // or null for this instance's own.
2273
+ readingFrom: a.adoptedFrom ?? null,
2207
2274
  expiresInMs: Math.max(0, a.expiresAt - now),
2208
2275
  // Refresh-token grant age (refresh-grant.ts): the wall a token
2209
2276
  // refresh cannot move. null fields = grant date unknown.
@@ -2226,6 +2293,11 @@ export async function startProxy(opts = {}) {
2226
2293
  mode: 'pool',
2227
2294
  ...pool.status(),
2228
2295
  stickyBindings: pool.stickyCount(),
2296
+ // Windows the pool really has: each measured window once, each
2297
+ // unmeasured seat as its own.
2298
+ distinctWindows: distinctWindows(pool.all(), now),
2299
+ // Shared pool state (pool-sync.ts) — null when off.
2300
+ sharedState: poolSync ? poolSync.status() : null,
2229
2301
  accounts,
2230
2302
  }));
2231
2303
  return;
@@ -2452,12 +2524,19 @@ export async function startProxy(opts = {}) {
2452
2524
  res.end(JSON.stringify(buildHaltErrorBody(state)));
2453
2525
  return;
2454
2526
  }
2527
+ // Who this request is for (dario#1244 follow-up). An `x-dario-consumer`
2528
+ // header names the consumer for both the per-consumer concurrency cap and
2529
+ // attribution. Without one, attribution falls back to a hash of the
2530
+ // body's user id once the body is parsed; the cap needs the name before
2531
+ // the slot is taken, so only the header gates.
2532
+ const consumerFromHeaders = consumerFromHeader(req.headers[CONSUMER_HEADER]);
2533
+ let consumer = consumerFromHeaders;
2455
2534
  // Proxy to Anthropic (with concurrency control). The bounded queue
2456
2535
  // replaces the v3.30.x-and-earlier unbounded semaphore — dario#80. A
2457
2536
  // queue-full condition returns an explicit 429 with a `"queue-full"`
2458
2537
  // marker in the body; a queue-timeout returns 504 with `"queue-timeout"`.
2459
2538
  try {
2460
- await queue.acquire();
2539
+ await queue.acquire(consumerFromHeaders);
2461
2540
  }
2462
2541
  catch (err) {
2463
2542
  if (err instanceof QueueFullError) {
@@ -2747,8 +2826,11 @@ export async function startProxy(opts = {}) {
2747
2826
  const v = JSON.parse(text);
2748
2827
  if (v === null || typeof v !== 'object' || Array.isArray(v))
2749
2828
  invalid = 'request body must be a JSON object';
2750
- else
2829
+ else {
2751
2830
  parsedBody = v;
2831
+ if (!consumer)
2832
+ consumer = consumerFromBody(parsedBody);
2833
+ }
2752
2834
  }
2753
2835
  catch (err) {
2754
2836
  invalid = `request body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`;
@@ -3045,6 +3127,7 @@ export async function startProxy(opts = {}) {
3045
3127
  codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
3046
3128
  analytics.record({
3047
3129
  timestamp: Date.now(),
3130
+ consumer,
3048
3131
  account: o.alias,
3049
3132
  model: o.model || rawModel || 'codex',
3050
3133
  inputTokens: o.inputTokens, outputTokens: o.outputTokens,
@@ -3062,13 +3145,13 @@ export async function startProxy(opts = {}) {
3062
3145
  method: req.method ?? '', path: urlPath, model: o.model || rawModel || undefined,
3063
3146
  status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
3064
3147
  cache_read: o.cacheReadTokens, cache_create: o.cacheCreateTokens,
3065
- claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, stream: o.stream,
3148
+ claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, consumer, stream: o.stream,
3066
3149
  });
3067
3150
  if (verbose)
3068
3151
  console.log(formatUsageLogLine(codexReq, {
3069
3152
  inputTokens: o.inputTokens, outputTokens: o.outputTokens,
3070
3153
  cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
3071
- }));
3154
+ }, consumer));
3072
3155
  },
3073
3156
  // Cool codex on a rate limit only — a 5xx or an unreachable backend
3074
3157
  // is an outage, and parking a provider for that would keep it out
@@ -3322,7 +3405,20 @@ export async function startProxy(opts = {}) {
3322
3405
  // Rotating off mid-session costs cache-create on every turn.
3323
3406
  stickyKey = computeStickyKey(userMsg);
3324
3407
  if (stickyKey && !pinnedAccount) {
3408
+ // Shared state (pool-sync.ts): a conversation a peer instance
3409
+ // already bound lands on the same seat here, so its prompt
3410
+ // cache is read rather than rewritten. Only consulted when this
3411
+ // instance holds no binding of its own; a binding made here is
3412
+ // published for the peers.
3413
+ const hadLocalBinding = pool.stickyAliasFor(stickyKey) !== null;
3414
+ if (poolSync && !hadLocalBinding) {
3415
+ const peerAlias = await poolSync.lookupSticky(stickyKey);
3416
+ if (peerAlias)
3417
+ pool.rebindSticky(stickyKey, peerAlias);
3418
+ }
3325
3419
  const preferred = pool.selectSticky(stickyKey, modelFamily(requestModel));
3420
+ if (poolSync && preferred && !hadLocalBinding)
3421
+ poolSync.bindSticky(stickyKey, preferred.alias);
3326
3422
  if (preferred && preferred.alias !== poolAccount?.alias) {
3327
3423
  poolAccount = preferred;
3328
3424
  accessToken = preferred.accessToken;
@@ -3723,11 +3819,27 @@ export async function startProxy(opts = {}) {
3723
3819
  if (poolAccount) {
3724
3820
  const snapshot = parseRateLimits(upstream.headers);
3725
3821
  if (upstream.status === 429) {
3726
- pool.markRejected(poolAccount.alias, snapshot);
3822
+ // Say so the moment a seat leaves rotation. With a peer to fail
3823
+ // over to the client sees 200, and nothing else named the seat,
3824
+ // the reading, or when it comes back (dario#1244). Once per
3825
+ // parking: the all-exhausted fallback re-probes parked seats, and
3826
+ // those repeats are verbose-only.
3827
+ const parked = pool.markRejected(poolAccount.alias, snapshot);
3828
+ if (parked || verbose) {
3829
+ console.error(`[dario] #${requestCount} rate limited (429) on account "${poolAccount.alias}": ${describeRateLimitSnapshot(snapshot)} — parked until the window rolls`);
3830
+ }
3727
3831
  }
3728
3832
  else {
3729
3833
  pool.updateRateLimits(poolAccount.alias, snapshot);
3730
3834
  }
3835
+ // Which organization answered (dario#1244). Held in the pool at
3836
+ // once; written to the seat's record by its next token refresh —
3837
+ // the write that already exists — so nothing here races a refresh.
3838
+ const organizationId = upstream.headers.get('anthropic-organization-id');
3839
+ if (organizationId)
3840
+ pool.noteOrganization(poolAccount.alias, organizationId);
3841
+ announceWindowPeers(poolAccount.alias);
3842
+ poolSync?.reportSeat(poolAccount.alias);
3731
3843
  // First-sight detector for per-model rate-limit buckets. Anthropic
3732
3844
  // ships these unannounced — e.g. `7d_sonnet-utilization` appeared
3733
3845
  // around 2026-04-25 — and verbose-mode users want a heads-up the
@@ -4003,6 +4115,7 @@ export async function startProxy(opts = {}) {
4003
4115
  headers['Authorization'] = `Bearer ${accessToken}`;
4004
4116
  headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
4005
4117
  pool.rebindSticky(stickyKey, nextAccount.alias);
4118
+ poolSync?.bindSticky(stickyKey, nextAccount.alias);
4006
4119
  peekedBody = null;
4007
4120
  continue dispatchLoop;
4008
4121
  }
@@ -4050,6 +4163,7 @@ export async function startProxy(opts = {}) {
4050
4163
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
4051
4164
  analytics.record({
4052
4165
  timestamp: Date.now(),
4166
+ consumer,
4053
4167
  account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
4054
4168
  model: requestModel,
4055
4169
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
@@ -4103,6 +4217,7 @@ export async function startProxy(opts = {}) {
4103
4217
  headers['Authorization'] = `Bearer ${accessToken}`;
4104
4218
  headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
4105
4219
  pool.rebindSticky(stickyKey, nextAccount.alias);
4220
+ poolSync?.bindSticky(stickyKey, nextAccount.alias);
4106
4221
  continue dispatchLoop;
4107
4222
  }
4108
4223
  // No peer available — forward the saved generic-403 bytes when the
@@ -4136,6 +4251,7 @@ export async function startProxy(opts = {}) {
4136
4251
  headers['Authorization'] = `Bearer ${accessToken}`;
4137
4252
  headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
4138
4253
  pool.rebindSticky(stickyKey, nextAccount.alias);
4254
+ poolSync?.bindSticky(stickyKey, nextAccount.alias);
4139
4255
  continue dispatchLoop;
4140
4256
  }
4141
4257
  }
@@ -4171,6 +4287,7 @@ export async function startProxy(opts = {}) {
4171
4287
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
4172
4288
  analytics.record({
4173
4289
  timestamp: Date.now(),
4290
+ consumer,
4174
4291
  account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
4175
4292
  model: requestModel,
4176
4293
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
@@ -4428,6 +4545,7 @@ export async function startProxy(opts = {}) {
4428
4545
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
4429
4546
  analytics.record({
4430
4547
  timestamp: Date.now(),
4548
+ consumer,
4431
4549
  account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
4432
4550
  model: requestModel,
4433
4551
  inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
@@ -4447,6 +4565,7 @@ export async function startProxy(opts = {}) {
4447
4565
  claim: poolAccount?.rateLimit.claim,
4448
4566
  bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
4449
4567
  account: poolAccount?.alias,
4568
+ consumer,
4450
4569
  client: detectedClientForLog,
4451
4570
  preserve_tools: preserveToolsEffective,
4452
4571
  stream: true,
@@ -4455,7 +4574,7 @@ export async function startProxy(opts = {}) {
4455
4574
  console.log(formatUsageLogLine(requestCount, {
4456
4575
  inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
4457
4576
  cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
4458
- }));
4577
+ }, consumer));
4459
4578
  }
4460
4579
  else {
4461
4580
  // Buffer and forward
@@ -4494,6 +4613,7 @@ export async function startProxy(opts = {}) {
4494
4613
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
4495
4614
  analytics.record({
4496
4615
  timestamp: Date.now(),
4616
+ consumer,
4497
4617
  account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
4498
4618
  model: bufferedUsage.model || requestModel,
4499
4619
  inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
@@ -4515,12 +4635,13 @@ export async function startProxy(opts = {}) {
4515
4635
  claim: poolAccount?.rateLimit.claim,
4516
4636
  bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
4517
4637
  account: poolAccount?.alias,
4638
+ consumer,
4518
4639
  client: detectedClientForLog,
4519
4640
  preserve_tools: preserveToolsEffective,
4520
4641
  stream: false,
4521
4642
  });
4522
4643
  if (verbose && bufferedUsage)
4523
- console.log(formatUsageLogLine(requestCount, bufferedUsage));
4644
+ console.log(formatUsageLogLine(requestCount, bufferedUsage, consumer));
4524
4645
  if (verbose)
4525
4646
  console.log(`[dario] #${requestCount} ${upstream.status}`);
4526
4647
  }
@@ -4532,6 +4653,7 @@ export async function startProxy(opts = {}) {
4532
4653
  ts: new Date().toISOString(), req: requestCount,
4533
4654
  method: req.method ?? '', path: urlPath,
4534
4655
  model: requestModel || undefined,
4656
+ consumer,
4535
4657
  client: detectedClientForLog,
4536
4658
  preserve_tools: preserveToolsEffective,
4537
4659
  };
@@ -4572,7 +4694,7 @@ export async function startProxy(opts = {}) {
4572
4694
  clearTimeout(upstreamTimeout);
4573
4695
  if (onClientClose !== null)
4574
4696
  req.off('close', onClientClose);
4575
- queue.release();
4697
+ queue.release(consumerFromHeaders);
4576
4698
  }
4577
4699
  });
4578
4700
  server.on('error', async (err) => {
@@ -4801,6 +4923,7 @@ export async function startProxy(opts = {}) {
4801
4923
  console.log('\n[dario] Shutting down...');
4802
4924
  clearInterval(presenceInterval);
4803
4925
  clearInterval(refreshInterval);
4926
+ poolSync?.stop();
4804
4927
  if (logFileStream)
4805
4928
  logFileStream.end();
4806
4929
  // Flush tokens first (best-effort, bounded), then close the server. The
@@ -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
- acquire(): Promise<void>;
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
  }
@@ -30,6 +30,20 @@ export function decideAdmit(state) {
30
30
  return { action: 'enqueue' };
31
31
  return { action: 'reject', reason: 'queue-full' };
32
32
  }
33
+ /**
34
+ * Pure per-consumer gate (dario#1244 follow-up — a team gateway where one
35
+ * heavy user could hold every slot). A consumer already holding `cap` slots
36
+ * waits even when the queue has room: `enqueue` if it does, `reject` if
37
+ * not. Returns null when the gate does not apply (cap off, or the consumer
38
+ * is under it), so `decideAdmit` decides as before.
39
+ */
40
+ export function decideConsumerAdmit(activeForConsumer, cap, state) {
41
+ if (cap <= 0 || activeForConsumer < cap)
42
+ return null;
43
+ if (state.queued < state.maxQueued)
44
+ return { action: 'enqueue' };
45
+ return { action: 'reject', reason: 'queue-full' };
46
+ }
33
47
  /** Pure timeout check — separated so tests can pass an explicit clock. */
34
48
  export function isQueueEntryExpired(enqueuedAt, now, timeoutMs) {
35
49
  return (now - enqueuedAt) > timeoutMs;
@@ -47,8 +61,10 @@ export class RequestQueue {
47
61
  maxConcurrent;
48
62
  maxQueued;
49
63
  queueTimeoutMs;
64
+ maxConcurrentPerConsumer;
50
65
  unrefTimers;
51
66
  active = 0;
67
+ activeByConsumer = new Map();
52
68
  queue = [];
53
69
  now;
54
70
  stalledSince = null;
@@ -56,6 +72,7 @@ export class RequestQueue {
56
72
  this.maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
57
73
  this.maxQueued = opts.maxQueued ?? DEFAULT_MAX_QUEUED;
58
74
  this.queueTimeoutMs = opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
75
+ this.maxConcurrentPerConsumer = Math.max(0, opts.maxConcurrentPerConsumer ?? 0);
59
76
  this.unrefTimers = opts.unrefTimers ?? true;
60
77
  this.now = opts.now ?? Date.now;
61
78
  }
@@ -77,17 +94,32 @@ export class RequestQueue {
77
94
  if (this.stalledSince === null)
78
95
  this.stalledSince = this.now();
79
96
  }
97
+ /** A consumer is under its cap when there is no cap, no consumer, or room. */
98
+ underCap(consumer) {
99
+ if (!consumer || this.maxConcurrentPerConsumer <= 0)
100
+ return true;
101
+ return (this.activeByConsumer.get(consumer) ?? 0) < this.maxConcurrentPerConsumer;
102
+ }
103
+ admit(consumer) {
104
+ this.active++;
105
+ if (consumer)
106
+ this.activeByConsumer.set(consumer, (this.activeByConsumer.get(consumer) ?? 0) + 1);
107
+ this.updateStall();
108
+ }
80
109
  /**
81
110
  * Acquire a concurrency slot. Resolves when admitted; throws
82
111
  * `QueueFullError` when the queue is at its `maxQueued` cap, throws
83
112
  * `QueueTimeoutError` when a queued request waited longer than
84
- * `queueTimeoutMs`.
113
+ * `queueTimeoutMs`. `consumer` names who the request is for: with a
114
+ * per-consumer cap set, a consumer at its cap waits even while slots are
115
+ * free, and `release(consumer)` must be called with the same name.
85
116
  */
86
- async acquire() {
87
- const decision = decideAdmit(this.snapshot());
117
+ async acquire(consumer) {
118
+ const state = this.snapshot();
119
+ const gated = consumer ? decideConsumerAdmit(this.activeByConsumer.get(consumer) ?? 0, this.maxConcurrentPerConsumer, state) : null;
120
+ const decision = gated ?? decideAdmit(state);
88
121
  if (decision.action === 'admit') {
89
- this.active++;
90
- this.updateStall();
122
+ this.admit(consumer);
91
123
  return;
92
124
  }
93
125
  if (decision.action === 'reject') {
@@ -108,19 +140,31 @@ export class RequestQueue {
108
140
  // Opt-out for tests — see `unrefTimers` comment in RequestQueueOptions.
109
141
  if (this.unrefTimers)
110
142
  timeoutHandle.unref?.();
111
- const entry = { resolve, reject, enqueuedAt, timeoutHandle };
143
+ const entry = { resolve, reject, enqueuedAt, timeoutHandle, consumer };
112
144
  this.queue.push(entry);
113
145
  this.updateStall();
114
146
  });
115
147
  }
116
- /** Release a slot. The next queued entry (if any) is admitted in FIFO order. */
117
- release() {
148
+ /**
149
+ * Release a slot. The first queued entry whose consumer is under its cap is
150
+ * admitted — FIFO among the admissible, so a capped consumer's waiters do
151
+ * not hold up anyone else's; they get in when that consumer releases.
152
+ */
153
+ release(consumer) {
118
154
  if (this.active > 0)
119
155
  this.active--;
120
- const next = this.queue.shift();
121
- if (next) {
156
+ if (consumer) {
157
+ const left = (this.activeByConsumer.get(consumer) ?? 0) - 1;
158
+ if (left <= 0)
159
+ this.activeByConsumer.delete(consumer);
160
+ else
161
+ this.activeByConsumer.set(consumer, left);
162
+ }
163
+ const idx = this.queue.findIndex((e) => this.underCap(e.consumer));
164
+ if (idx >= 0) {
165
+ const [next] = this.queue.splice(idx, 1);
122
166
  clearTimeout(next.timeoutHandle);
123
- this.active++;
167
+ this.admit(next.consumer);
124
168
  next.resolve();
125
169
  }
126
170
  // A release IS turnover — the thing whose absence defines the wedge — so
@@ -139,6 +183,8 @@ export class RequestQueue {
139
183
  maxConcurrent: this.maxConcurrent,
140
184
  maxQueued: this.maxQueued,
141
185
  stalledSince: this.stalledSince,
186
+ maxConcurrentPerConsumer: this.maxConcurrentPerConsumer,
187
+ consumersActive: this.activeByConsumer.size,
142
188
  };
143
189
  }
144
190
  }
@@ -34,6 +34,8 @@ export interface AccountsState {
34
34
  util5h?: number;
35
35
  util7d?: number;
36
36
  status?: string;
37
+ /** Ms until the seat's rate-limit window rolls; null/absent when unknown. */
38
+ resetInMs?: number | null;
37
39
  }>;
38
40
  error: string | null;
39
41
  /** Where the list came from: the running proxy's pool, the proxy's
@@ -98,8 +98,11 @@ export const AccountsTab = {
98
98
  const expiresCol = pad(formatExpiry(acc.expiresAt), 14);
99
99
  const u5 = pad(acc.util5h !== undefined ? `${Math.round(acc.util5h * 100)}%` : '—', 9);
100
100
  const u7 = pad(acc.util7d !== undefined ? `${Math.round(acc.util7d * 100)}%` : '—', 9);
101
- const statusCol = acc.status ?? '—';
102
- const statusFg = statusCol === 'auth-cooldown' ? fg('yellow', statusCol) : dim(statusCol);
101
+ // A parked seat says for how long (dario#1244): "rejected 37m".
102
+ const statusCol = acc.status === 'rejected' && typeof acc.resetInMs === 'number'
103
+ ? `rejected ${formatCountdown(acc.resetInMs)}`
104
+ : (acc.status ?? '—');
105
+ const statusFg = statusCol === 'auth-cooldown' || acc.status === 'rejected' ? fg('yellow', statusCol) : dim(statusCol);
103
106
  push(' ' + aliasCol + expiresCol + u5 + u7 + statusFg);
104
107
  }
105
108
  else {
@@ -143,6 +146,7 @@ export async function refreshAccounts(ctx) {
143
146
  util5h: a.util5h,
144
147
  util7d: a.util7d,
145
148
  status: a.status,
149
+ resetInMs: a.resetInMs,
146
150
  })),
147
151
  error: null,
148
152
  };
@@ -177,6 +181,15 @@ async function diskFallback() {
177
181
  return { loading: false, accounts: [], error: e.message, source: 'disk' };
178
182
  }
179
183
  }
184
+ /** `37m` / `4h59m` / `now` — how long until a parked seat's window rolls. */
185
+ function formatCountdown(ms) {
186
+ if (ms <= 0)
187
+ return 'now';
188
+ const totalMins = Math.max(1, Math.round(ms / 60_000));
189
+ const h = Math.floor(totalMins / 60);
190
+ const m = totalMins % 60;
191
+ return h > 0 ? `${h}h${m}m` : `${m}m`;
192
+ }
180
193
  function formatExpiry(expiresAt) {
181
194
  if (expiresAt === 0)
182
195
  return dim('—');
@@ -225,6 +225,8 @@ export const HitsTab = {
225
225
  const r = newestFirst[state.selectedIdx];
226
226
  lines.push(truncate(' ' + brand('Selected') + dim(` ${formatTime(r.timestamp)}`), w));
227
227
  lines.push(' ' + renderKvRow('Account', r.account, w - 4));
228
+ if (r.consumer)
229
+ lines.push(' ' + renderKvRow('Consumer', r.consumer, w - 4));
228
230
  lines.push(' ' + renderKvRow('Model', r.model, w - 4));
229
231
  lines.push(' ' + renderKvRow('Billing bucket', billingBucketFromClaim(r.claim), w - 4));
230
232
  lines.push(' ' + renderKvRow('Tokens', tokenBreakdown(r), w - 4));