@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/README.md +39 -59
- package/dist/accounts.d.ts +18 -0
- package/dist/accounts.js +14 -0
- package/dist/admin-api.d.ts +27 -3
- package/dist/admin-api.js +20 -1
- package/dist/analytics.d.ts +41 -1
- package/dist/analytics.js +65 -2
- package/dist/cli.js +94 -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 +97 -1
- package/dist/pool.js +156 -7
- package/dist/proxy.d.ts +14 -0
- package/dist/proxy.js +136 -13
- package/dist/request-queue.d.ts +34 -4
- package/dist/request-queue.js +57 -11
- package/dist/tui/tabs/accounts.d.ts +2 -0
- package/dist/tui/tabs/accounts.js +15 -2
- package/dist/tui/tabs/hits.js +2 -0
- package/docs/admin-api.md +18 -6
- package/docs/configuration.md +10 -0
- package/docs/integrations/openclaw-walkthrough.md +2 -2
- package/docs/multi-account-pool.md +39 -0
- 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 } from './pool.js';
|
|
16
|
-
import {
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
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
|
}
|
package/dist/request-queue.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
/**
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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.
|
|
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
|
-
|
|
102
|
-
const
|
|
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('—');
|
package/dist/tui/tabs/hits.js
CHANGED
|
@@ -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));
|