@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/analytics.d.ts +22 -0
- package/dist/analytics.js +44 -1
- package/dist/cc-template.d.ts +34 -4
- package/dist/cc-template.js +110 -10
- package/dist/cli.js +6 -4
- package/dist/codex-usage.d.ts +5 -1
- package/dist/codex-usage.js +7 -2
- package/dist/doctor-core.js +3 -2
- package/dist/keys.d.ts +5 -3
- package/dist/keys.js +7 -3
- package/dist/overage-guard.js +3 -1
- package/dist/pool-sync.js +2 -1
- package/dist/pool.d.ts +7 -4
- package/dist/pool.js +102 -25
- package/dist/proxy.d.ts +19 -1
- package/dist/proxy.js +115 -37
- package/dist/request-queue.js +3 -1
- package/dist/serving-probe.js +2 -1
- package/dist/shutdown-drain.d.ts +26 -0
- package/dist/shutdown-drain.js +28 -0
- package/dist/timer-ms.d.ts +19 -0
- package/dist/timer-ms.js +23 -0
- package/docs/integrations/agent-compat.md +3 -1
- package/docs/pool-keys-and-analytics.md +1 -1
- package/package.json +1 -1
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(
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
1316
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
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%`)
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1480
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
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
|
-
//
|
|
6038
|
-
//
|
|
6039
|
-
//
|
|
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
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
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);
|
package/dist/request-queue.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/serving-probe.js
CHANGED
|
@@ -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);
|
package/dist/shutdown-drain.d.ts
CHANGED
|
@@ -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>;
|
package/dist/shutdown-drain.js
CHANGED
|
@@ -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
|
+
}
|