@askalf/dario 6.12.6 → 6.12.8

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/pool.js CHANGED
@@ -453,9 +453,11 @@ export function describePoolStrategy(strategy, floor = DEFAULT_POOL_HEADROOM_FLO
453
453
  * CLI flag, which only the running process knows (GET /status reports it).
454
454
  */
455
455
  export function configuredPoolRouting(file, env = process.env) {
456
+ const floorCandidate = env.DARIO_POOL_HEADROOM_FLOOR ?? file?.headroomFloor;
456
457
  return {
457
458
  strategy: resolvePoolStrategy(env.DARIO_POOL_STRATEGY ?? file?.strategy, env),
458
- headroomFloor: resolvePoolHeadroomFloor(env.DARIO_POOL_HEADROOM_FLOOR ?? file?.headroomFloor, env),
459
+ headroomFloor: resolvePoolHeadroomFloor(floorCandidate, env),
460
+ headroomFloorExplicit: parsePoolHeadroomFloor(floorCandidate) !== null,
459
461
  };
460
462
  }
461
463
  /**
@@ -834,11 +836,56 @@ function pickMaxHeadroom(accounts, family) {
834
836
  }
835
837
  return best;
836
838
  }
839
+ function isAboveHeadroomFloor(headroom, floor) {
840
+ return headroom - floor > Number.EPSILON;
841
+ }
842
+ /**
843
+ * When a below-floor reading can retire without an upstream probe, return the
844
+ * first relevant reset in epoch ms. Null means at least one bucket keeping the
845
+ * seat below the floor has no future reset, so one request must refresh it.
846
+ */
847
+ function floorParkedUntil(snapshot, family, floor, now) {
848
+ const rl = expireElapsedWindow(snapshot, now);
849
+ const limiting = (util) => !isAboveHeadroomFloor(1 - util, floor);
850
+ const resets = [];
851
+ if (limiting(rl.util5h)) {
852
+ const reset = rl.claim === 'five_hour' ? rl.reset * 1000 : 0;
853
+ if (reset <= now)
854
+ return null;
855
+ resets.push(reset);
856
+ }
857
+ if (limiting(rl.util7d)) {
858
+ const reset = (rl.reset7d ?? 0) * 1000 > now
859
+ ? (rl.reset7d ?? 0) * 1000
860
+ : rl.claim.startsWith('seven_day') ? rl.reset * 1000 : 0;
861
+ if (reset <= now)
862
+ return null;
863
+ resets.push(reset);
864
+ }
865
+ if (family) {
866
+ for (const bucket of bucketsBindingFamily(rl, family)) {
867
+ const util = rl.perModel7d[bucket];
868
+ if (util === undefined || !limiting(util))
869
+ continue;
870
+ const reset = (rl.reset7d ?? 0) * 1000 > now
871
+ ? (rl.reset7d ?? 0) * 1000
872
+ : rl.claim.startsWith('seven_day') ? rl.reset * 1000 : 0;
873
+ if (reset <= now)
874
+ return null;
875
+ resets.push(reset);
876
+ }
877
+ }
878
+ return resets.length > 0 ? Math.min(...resets) : null;
879
+ }
880
+ function pickFloorProbe(accounts, family, floor, now) {
881
+ const stale = accounts.filter(a => floorParkedUntil(a.rateLimit, family, floor, now) === null);
882
+ return stale.length > 0 ? pickMaxHeadroom(stale, family) : null;
883
+ }
837
884
  // Fill-first pick: lexicographically-first eligible account still above the
838
885
  // headroom floor. Alias order (not insertion order) — accounts load from a
839
886
  // readdir whose order the OS doesn't guarantee, and the operator can control
840
887
  // alias names but not readdir. Returns null when every candidate is at/below
841
- // the floor so the caller can fall back to max-headroom.
888
+ // the floor.
842
889
  /**
843
890
  * The 7-day reset `expiring-first` orders by, in epoch ms: the seat's own
844
891
  * reading while it is still ahead of `now`, else +Infinity (never read, or the
@@ -851,7 +898,7 @@ export function seatExpiry(account, now = Date.now()) {
851
898
  function pickExpiringFirst(accounts, family, floor = DEFAULT_POOL_HEADROOM_FLOOR, now = Date.now()) {
852
899
  const order = [...accounts].sort((a, b) => (seatExpiry(a, now) - seatExpiry(b, now)) || (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0));
853
900
  for (const a of order) {
854
- if (computeHeadroom(a.rateLimit, family) > floor)
901
+ if (isAboveHeadroomFloor(computeHeadroom(a.rateLimit, family), floor))
855
902
  return a;
856
903
  }
857
904
  return null;
@@ -861,14 +908,13 @@ function pickFillFirst(accounts, family, floor = DEFAULT_POOL_HEADROOM_FLOOR) {
861
908
  for (const a of accounts) {
862
909
  if (best !== null && a.alias >= best.alias)
863
910
  continue;
864
- if (computeHeadroom(a.rateLimit, family) > floor)
911
+ if (isAboveHeadroomFloor(computeHeadroom(a.rateLimit, family), floor))
865
912
  best = a;
866
913
  }
867
914
  return best;
868
915
  }
869
916
  export class AccountPool {
870
917
  strategy;
871
- headroomFloor;
872
918
  accounts = new Map();
873
919
  queue = [];
874
920
  queueMaxSize = 50;
@@ -877,11 +923,14 @@ export class AccountPool {
877
923
  sticky = new Map();
878
924
  // Amortize the O(n) sticky TTL/orphan sweep — timestamp of the last run.
879
925
  lastStickyCleanup = 0;
880
- constructor(strategy = 'headroom',
881
926
  /** Headroom at/below which a seat counts as drained — see DEFAULT_POOL_HEADROOM_FLOOR. */
882
- headroomFloor = DEFAULT_POOL_HEADROOM_FLOOR) {
927
+ headroomFloor;
928
+ /** Only an explicitly configured floor is a hard stop; the implicit 2% remains a preference. */
929
+ hardHeadroomFloor;
930
+ constructor(strategy = 'headroom', headroomFloor) {
883
931
  this.strategy = strategy;
884
- this.headroomFloor = headroomFloor;
932
+ this.headroomFloor = headroomFloor ?? DEFAULT_POOL_HEADROOM_FLOOR;
933
+ this.hardHeadroomFloor = headroomFloor !== undefined;
885
934
  }
886
935
  add(alias, opts) {
887
936
  const existing = this.accounts.get(alias);
@@ -997,11 +1046,13 @@ export class AccountPool {
997
1046
  : pickExpiringFirst(eligible, family, this.headroomFloor, now);
998
1047
  if (first)
999
1048
  return first;
1000
- // Every eligible account is at/below the floor — the terminal state
1001
- // both strategies share. Fall through to max-headroom so the caller
1002
- // still gets the least-drained account instead of null.
1003
1049
  }
1004
- return pickMaxHeadroom(eligible, family);
1050
+ if (!this.hardHeadroomFloor)
1051
+ return pickMaxHeadroom(eligible, family);
1052
+ const aboveFloor = eligible.filter(a => isAboveHeadroomFloor(computeHeadroom(a.rateLimit, family, now), this.headroomFloor));
1053
+ return aboveFloor.length > 0
1054
+ ? pickMaxHeadroom(aboveFloor, family)
1055
+ : pickFloorProbe(eligible, family, this.headroomFloor, now);
1005
1056
  }
1006
1057
  // No seat is eligible. A seat parked inside a live window is not
1007
1058
  // re-probed: its 429 named the reset, the clock has not reached it, and a
@@ -1042,11 +1093,20 @@ export class AccountPool {
1042
1093
  // 429 again (dario#1264 review). An auth cool-down or an expired token
1043
1094
  // still does NOT count — those are not rate limits and must not be
1044
1095
  // reported, or cooled, as if they were.
1045
- if (!all.every(a => isParkedInLiveWindow(a, now, family) || isCoolingAfterRejection(a, now)))
1096
+ const until = all.map((a) => {
1097
+ if (isParkedInLiveWindow(a, now, family)) {
1098
+ return isBucketScopedRejection(a.rateLimit)
1099
+ ? (a.rateLimit.parkedBucketsUntil ?? a.rateLimit.reset * 1000)
1100
+ : a.rateLimit.reset * 1000;
1101
+ }
1102
+ if (isCoolingAfterRejection(a, now))
1103
+ return a.rateLimit.cooldownUntil ?? now;
1104
+ if (this.hardHeadroomFloor && isAccountEligible(a, now, family)) {
1105
+ return floorParkedUntil(a.rateLimit, family, this.headroomFloor, now);
1106
+ }
1046
1107
  return null;
1047
- return Math.min(...all.map(a => isParkedInLiveWindow(a, now, family)
1048
- ? (isBucketScopedRejection(a.rateLimit) ? (a.rateLimit.parkedBucketsUntil ?? a.rateLimit.reset * 1000) : a.rateLimit.reset * 1000)
1049
- : a.rateLimit.cooldownUntil ?? now));
1108
+ });
1109
+ return until.every((reset) => reset !== null) ? Math.min(...until) : null;
1050
1110
  }
1051
1111
  /** Seats a rate limit is currently keeping out of rotation (dario#1244, #1264). */
1052
1112
  parkedCount(now = Date.now()) {
@@ -1077,7 +1137,7 @@ export class AccountPool {
1077
1137
  const bound = this.accounts.get(binding.alias);
1078
1138
  if (bound
1079
1139
  && isAccountEligible(bound, now, family)
1080
- && computeHeadroom(bound.rateLimit, family) > this.headroomFloor) {
1140
+ && isAboveHeadroomFloor(computeHeadroom(bound.rateLimit, family), this.headroomFloor)) {
1081
1141
  // Refresh the idle timer. A session that keeps taking turns must never
1082
1142
  // be reaped or rebound while active — that would strand its warm prompt
1083
1143
  // cache — so the TTL is re-based to now on every hit.
@@ -1167,7 +1227,12 @@ export class AccountPool {
1167
1227
  if (first)
1168
1228
  return first;
1169
1229
  }
1170
- return pickMaxHeadroom(eligible, family);
1230
+ if (!this.hardHeadroomFloor)
1231
+ return pickMaxHeadroom(eligible, family);
1232
+ const aboveFloor = eligible.filter(a => isAboveHeadroomFloor(computeHeadroom(a.rateLimit, family, now), this.headroomFloor));
1233
+ return aboveFloor.length > 0
1234
+ ? pickMaxHeadroom(aboveFloor, family)
1235
+ : pickFloorProbe(eligible, family, this.headroomFloor, now);
1171
1236
  }
1172
1237
  // Mid-flight: the seats a 429 could still hand this request to. A seat
1173
1238
  // parked inside a live window is not one of them — on the dario#1244
@@ -1280,6 +1345,18 @@ export class AccountPool {
1280
1345
  get(alias) {
1281
1346
  return this.accounts.get(alias);
1282
1347
  }
1348
+ /** Whether a preferred (but not pinned) seat may bypass normal pool ordering. */
1349
+ canPrefer(account, now = Date.now(), family) {
1350
+ if (!isAccountEligible(account, now, family))
1351
+ return false;
1352
+ if (!this.hardHeadroomFloor)
1353
+ return true;
1354
+ if (isAboveHeadroomFloor(computeHeadroom(account.rateLimit, family, now), this.headroomFloor))
1355
+ return true;
1356
+ // A stale below-floor reading needs one probe to refresh it; a reading
1357
+ // with a known future reset remains hard-parked until that reset.
1358
+ return floorParkedUntil(account.rateLimit, family, this.headroomFloor, now) === null;
1359
+ }
1283
1360
  all() {
1284
1361
  return [...this.accounts.values()];
1285
1362
  }
@@ -1311,11 +1388,9 @@ export class AccountPool {
1311
1388
  */
1312
1389
  async waitForAccount() {
1313
1390
  const immediate = this.select();
1314
- if (immediate) {
1315
- const headroom = computeHeadroom(immediate.rateLimit);
1316
- if (headroom > this.headroomFloor)
1317
- return immediate;
1318
- }
1391
+ if (immediate && (this.hardHeadroomFloor
1392
+ || isAboveHeadroomFloor(computeHeadroom(immediate.rateLimit), this.headroomFloor)))
1393
+ return immediate;
1319
1394
  if (this.queue.length >= this.queueMaxSize) {
1320
1395
  throw new Error('Queue full — all accounts exhausted');
1321
1396
  }
@@ -1355,8 +1430,10 @@ export class AccountPool {
1355
1430
  const account = this.select();
1356
1431
  if (!account)
1357
1432
  break;
1358
- const headroom = computeHeadroom(account.rateLimit);
1359
- if (headroom <= this.headroomFloor)
1433
+ // The implicit floor keeps its historical queueing behavior. In hard
1434
+ // mode select() has already applied the floor, including stale probes.
1435
+ if (!this.hardHeadroomFloor
1436
+ && !isAboveHeadroomFloor(computeHeadroom(account.rateLimit), this.headroomFloor))
1360
1437
  break;
1361
1438
  const entry = this.queue.shift();
1362
1439
  if (entry)
package/dist/proxy.d.ts CHANGED
@@ -70,6 +70,23 @@ export declare function applyModelAlias(model: string | null | undefined, aliase
70
70
  * change is inert until the operator opts in.
71
71
  */
72
72
  export declare function selectModelOverride(incomingModel: string, modelOverride: string | null, fastModelOverride: string | null): string | null;
73
+ /**
74
+ * The model ids a request can be billed as, for pricing its key-budget
75
+ * reservation, which runs before routing. The client's model goes through
76
+ * the resolution the request path applies (operator alias, provider prefix,
77
+ * effort suffix, family shorthand, the OpenAI-name map on the chat shape),
78
+ * and a server-wide --model / --fast-model override is added beside it,
79
+ * since routing has not yet decided whether it replaces the name. The
80
+ * reservation is an upper bound, so it prices the costliest of these.
81
+ */
82
+ export declare function budgetModelsFor(clientModel: string, ctx: {
83
+ modelAliases?: Record<string, string>;
84
+ modelOverride: string | null;
85
+ fastModelOverride: string | null;
86
+ cliProviderOverride: 'openai' | 'claude' | 'codex' | null;
87
+ cliModelRaw: string | null | undefined;
88
+ isOpenAI: boolean;
89
+ }): string[];
73
90
  /**
74
91
  * Rebuild an OpenAI-shape request body with the model swapped to the pool-
75
92
  * fallback target. Null when the body isn't a JSON object — the caller
@@ -419,7 +436,8 @@ interface ProxyOptions {
419
436
  /**
420
437
  * Headroom at/below which a seat counts as drained (dario#1333): a sticky
421
438
  * session rebinds off it and new conversations skip it. Ratio (`0.05`) or
422
- * percent (`5%`); default 2%. Sourced from `--pool-headroom-floor` /
439
+ * percent (`5%`). The implicit 2% default is a routing preference; setting
440
+ * this option explicitly makes the floor a hard stop. Sourced from `--pool-headroom-floor` /
423
441
  * `DARIO_POOL_HEADROOM_FLOOR` / config `pool.headroomFloor`.
424
442
  */
425
443
  poolHeadroomFloor?: string | number;
package/dist/proxy.js CHANGED
@@ -9,14 +9,14 @@ import { getAccessToken, getStatus, ignoreCcCredentials } from './oauth.js';
9
9
  import { buildHealthResponse, derivePoolStatus, probeRequested, shouldDiscloseHealthInternals, shouldRunServingProbe } from './health-response.js';
10
10
  import { getServingProbe } from './serving-probe.js';
11
11
  import { darioVersion } from './version.js';
12
- import { CC_TOOL_DEFINITIONS_UNADVERTISABLE, CC_TEMPLATE_PROMPT_BYTES, resolveMaxTokens, buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
12
+ import { CC_TOOL_DEFINITIONS_UNADVERTISABLE, CC_TEMPLATE_PROMPT_BYTES, resolveMaxTokens, buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, UnsupportedClientToolError, CLIENT_TOOL_MCP_PREFIX, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
13
13
  import { stampCch, hasCchSeed } from './cch.js';
14
14
  import { foldTiming, timingHeaders, timingLogFields } from './timing.js';
15
15
  import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
16
- import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, activeParkedBuckets, reconcilePoolAccounts, resolvePoolStrategy, resolvePoolHeadroomFloor, describePoolStrategy, DEFAULT_POOL_HEADROOM_FLOOR, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail, isAccountEligible } from './pool.js';
16
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, activeParkedBuckets, reconcilePoolAccounts, resolvePoolStrategy, resolvePoolHeadroomFloor, parsePoolHeadroomFloor, describePoolStrategy, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail } from './pool.js';
17
17
  import { backfillIdentity } from './accounts.js';
18
18
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
19
- import { Analytics, billingBucketFromClaim, costOfTokens, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
19
+ import { Analytics, billingBucketFromClaim, costOfTokensFailClosed, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
20
20
  import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
21
21
  import { renderPrometheus } from './metrics.js';
22
22
  import { renderSpendDonuts, renderAnalyticsView, ANALYTICS_UI_SHELL } from './donuts.js';
@@ -54,7 +54,8 @@ import { seedStaleCodexUsage } from './codex-backend.js';
54
54
  import { route as routeProvider } from './provider-adapter.js';
55
55
  import { selectPoolFallbackModels } from './pool-fallback-tier.js';
56
56
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS, resolveMaxConcurrent } from './request-queue.js';
57
- import { waitForIdle, DEFAULT_SHUTDOWN_GRACE_MS } from './shutdown-drain.js';
57
+ import { waitForIdle, drainThenClose, shutdownTimers, DEFAULT_SHUTDOWN_GRACE_MS } from './shutdown-drain.js';
58
+ import { clampTimerMs } from './timer-ms.js';
58
59
  import { redactSecrets } from './redact.js';
59
60
  import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, retryModelCatalogNow, isSuspendedModel } from './model-catalog.js';
60
61
  import { classifyUpstreamRejection, diagnosticSnippet, POOL_PARKED } from './upstream-rejection.js';
@@ -302,7 +303,8 @@ const MODEL_ALIASES = {
302
303
  * that needs to Just Work.
303
304
  */
304
305
  export function resolveClaudeAlias(model) {
305
- return resolveAliasAgainst(model, getCachedBases()) ?? MODEL_ALIASES[model] ?? model;
306
+ // Own keys only: the model is client input, and `__proto__` would read Object.prototype.
307
+ return resolveAliasAgainst(model, getCachedBases()) ?? (Object.hasOwn(MODEL_ALIASES, model) ? MODEL_ALIASES[model] : model);
306
308
  }
307
309
  /**
308
310
  * User-defined model aliases: client-visible name → target model.
@@ -337,7 +339,9 @@ export function parseModelAliasSpecs(specs) {
337
339
  export function applyModelAlias(model, aliases) {
338
340
  if (!aliases || !model)
339
341
  return null;
340
- const target = aliases[model.trim().toLowerCase()];
342
+ const name = model.trim().toLowerCase();
343
+ // Own keys only, as in resolveClaudeAlias: the name is client input.
344
+ const target = Object.hasOwn(aliases, name) ? aliases[name] : undefined;
341
345
  if (target === undefined || target === model)
342
346
  return null;
343
347
  return target;
@@ -361,6 +365,36 @@ export function selectModelOverride(incomingModel, modelOverride, fastModelOverr
361
365
  return fastModelOverride;
362
366
  return modelOverride;
363
367
  }
368
+ /**
369
+ * The model ids a request can be billed as, for pricing its key-budget
370
+ * reservation, which runs before routing. The client's model goes through
371
+ * the resolution the request path applies (operator alias, provider prefix,
372
+ * effort suffix, family shorthand, the OpenAI-name map on the chat shape),
373
+ * and a server-wide --model / --fast-model override is added beside it,
374
+ * since routing has not yet decided whether it replaces the name. The
375
+ * reservation is an upper bound, so it prices the costliest of these.
376
+ */
377
+ export function budgetModelsFor(clientModel, ctx) {
378
+ const out = new Set();
379
+ const aliased = applyModelAlias(clientModel, ctx.modelAliases) ?? clientModel;
380
+ const prefix = parseProviderPrefix(aliased);
381
+ if (prefix) {
382
+ out.add(prefix.provider === 'claude' ? resolveClaudeAlias(parseEffortSuffix(prefix.model).model) : prefix.model);
383
+ }
384
+ else if (ctx.cliProviderOverride === 'openai' && ctx.cliModelRaw) {
385
+ out.add(ctx.cliModelRaw);
386
+ }
387
+ else {
388
+ const bare = isOpenAIModel(aliased) ? aliased : parseEffortSuffix(aliased).model;
389
+ out.add(resolveClaudeAlias(bare));
390
+ if (ctx.isOpenAI && Object.hasOwn(OPENAI_MODEL_MAP, bare))
391
+ out.add(OPENAI_MODEL_MAP[bare]);
392
+ }
393
+ const override = selectModelOverride(aliased, ctx.modelOverride, ctx.fastModelOverride);
394
+ if (override)
395
+ out.add(override);
396
+ return [...out];
397
+ }
364
398
  // Provider prefix in the `model` field — `<provider>:<model>`. Forces
365
399
  // routing regardless of model-name regex. Only recognized prefixes are
366
400
  // parsed, so ollama-style `llama3:8b` (without a recognized prefix)
@@ -400,7 +434,9 @@ export function parseProviderPrefix(model) {
400
434
  if (idx <= 0)
401
435
  return null;
402
436
  const prefix = model.slice(0, idx).toLowerCase();
403
- const provider = PROVIDER_PREFIXES[prefix];
437
+ // Own keys only: PROVIDER_PREFIXES is a plain object, so `__proto__:x` or `constructor:x` would
438
+ // otherwise read Object.prototype's members as a provider (found by fuzz/reject_parsers.fuzz.js).
439
+ const provider = Object.hasOwn(PROVIDER_PREFIXES, prefix) ? PROVIDER_PREFIXES[prefix] : undefined;
404
440
  if (!provider)
405
441
  return null;
406
442
  const stripped = model.slice(idx + 1);
@@ -1274,6 +1310,8 @@ export async function startProxy(opts = {}) {
1274
1310
  const toolSubLogged = new Set();
1275
1311
  // Same de-dup contract for the verbose-only MCP-passthrough note.
1276
1312
  const mcpPassthroughLogged = new Set();
1313
+ // Same de-dup contract for the note on client tools carried MCP-shaped.
1314
+ const mcpCarryLogged = new Set();
1277
1315
  // One-shot log for the genuine-CC byte-faithful passthrough path.
1278
1316
  let ccPassthroughLogged = false;
1279
1317
  // Body-dump mode: set via --verbose=2 / -vv or DARIO_LOG_BODIES=1.
@@ -1476,8 +1514,9 @@ export async function startProxy(opts = {}) {
1476
1514
  const accountsList = await loadAllAccounts();
1477
1515
  const poolStrategy = resolvePoolStrategy(opts.poolStrategy);
1478
1516
  const poolHeadroomFloor = resolvePoolHeadroomFloor(opts.poolHeadroomFloor);
1479
- const pool = new AccountPool(poolStrategy, poolHeadroomFloor);
1480
- if (poolHeadroomFloor !== DEFAULT_POOL_HEADROOM_FLOOR) {
1517
+ const poolHeadroomFloorExplicit = parsePoolHeadroomFloor(opts.poolHeadroomFloor) !== null;
1518
+ const pool = new AccountPool(poolStrategy, poolHeadroomFloorExplicit ? poolHeadroomFloor : undefined);
1519
+ if (poolHeadroomFloorExplicit) {
1481
1520
  console.log(`[dario] Pool headroom floor: ${Math.round(poolHeadroomFloor * 100)}% — a seat at or below it is left alone: sticky sessions rebind off it, new conversations skip it`);
1482
1521
  }
1483
1522
  // Two aliases that are one account (same OAuth account uuid) are one
@@ -1910,7 +1949,8 @@ export async function startProxy(opts = {}) {
1910
1949
  else if (pool.size > 0 && opts.maxConcurrent !== undefined && opts.maxConcurrent < pool.size) {
1911
1950
  console.error(`[dario] --max-concurrent=${opts.maxConcurrent} is below the ${pool.size} seats in the pool. That flag is a proxy-wide ceiling on in-flight requests, not a per-seat one: at most ${opts.maxConcurrent} requests can be in flight across all ${pool.size} seats, and the rest wait in dario. (dario#1244)`);
1912
1951
  }
1913
- const upstreamTimeoutMs = opts.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS;
1952
+ // Every upstream abort timer (Claude, codex, openai-compat, continuation) takes this value.
1953
+ const upstreamTimeoutMs = clampTimerMs(opts.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS);
1914
1954
  // Cache context-1m beta availability. Set false once per account after the
1915
1955
  // first "long context" rejection, so we skip sending context-1m on every
1916
1956
  // subsequent request instead of paying the round-trip + retry cost each time.
@@ -3352,7 +3392,7 @@ export async function startProxy(opts = {}) {
3352
3392
  // eligible right now, else the pool picks as usual. Failover
3353
3393
  // mid-request is unchanged either way — a preference, not a pin.
3354
3394
  const preferredSeat = requestAuth.key?.seat ? (pool.get(requestAuth.key.seat) ?? null) : null;
3355
- keySeatTaken = preferredSeat !== null && isAccountEligible(preferredSeat, Date.now(), requestFamily);
3395
+ keySeatTaken = preferredSeat !== null && pool.canPrefer(preferredSeat, Date.now(), requestFamily);
3356
3396
  poolAccount = keySeatTaken ? preferredSeat : pool.select(requestFamily);
3357
3397
  if (poolAccount)
3358
3398
  poolParkedAnnounced = false;
@@ -3597,13 +3637,20 @@ export async function startProxy(opts = {}) {
3597
3637
  // it — bounded by what dario will SEND: the client's body plus the
3598
3638
  // template's prompt (passthrough adds nothing), and the max_tokens
3599
3639
  // that will go on the wire (the template pins its own default unless
3600
- // --max-tokens=client; passthrough forwards the client's).
3640
+ // --max-tokens=client; passthrough forwards the client's). Priced at
3641
+ // the model it will be billed as (dario#1378): a short name or a
3642
+ // --model override priced as written fell back to the sonnet-4-6
3643
+ // rate, a third of fable's; a model with no published rate is priced
3644
+ // at the highest one.
3601
3645
  const pb = parsedBody;
3602
3646
  const clientMax = pb ? (pb.max_tokens ?? pb.max_completion_tokens ?? pb.max_output_tokens) : undefined;
3603
3647
  const outboundMax = passthrough
3604
3648
  ? (typeof clientMax === 'number' ? clientMax : null)
3605
3649
  : resolveMaxTokens(opts.maxTokens, { max_tokens: clientMax });
3606
- budgetReserved = requestBudgetReservation(typeof pb?.model === 'string' ? pb.model : '', body.length, outboundMax, costOfTokens, Date.now(), passthrough ? 0 : CC_TEMPLATE_PROMPT_BYTES);
3650
+ const billedAs = budgetModelsFor(typeof pb?.model === 'string' ? pb.model : '', {
3651
+ modelAliases, modelOverride, fastModelOverride, cliProviderOverride, cliModelRaw, isOpenAI,
3652
+ });
3653
+ budgetReserved = requestBudgetReservation(billedAs, body.length, outboundMax, costOfTokensFailClosed, Date.now(), passthrough ? 0 : CC_TEMPLATE_PROMPT_BYTES);
3607
3654
  budgetInflightKey = requestAuth.key.name;
3608
3655
  keyInflight.set(budgetInflightKey, addReservation(inflightNow, budgetReserved));
3609
3656
  }
@@ -4449,18 +4496,38 @@ export async function startProxy(opts = {}) {
4449
4496
  // resolved just above so body and header agree.
4450
4497
  const idSource = poolAccount ? poolAccount.identity : identity;
4451
4498
  const bodyIdentity = { deviceId: idSource.deviceId, accountUuid: idSource.accountUuid, sessionId: preBodySessionId };
4452
- const { body: ccBody, toolMap, detectedClient, unmappedTools, genuineCC } = buildCCRequest(r, billingTag, CACHE_EPHEMERAL, bodyIdentity, {
4453
- preserveTools: opts.preserveTools ?? false,
4454
- hybridTools: opts.hybridTools ?? false,
4455
- mergeTools: opts.mergeTools ?? false,
4456
- noAutoDetect: opts.noAutoDetect ?? false,
4457
- effort: requestEffort ?? opts.effort,
4458
- maxTokens: opts.maxTokens,
4459
- systemPrompt: opts.systemPrompt,
4460
- skipFields,
4461
- honorClientThinking: opts.honorClientThinking ?? false,
4462
- preserveOutputFormat: opts.preserveOutputFormat ?? false,
4463
- });
4499
+ let built;
4500
+ try {
4501
+ built = buildCCRequest(r, billingTag, CACHE_EPHEMERAL, bodyIdentity, {
4502
+ preserveTools: opts.preserveTools ?? false,
4503
+ hybridTools: opts.hybridTools ?? false,
4504
+ mergeTools: opts.mergeTools ?? false,
4505
+ noAutoDetect: opts.noAutoDetect ?? false,
4506
+ effort: requestEffort ?? opts.effort,
4507
+ maxTokens: opts.maxTokens,
4508
+ systemPrompt: opts.systemPrompt,
4509
+ skipFields,
4510
+ honorClientThinking: opts.honorClientThinking ?? false,
4511
+ preserveOutputFormat: opts.preserveOutputFormat ?? false,
4512
+ });
4513
+ }
4514
+ catch (err) {
4515
+ // A client tool the Claude Code-shaped request cannot carry is
4516
+ // refused by name. The catch around this block forwards the body
4517
+ // as received on any other throw, which would drop the tool.
4518
+ if (!(err instanceof UnsupportedClientToolError))
4519
+ throw err;
4520
+ console.log(`[dario] #${requestCount} refused: ${err.message}`);
4521
+ requestCount++;
4522
+ res.writeHead(400, JSON_HEADERS);
4523
+ res.end(JSON.stringify(isOpenAI || isResponses
4524
+ ? { error: { message: err.message, type: 'invalid_request_error', param: 'tools', code: null } }
4525
+ : { type: 'error', error: { type: 'invalid_request_error', message: err.message } }));
4526
+ return;
4527
+ }
4528
+ const { body: ccBody, toolMap, detectedClient, unmappedTools: notInToolMap, carriedAsMcp = [], genuineCC } = built;
4529
+ // The tools that rode a fallback slot; the MCP-shaped ones have their own note below.
4530
+ const unmappedTools = notInToolMap.filter((n) => !carriedAsMcp.includes(n));
4464
4531
  // Prompt-cache the tools + conversation prefix (the system prompt
4465
4532
  // is already cached in ccBody's system blocks). Mirrors CC's cache
4466
4533
  // breakpoints so a long session doesn't re-bill them as fresh input
@@ -4515,6 +4582,15 @@ export async function startProxy(opts = {}) {
4515
4582
  const more = unmappedTools.length > 5 ? `, +${unmappedTools.length - 5} more` : '';
4516
4583
  console.log(`[dario] tool substitution: ${unmappedTools.length}/${totalTools} client tool${unmappedTools.length === 1 ? '' : 's'} not in TOOL_MAP — remapped onto CC fallback slots (${sample}${more}). Pass --preserve-tools to forward your schemas verbatim instead.`);
4517
4584
  }
4585
+ // Client tools outside CC's set on a request that names CC tools go
4586
+ // out under MCP-shaped names with their own schemas: say so once
4587
+ // per client family, so the renamed names in a body dump are expected.
4588
+ if (carriedAsMcp.length > 0 && !preserveToolsEffective && !mcpCarryLogged.has(subKey)) {
4589
+ mcpCarryLogged.add(subKey);
4590
+ const sample = carriedAsMcp.slice(0, 5).join(', ');
4591
+ const more = carriedAsMcp.length > 5 ? `, +${carriedAsMcp.length - 5} more` : '';
4592
+ console.log(`[dario] ${carriedAsMcp.length} client tool${carriedAsMcp.length === 1 ? '' : 's'} outside Claude Code's set carried as ${CLIENT_TOOL_MCP_PREFIX}<name> with ${carriedAsMcp.length === 1 ? 'its' : 'their'} own schema${carriedAsMcp.length === 1 ? '' : 's'} (${sample}${more}); calls come back under the client's names.`);
4593
+ }
4518
4594
  // MCP tools (mcp__<server>__<tool>) forward verbatim in default
4519
4595
  // mode — real CC advertises session-attached MCP schemas as-is,
4520
4596
  // so this is passthrough, not substitution. Verbose-only note so
@@ -4707,7 +4783,7 @@ export async function startProxy(opts = {}) {
4707
4783
  const totalDelay = Math.max(pacingDelay, thinkDelay, sessionStartDelay);
4708
4784
  if (totalDelay > 0) {
4709
4785
  pacingMs += totalDelay;
4710
- await new Promise(r => setTimeout(r, totalDelay));
4786
+ await new Promise(r => setTimeout(r, clampTimerMs(totalDelay)));
4711
4787
  }
4712
4788
  seatClock.lastRequestTime = Date.now();
4713
4789
  // Session ID: resolved through the rotation registry keyed by the selected
@@ -6030,19 +6106,21 @@ export async function startProxy(opts = {}) {
6030
6106
  clearInterval(presenceInterval);
6031
6107
  clearInterval(refreshInterval);
6032
6108
  poolSync?.stop();
6033
- if (logFileStream)
6034
- logFileStream.end();
6035
- keyStore?.close();
6036
6109
  // Stop accepting connections now; responses already streaming keep going.
6037
- // Then flush tokens (best-effort), drain what is in flight up to the
6038
- // grace, and exit. The force-exit guard sits past the grace so a stream
6039
- // that never ends cannot wedge shutdown, and a hung fsync cannot either.
6110
+ // Tokens and what the ledger already holds are written while the drain
6111
+ // waits. The ledger, the key store and the request log close only after
6112
+ // the drain: a request that finishes inside the grace still records its
6113
+ // spend, its key's daily total and its log line (dario#1370). The
6114
+ // force-exit guard sits past the grace so a stream that never ends cannot
6115
+ // wedge shutdown, and a hung fsync cannot either.
6040
6116
  server.close();
6041
- const graceMs = opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
6042
- void Promise.all([flushPoolTokens(), ledger?.close()])
6043
- .then(() => waitForIdle(() => queue.snapshot().active, { graceMs }))
6044
- .finally(() => process.exit(0));
6045
- setTimeout(() => process.exit(0), graceMs + 5000).unref();
6117
+ const { graceMs, forceExitMs } = shutdownTimers(opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
6118
+ void drainThenClose(() => waitForIdle(() => queue.snapshot().active, { graceMs }), {
6119
+ before: [flushPoolTokens, () => ledger?.flush()],
6120
+ after: [() => ledger?.close(), () => keyStore?.close(), () => { if (logFileStream)
6121
+ logFileStream.end(); }],
6122
+ }).finally(() => process.exit(0));
6123
+ setTimeout(() => process.exit(0), forceExitMs).unref();
6046
6124
  };
6047
6125
  process.on('SIGINT', shutdown);
6048
6126
  process.on('SIGTERM', shutdown);
@@ -22,6 +22,7 @@
22
22
  *
23
23
  * dario#80 (Gemini review push-back).
24
24
  */
25
+ import { clampTimerMs } from './timer-ms.js';
25
26
  /** Pure admission decision — no side effects, no clock dep. */
26
27
  export function decideAdmit(state) {
27
28
  if (state.active < state.maxConcurrent)
@@ -94,7 +95,8 @@ export class RequestQueue {
94
95
  constructor(opts = {}) {
95
96
  this.maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
96
97
  this.maxQueued = opts.maxQueued ?? DEFAULT_MAX_QUEUED;
97
- this.queueTimeoutMs = opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
98
+ // A timer's delay: capped where Node would otherwise fire it after 1 ms (timer-ms.ts).
99
+ this.queueTimeoutMs = clampTimerMs(opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS);
98
100
  this.maxConcurrentPerConsumer = Math.max(0, opts.maxConcurrentPerConsumer ?? 0);
99
101
  this.unrefTimers = opts.unrefTimers ?? true;
100
102
  this.now = opts.now ?? Date.now;
@@ -3,6 +3,7 @@
3
3
  * Opt-in, trusted-callers-only, cached and single-flighted.
4
4
  */
5
5
  import { classifyUpstreamRejection, rejectionRemediation } from './upstream-rejection.js';
6
+ import { clampTimerMs } from './timer-ms.js';
6
7
  const ANTHROPIC_MESSAGES = 'https://api.anthropic.com/v1/messages';
7
8
  const ANTHROPIC_VERSION = '2023-06-01';
8
9
  const OAUTH_BETA = 'oauth-2025-04-20';
@@ -45,7 +46,7 @@ async function runProbe(deps) {
45
46
  const f = deps.fetchImpl ?? fetch;
46
47
  const now = deps.now ?? Date.now;
47
48
  const model = deps.model ?? process.env.DARIO_PROBE_MODEL ?? DEFAULT_PROBE_MODEL;
48
- const timeoutMs = deps.timeoutMs ?? envInt('DARIO_PROBE_TIMEOUT_MS', DEFAULT_PROBE_TIMEOUT_MS);
49
+ const timeoutMs = clampTimerMs(deps.timeoutMs ?? envInt('DARIO_PROBE_TIMEOUT_MS', DEFAULT_PROBE_TIMEOUT_MS));
49
50
  const startedAt = now();
50
51
  const ctl = new AbortController();
51
52
  const timer = setTimeout(() => ctl.abort(), timeoutMs);
@@ -14,6 +14,18 @@
14
14
  */
15
15
  /** How long a SIGTERM waits for in-flight requests by default. */
16
16
  export declare const DEFAULT_SHUTDOWN_GRACE_MS = 90000;
17
+ /** How long past the grace the force-exit guard waits before exiting anyway. */
18
+ export declare const SHUTDOWN_FORCE_EXIT_MARGIN_MS = 5000;
19
+ /**
20
+ * The drain's grace and the force-exit guard's delay for a configured grace.
21
+ * The guard is a timer, so the grace is capped where grace plus margin still
22
+ * fits one (timer-ms.ts): past that the guard would fire at once and skip
23
+ * the drain.
24
+ */
25
+ export declare function shutdownTimers(graceMs: number): {
26
+ graceMs: number;
27
+ forceExitMs: number;
28
+ };
17
29
  /** How often the drain re-reads the in-flight count. */
18
30
  export declare const SHUTDOWN_POLL_MS = 250;
19
31
  export interface DrainOptions {
@@ -41,3 +53,17 @@ export interface DrainResult {
41
53
  * poll, so a long drain is two lines rather than a scroll.
42
54
  */
43
55
  export declare function waitForIdle(getActive: () => number, opts: DrainOptions): Promise<DrainResult>;
56
+ /**
57
+ * The order `shutdown` runs its steps in: `before` and the drain start
58
+ * together, and `after` runs only once the drain has returned.
59
+ *
60
+ * Anything a finishing request writes to belongs in `after`. The ledger
61
+ * refuses rows once closed and an ended log stream drops lines, so closing
62
+ * either before the drain loses exactly the requests the drain waits for:
63
+ * the long streamed runs that finish inside the grace. A step that throws
64
+ * or rejects is skipped; the others still run and shutdown still exits.
65
+ */
66
+ export declare function drainThenClose(drain: () => Promise<unknown>, steps: {
67
+ before?: Array<() => unknown>;
68
+ after?: Array<() => unknown>;
69
+ }): Promise<void>;
@@ -12,8 +12,21 @@
12
12
  * runs out. It is pure over its inputs — the count, the clock and the sleep
13
13
  * are injected — so the policy is tested without a server or real timers.
14
14
  */
15
+ import { clampTimerMs, MAX_TIMER_MS } from './timer-ms.js';
15
16
  /** How long a SIGTERM waits for in-flight requests by default. */
16
17
  export const DEFAULT_SHUTDOWN_GRACE_MS = 90_000;
18
+ /** How long past the grace the force-exit guard waits before exiting anyway. */
19
+ export const SHUTDOWN_FORCE_EXIT_MARGIN_MS = 5_000;
20
+ /**
21
+ * The drain's grace and the force-exit guard's delay for a configured grace.
22
+ * The guard is a timer, so the grace is capped where grace plus margin still
23
+ * fits one (timer-ms.ts): past that the guard would fire at once and skip
24
+ * the drain.
25
+ */
26
+ export function shutdownTimers(graceMs) {
27
+ const grace = Math.min(clampTimerMs(graceMs), MAX_TIMER_MS - SHUTDOWN_FORCE_EXIT_MARGIN_MS);
28
+ return { graceMs: grace, forceExitMs: grace + SHUTDOWN_FORCE_EXIT_MARGIN_MS };
29
+ }
17
30
  /** How often the drain re-reads the in-flight count. */
18
31
  export const SHUTDOWN_POLL_MS = 250;
19
32
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -46,3 +59,18 @@ export async function waitForIdle(getActive, opts) {
46
59
  }
47
60
  }
48
61
  }
62
+ /**
63
+ * The order `shutdown` runs its steps in: `before` and the drain start
64
+ * together, and `after` runs only once the drain has returned.
65
+ *
66
+ * Anything a finishing request writes to belongs in `after`. The ledger
67
+ * refuses rows once closed and an ended log stream drops lines, so closing
68
+ * either before the drain loses exactly the requests the drain waits for:
69
+ * the long streamed runs that finish inside the grace. A step that throws
70
+ * or rejects is skipped; the others still run and shutdown still exits.
71
+ */
72
+ export async function drainThenClose(drain, steps) {
73
+ const run = (fns) => Promise.all((fns ?? []).map((fn) => Promise.resolve().then(fn).catch(() => undefined)));
74
+ await Promise.all([run(steps.before), drain().catch(() => undefined)]);
75
+ await run(steps.after);
76
+ }