@askalf/dario 3.27.0 → 3.28.0

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/cli.js CHANGED
@@ -220,7 +220,16 @@ async function proxy() {
220
220
  // read-to-completion pattern. Costs tokens (the response is fully
221
221
  // generated even if nobody reads it), so it's opt-in.
222
222
  const drainOnClose = args.includes('--drain-on-close') || undefined;
223
- await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, drainOnClose });
223
+ // --session-* knobs (v3.28, direction #1). Control the single-account
224
+ // session-id lifecycle: idle threshold, jitter on that threshold, hard
225
+ // max-age, and whether to give each upstream client its own session.
226
+ // All defaults preserve v3.27 behaviour exactly. Logic lives in
227
+ // src/session-rotation.ts; these flags just feed resolveSessionRotationConfig.
228
+ const sessionIdleRotateMs = parsePositiveIntFlag('--session-idle-rotate=');
229
+ const sessionRotateJitterMs = parsePositiveIntFlag('--session-rotate-jitter=');
230
+ const sessionMaxAgeMs = parsePositiveIntFlag('--session-max-age=');
231
+ const sessionPerClient = args.includes('--session-per-client') || undefined;
232
+ await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient });
224
233
  }
225
234
  function parsePositiveIntFlag(prefix) {
226
235
  const found = args.find(a => a.startsWith(prefix));
@@ -513,6 +522,26 @@ async function help() {
513
522
  is fully generated even if nobody reads
514
523
  it) for fingerprint fidelity. Bounded by
515
524
  the 5-minute upstream timeout. (v3.25)
525
+ --session-idle-rotate=MS Idle ms before the single-account session
526
+ id rotates (default: 900000 = 15 min).
527
+ Real CC rotates once per conversation, not
528
+ per call; the default matches its observed
529
+ cadence. Pool mode is unaffected. (v3.28)
530
+ --session-rotate-jitter=MS
531
+ Max additional uniform-random jitter (ms)
532
+ added to the idle threshold, sampled once
533
+ per session at creation. Default: 0 (off).
534
+ Hides the exact threshold from long-run
535
+ rotation statistics. (v3.28)
536
+ --session-max-age=MS Hard cap on a session id's lifetime
537
+ regardless of activity. Default: off. Set
538
+ for always-on pipelines where an idle
539
+ window would never trigger. (v3.28)
540
+ --session-per-client Give each upstream client (keyed by
541
+ x-session-id / x-client-session-id
542
+ header) its own rotated session id.
543
+ Default: off (single session across all
544
+ clients, v3.27 behaviour). (v3.28)
516
545
  --port=PORT Port to listen on (default: 3456)
517
546
  --host=ADDRESS Address to bind to (default: 127.0.0.1)
518
547
  Use 0.0.0.0 for LAN; see README for DARIO_API_KEY
package/dist/proxy.d.ts CHANGED
@@ -16,6 +16,10 @@ interface ProxyOptions {
16
16
  pacingMinMs?: number;
17
17
  pacingJitterMs?: number;
18
18
  drainOnClose?: boolean;
19
+ sessionIdleRotateMs?: number;
20
+ sessionRotateJitterMs?: number;
21
+ sessionMaxAgeMs?: number;
22
+ sessionPerClient?: boolean;
19
23
  }
20
24
  export declare function sanitizeError(err: unknown): string;
21
25
  export declare function startProxy(opts?: ProxyOptions): Promise<void>;
package/dist/proxy.js CHANGED
@@ -97,11 +97,17 @@ function extractFirstUserMessage(body) {
97
97
  //
98
98
  // v3.19 keeps the id stable through a conversation window and rotates
99
99
  // only after an idle gap long enough to credibly indicate a new
100
- // conversation (SESSION_IDLE_ROTATE_MS). Pool mode still uses the
101
- // per-account identity.sessionId (stable across the account's lifetime).
100
+ // conversation. Pool mode still uses the per-account identity.sessionId
101
+ // (stable across the account's lifetime).
102
+ //
103
+ // v3.28 generalises the single hardcoded 15-min window into a tunable
104
+ // registry (see src/session-rotation.ts) with optional jitter, max-age,
105
+ // and per-client keying. SESSION_ID below is kept only as a mirror of
106
+ // the default single-account session so out-of-band consumers (presence
107
+ // ping, diagnostic logs) can read the most recent id without going
108
+ // through the registry. It's refreshed after every dispatch-path call
109
+ // that assigns a new id.
102
110
  let SESSION_ID = randomUUID();
103
- let SESSION_LAST_USED = 0;
104
- const SESSION_IDLE_ROTATE_MS = 15 * 60 * 1000;
105
111
  const OS_NAME = platform === 'win32' ? 'Windows' : platform === 'darwin' ? 'MacOS' : 'Linux';
106
112
  // Claude Code device identity — required for Max plan billing classification.
107
113
  // Without metadata.user_id, Anthropic classifies requests as third-party and
@@ -593,6 +599,22 @@ export async function startProxy(opts = {}) {
593
599
  if (verbose) {
594
600
  console.log(`[dario] drain-on-close: ${drainOnClose ? 'enabled' : 'disabled'}`);
595
601
  }
602
+ // Session-ID lifecycle (v3.28, direction #1). Replaces the v3.27 hardcoded
603
+ // 15-minute idle window with a tunable registry: idle threshold, jitter on
604
+ // that threshold, optional hard max-age, and optional per-client keying.
605
+ // Defaults preserve v3.27 behavior exactly. See src/session-rotation.ts.
606
+ const { SessionRegistry, resolveSessionRotationConfig } = await import('./session-rotation.js');
607
+ const sessionCfg = resolveSessionRotationConfig({
608
+ idleRotateMs: opts.sessionIdleRotateMs,
609
+ jitterMs: opts.sessionRotateJitterMs,
610
+ maxAgeMs: opts.sessionMaxAgeMs,
611
+ perClient: opts.sessionPerClient,
612
+ });
613
+ const sessionRegistry = new SessionRegistry(sessionCfg, () => randomUUID());
614
+ if (verbose) {
615
+ const maxAge = sessionCfg.maxAgeMs !== undefined ? `${sessionCfg.maxAgeMs}ms` : 'off';
616
+ console.log(`[dario] session: idle=${sessionCfg.idleRotateMs}ms jitter=${sessionCfg.jitterMs}ms maxAge=${maxAge} perClient=${sessionCfg.perClient}`);
617
+ }
596
618
  // Optional proxy authentication — pre-encode key buffer for performance
597
619
  const apiKey = process.env.DARIO_API_KEY;
598
620
  const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
@@ -958,6 +980,9 @@ export async function startProxy(opts = {}) {
958
980
  // selection toward one we already paid cache cost on — passthrough
959
981
  // users aren't doing template replay anyway).
960
982
  let stickyKey = null;
983
+ // Outbound session id resolved once — either inside the template build
984
+ // (so body metadata matches) or below for passthrough (no body build).
985
+ let preBodySessionId;
961
986
  // Request context for hybrid-mode field injection (#33). Built once
962
987
  // per request from incoming headers so the reverse mapper can fill
963
988
  // client-declared fields like `sessionId` that CC's schema doesn't
@@ -1010,9 +1035,28 @@ export async function startProxy(opts = {}) {
1010
1035
  }
1011
1036
  }
1012
1037
  }
1038
+ // Resolve the outbound session id before the body build so the
1039
+ // metadata.session_id in the CC body and the x-claude-code-session-id
1040
+ // header both use the same value. v3.27 consulted SESSION_ID twice
1041
+ // with rotation between the reads, so on rotation events body and
1042
+ // header disagreed — harmless for plain operation but a fingerprint
1043
+ // in its own right.
1044
+ if (poolAccount) {
1045
+ preBodySessionId = poolAccount.identity.sessionId;
1046
+ }
1047
+ else {
1048
+ const clientKey = req.headers['x-session-id']
1049
+ ?? req.headers['x-client-session-id'];
1050
+ const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
1051
+ preBodySessionId = assigned.sessionId;
1052
+ SESSION_ID = assigned.sessionId;
1053
+ if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
1054
+ console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
1055
+ }
1056
+ }
1013
1057
  const bodyIdentity = poolAccount
1014
1058
  ? poolAccount.identity
1015
- : { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: SESSION_ID };
1059
+ : { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: preBodySessionId };
1016
1060
  const { body: ccBody, toolMap, detectedClient } = buildCCRequest(r, billingTag, CACHE_1H, bodyIdentity, {
1017
1061
  preserveTools: opts.preserveTools ?? false,
1018
1062
  hybridTools: opts.hybridTools ?? false,
@@ -1102,18 +1146,30 @@ export async function startProxy(opts = {}) {
1102
1146
  }
1103
1147
  lastRequestTime = Date.now();
1104
1148
  // Session ID: pool mode uses the per-account identity.sessionId (stable
1105
- // per account). Single-account mode keeps SESSION_ID stable through
1106
- // active conversations and rotates only after an idle gap that looks
1107
- // like a new conversation matches CC's observed cadence (see note
1108
- // at SESSION_ID declaration).
1109
- if (!poolAccount) {
1110
- const nowTs = Date.now();
1111
- if (SESSION_LAST_USED === 0 || nowTs - SESSION_LAST_USED > SESSION_IDLE_ROTATE_MS) {
1112
- SESSION_ID = randomUUID();
1149
+ // per account). Single-account mode delegates to the session registry
1150
+ // (src/session-rotation.ts) which applies the configured idle / jitter /
1151
+ // max-age / per-client policy. Resolution happens earlier, at body-build
1152
+ // time, so the CC body's metadata.session_id and the outbound
1153
+ // x-claude-code-session-id header always agree. preBodySessionId holds
1154
+ // the template-build value; in passthrough mode (no template build)
1155
+ // the registry is consulted here instead.
1156
+ let outboundSessionId;
1157
+ if (poolAccount) {
1158
+ outboundSessionId = poolAccount.identity.sessionId;
1159
+ }
1160
+ else if (preBodySessionId !== undefined) {
1161
+ outboundSessionId = preBodySessionId;
1162
+ }
1163
+ else {
1164
+ const clientKey = req.headers['x-session-id']
1165
+ ?? req.headers['x-client-session-id'];
1166
+ const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
1167
+ outboundSessionId = assigned.sessionId;
1168
+ SESSION_ID = assigned.sessionId;
1169
+ if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
1170
+ console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
1113
1171
  }
1114
- SESSION_LAST_USED = nowTs;
1115
1172
  }
1116
- const outboundSessionId = poolAccount ? poolAccount.identity.sessionId : SESSION_ID;
1117
1173
  const headers = {
1118
1174
  ...staticHeaders,
1119
1175
  'Authorization': `Bearer ${accessToken}`,
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Session-ID lifecycle (v3.28, direction #1 — interactive-side rotation).
3
+ *
4
+ * Every outbound request to Anthropic carries a session identifier in the
5
+ * CC request body's metadata. Real Claude Code holds that id stable through
6
+ * a conversation and mints a new one when the user returns after an idle
7
+ * gap — roughly "one id per conversation", not per HTTP call. A proxy that
8
+ * rotates per-request looks synthetic; one that never rotates looks equally
9
+ * synthetic over long sessions. v3.19 tightened the per-request leak into a
10
+ * single hardcoded 15-minute idle window; this module generalises that into
11
+ * a registry so operators can tune the behaviour and so the multi-client
12
+ * case (dario fanning multiple UIs through one proxy) stops sharing one id.
13
+ *
14
+ * Three independent knobs:
15
+ *
16
+ * idleRotateMs — the v3.19 behaviour: rotate after this many ms of no
17
+ * traffic on a given session. Default 15 min preserves
18
+ * v3.27 exactly when the other knobs stay at defaults.
19
+ *
20
+ * jitterMs — the observable idle threshold for a given session is
21
+ * idleRotateMs + U(0, jitterMs), sampled once at session
22
+ * creation. A zero-jitter proxy rotates at exactly the
23
+ * same interval every time; adding jitter means the floor
24
+ * can't be inferred from long-run rotation cadence.
25
+ *
26
+ * maxAgeMs — hard cap on a session's total lifetime regardless of
27
+ * activity. Optional (undefined disables). A chatty
28
+ * always-on pipeline would otherwise keep one session id
29
+ * alive for days; real CC conversations don't.
30
+ *
31
+ * perClient — when true, the registry keys sessions by the caller's
32
+ * `x-session-id` / `x-client-session-id` header so two
33
+ * upstream UIs talking to one dario don't collapse onto
34
+ * a single session id. Default false preserves v3.27
35
+ * single-account semantics.
36
+ *
37
+ * Pure logic (decideSessionRotation) is separated from the stateful cache
38
+ * (SessionRegistry) so tests can walk every decision branch without Maps,
39
+ * timers, or UUID sources. The proxy injects a `() => string` id factory
40
+ * (randomUUID) and `() => number` rng so both are swappable in tests.
41
+ *
42
+ * Pool mode is unaffected — each account carries a stable identity.sessionId
43
+ * for its lifetime, and the caller doesn't consult this registry. This
44
+ * module only governs the single-account SESSION_ID slot.
45
+ */
46
+ export interface SessionRotationConfig {
47
+ /** Idle threshold in ms: if no traffic for this long, the session rotates on the next request. */
48
+ idleRotateMs: number;
49
+ /** Max additional uniform-random ms added to the idle threshold at session creation. Pass 0 to disable. */
50
+ jitterMs: number;
51
+ /** Optional hard cap on session lifetime in ms. Undefined = no cap. */
52
+ maxAgeMs?: number;
53
+ /** When true, key sessions by client header so multiple upstreams get distinct ids. Default false. */
54
+ perClient: boolean;
55
+ }
56
+ export interface SessionEntry {
57
+ /** The session id sent to Anthropic in the outbound body. */
58
+ upstreamSessionId: string;
59
+ /** Wall-clock creation time (ms since epoch). */
60
+ createdAt: number;
61
+ /** Wall-clock time of last outbound use (ms since epoch). */
62
+ lastUsedAt: number;
63
+ /** Jitter offset sampled once at creation; added to cfg.idleRotateMs to get this session's effective idle threshold. */
64
+ idleJitterOffsetMs: number;
65
+ }
66
+ export type RotationDecision = 'keep' | 'rotate-new' | 'rotate-idle' | 'rotate-age';
67
+ /**
68
+ * Pure decision: should the given entry be rotated at `now`?
69
+ *
70
+ * Returns 'rotate-new' when no entry exists yet (first use for this key).
71
+ * Returns 'rotate-idle' when traffic has been silent for longer than this
72
+ * entry's sampled threshold. Returns 'rotate-age' when the entry's
73
+ * absolute lifetime exceeds cfg.maxAgeMs (when set). Otherwise 'keep'.
74
+ *
75
+ * Idle is checked before age so an idle-but-young session rotates on a
76
+ * fresh conversation boundary rather than churning mid-conversation at
77
+ * exactly its max-age. Negative config values are clamped to 0 (lenient:
78
+ * a typoed flag should behave like "rotate eagerly", not crash startup).
79
+ */
80
+ export declare function decideSessionRotation(entry: SessionEntry | undefined, now: number, cfg: SessionRotationConfig): RotationDecision;
81
+ /** Result of SessionRegistry.getOrCreate — both the id to send and why it was chosen. */
82
+ export interface RegistryResult {
83
+ sessionId: string;
84
+ rotated: boolean;
85
+ reason: RotationDecision;
86
+ }
87
+ /**
88
+ * Per-client session cache with rotation + LRU eviction.
89
+ *
90
+ * Not concurrency-safe — the proxy's dispatch loop is single-threaded
91
+ * JavaScript and call sites are serialized by the event loop. The
92
+ * registry is intentionally a plain Map, not a TTL cache, because
93
+ * rotation timing is part of the observable behaviour we're modelling
94
+ * and a background sweeper would add a separate dimension (WHEN entries
95
+ * disappear) that doesn't exist in a real CC client.
96
+ *
97
+ * maxEntries defaults to 1024 — more than enough for any reasonable
98
+ * fan-out while capping memory growth against a pathological client
99
+ * that sends a fresh session header on every request.
100
+ */
101
+ export declare class SessionRegistry {
102
+ private readonly cfg;
103
+ private readonly newId;
104
+ private readonly rng;
105
+ private readonly maxEntries;
106
+ private readonly entries;
107
+ constructor(cfg: SessionRotationConfig, newId: () => string, rng?: () => number, maxEntries?: number);
108
+ /**
109
+ * Resolve the outbound session id for a given client key at time `now`.
110
+ *
111
+ * `clientKey` is the caller-side session header when cfg.perClient is
112
+ * true, and ignored (replaced with 'default') when perClient is false.
113
+ * Callers pass the raw header value and let the registry decide —
114
+ * otherwise flipping perClient at runtime would require threading
115
+ * the decision to every call site.
116
+ *
117
+ * Updates lastUsedAt on the entry (whether kept or freshly minted),
118
+ * and nudges the entry to the end of the insertion-order map so
119
+ * eviction under maxEntries pressure is LRU.
120
+ */
121
+ getOrCreate(clientKey: string | undefined, now: number): RegistryResult;
122
+ /**
123
+ * Read the current id for a client key without touching lastUsedAt.
124
+ *
125
+ * Used by out-of-band consumers (e.g. presence pings) that want to
126
+ * reflect the most recently assigned session id but must not count
127
+ * as activity for rotation purposes. Returns undefined if no entry.
128
+ */
129
+ peek(clientKey: string | undefined): string | undefined;
130
+ size(): number;
131
+ clear(): void;
132
+ private evictIfOverCap;
133
+ }
134
+ /**
135
+ * Resolve a SessionRotationConfig from explicit options, env vars, and defaults.
136
+ *
137
+ * Precedence (highest first):
138
+ * 1. Explicit argument (typically from CLI flag)
139
+ * 2. DARIO_SESSION_IDLE_ROTATE_MS / DARIO_SESSION_JITTER_MS /
140
+ * DARIO_SESSION_MAX_AGE_MS / DARIO_SESSION_PER_CLIENT env vars
141
+ * 3. Defaults: idleRotateMs=15min, jitterMs=0, maxAgeMs=undefined,
142
+ * perClient=false — exactly matches the hardcoded v3.27 behaviour.
143
+ *
144
+ * Invalid numeric strings fall through to the next source. For perClient,
145
+ * '1' / 'true' / 'yes' (case-insensitive) enable; anything else stays at
146
+ * the explicit or default value.
147
+ */
148
+ export declare function resolveSessionRotationConfig(explicit?: {
149
+ idleRotateMs?: number;
150
+ jitterMs?: number;
151
+ maxAgeMs?: number;
152
+ perClient?: boolean;
153
+ }, env?: NodeJS.ProcessEnv): SessionRotationConfig;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Session-ID lifecycle (v3.28, direction #1 — interactive-side rotation).
3
+ *
4
+ * Every outbound request to Anthropic carries a session identifier in the
5
+ * CC request body's metadata. Real Claude Code holds that id stable through
6
+ * a conversation and mints a new one when the user returns after an idle
7
+ * gap — roughly "one id per conversation", not per HTTP call. A proxy that
8
+ * rotates per-request looks synthetic; one that never rotates looks equally
9
+ * synthetic over long sessions. v3.19 tightened the per-request leak into a
10
+ * single hardcoded 15-minute idle window; this module generalises that into
11
+ * a registry so operators can tune the behaviour and so the multi-client
12
+ * case (dario fanning multiple UIs through one proxy) stops sharing one id.
13
+ *
14
+ * Three independent knobs:
15
+ *
16
+ * idleRotateMs — the v3.19 behaviour: rotate after this many ms of no
17
+ * traffic on a given session. Default 15 min preserves
18
+ * v3.27 exactly when the other knobs stay at defaults.
19
+ *
20
+ * jitterMs — the observable idle threshold for a given session is
21
+ * idleRotateMs + U(0, jitterMs), sampled once at session
22
+ * creation. A zero-jitter proxy rotates at exactly the
23
+ * same interval every time; adding jitter means the floor
24
+ * can't be inferred from long-run rotation cadence.
25
+ *
26
+ * maxAgeMs — hard cap on a session's total lifetime regardless of
27
+ * activity. Optional (undefined disables). A chatty
28
+ * always-on pipeline would otherwise keep one session id
29
+ * alive for days; real CC conversations don't.
30
+ *
31
+ * perClient — when true, the registry keys sessions by the caller's
32
+ * `x-session-id` / `x-client-session-id` header so two
33
+ * upstream UIs talking to one dario don't collapse onto
34
+ * a single session id. Default false preserves v3.27
35
+ * single-account semantics.
36
+ *
37
+ * Pure logic (decideSessionRotation) is separated from the stateful cache
38
+ * (SessionRegistry) so tests can walk every decision branch without Maps,
39
+ * timers, or UUID sources. The proxy injects a `() => string` id factory
40
+ * (randomUUID) and `() => number` rng so both are swappable in tests.
41
+ *
42
+ * Pool mode is unaffected — each account carries a stable identity.sessionId
43
+ * for its lifetime, and the caller doesn't consult this registry. This
44
+ * module only governs the single-account SESSION_ID slot.
45
+ */
46
+ /**
47
+ * Pure decision: should the given entry be rotated at `now`?
48
+ *
49
+ * Returns 'rotate-new' when no entry exists yet (first use for this key).
50
+ * Returns 'rotate-idle' when traffic has been silent for longer than this
51
+ * entry's sampled threshold. Returns 'rotate-age' when the entry's
52
+ * absolute lifetime exceeds cfg.maxAgeMs (when set). Otherwise 'keep'.
53
+ *
54
+ * Idle is checked before age so an idle-but-young session rotates on a
55
+ * fresh conversation boundary rather than churning mid-conversation at
56
+ * exactly its max-age. Negative config values are clamped to 0 (lenient:
57
+ * a typoed flag should behave like "rotate eagerly", not crash startup).
58
+ */
59
+ export function decideSessionRotation(entry, now, cfg) {
60
+ if (!entry)
61
+ return 'rotate-new';
62
+ const idleBase = Math.max(0, cfg.idleRotateMs);
63
+ const idleThreshold = idleBase + Math.max(0, entry.idleJitterOffsetMs);
64
+ if (now - entry.lastUsedAt > idleThreshold)
65
+ return 'rotate-idle';
66
+ if (cfg.maxAgeMs !== undefined && cfg.maxAgeMs > 0 && now - entry.createdAt > cfg.maxAgeMs) {
67
+ return 'rotate-age';
68
+ }
69
+ return 'keep';
70
+ }
71
+ /**
72
+ * Per-client session cache with rotation + LRU eviction.
73
+ *
74
+ * Not concurrency-safe — the proxy's dispatch loop is single-threaded
75
+ * JavaScript and call sites are serialized by the event loop. The
76
+ * registry is intentionally a plain Map, not a TTL cache, because
77
+ * rotation timing is part of the observable behaviour we're modelling
78
+ * and a background sweeper would add a separate dimension (WHEN entries
79
+ * disappear) that doesn't exist in a real CC client.
80
+ *
81
+ * maxEntries defaults to 1024 — more than enough for any reasonable
82
+ * fan-out while capping memory growth against a pathological client
83
+ * that sends a fresh session header on every request.
84
+ */
85
+ export class SessionRegistry {
86
+ cfg;
87
+ newId;
88
+ rng;
89
+ maxEntries;
90
+ entries = new Map();
91
+ constructor(cfg, newId, rng = Math.random, maxEntries = 1024) {
92
+ this.cfg = cfg;
93
+ this.newId = newId;
94
+ this.rng = rng;
95
+ this.maxEntries = maxEntries;
96
+ }
97
+ /**
98
+ * Resolve the outbound session id for a given client key at time `now`.
99
+ *
100
+ * `clientKey` is the caller-side session header when cfg.perClient is
101
+ * true, and ignored (replaced with 'default') when perClient is false.
102
+ * Callers pass the raw header value and let the registry decide —
103
+ * otherwise flipping perClient at runtime would require threading
104
+ * the decision to every call site.
105
+ *
106
+ * Updates lastUsedAt on the entry (whether kept or freshly minted),
107
+ * and nudges the entry to the end of the insertion-order map so
108
+ * eviction under maxEntries pressure is LRU.
109
+ */
110
+ getOrCreate(clientKey, now) {
111
+ const key = this.cfg.perClient ? (clientKey && clientKey.length > 0 ? clientKey : 'default') : 'default';
112
+ const existing = this.entries.get(key);
113
+ const decision = decideSessionRotation(existing, now, this.cfg);
114
+ if (decision === 'keep' && existing) {
115
+ existing.lastUsedAt = now;
116
+ // Re-insert to refresh LRU position.
117
+ this.entries.delete(key);
118
+ this.entries.set(key, existing);
119
+ return { sessionId: existing.upstreamSessionId, rotated: false, reason: 'keep' };
120
+ }
121
+ const jitterOffset = this.cfg.jitterMs > 0 ? Math.floor(this.rng() * this.cfg.jitterMs) : 0;
122
+ const entry = {
123
+ upstreamSessionId: this.newId(),
124
+ createdAt: now,
125
+ lastUsedAt: now,
126
+ idleJitterOffsetMs: jitterOffset,
127
+ };
128
+ this.entries.set(key, entry);
129
+ this.evictIfOverCap();
130
+ return { sessionId: entry.upstreamSessionId, rotated: true, reason: decision };
131
+ }
132
+ /**
133
+ * Read the current id for a client key without touching lastUsedAt.
134
+ *
135
+ * Used by out-of-band consumers (e.g. presence pings) that want to
136
+ * reflect the most recently assigned session id but must not count
137
+ * as activity for rotation purposes. Returns undefined if no entry.
138
+ */
139
+ peek(clientKey) {
140
+ const key = this.cfg.perClient ? (clientKey && clientKey.length > 0 ? clientKey : 'default') : 'default';
141
+ return this.entries.get(key)?.upstreamSessionId;
142
+ }
143
+ size() {
144
+ return this.entries.size;
145
+ }
146
+ clear() {
147
+ this.entries.clear();
148
+ }
149
+ evictIfOverCap() {
150
+ while (this.entries.size > this.maxEntries) {
151
+ const oldest = this.entries.keys().next().value;
152
+ if (oldest === undefined)
153
+ break;
154
+ this.entries.delete(oldest);
155
+ }
156
+ }
157
+ }
158
+ /**
159
+ * Resolve a SessionRotationConfig from explicit options, env vars, and defaults.
160
+ *
161
+ * Precedence (highest first):
162
+ * 1. Explicit argument (typically from CLI flag)
163
+ * 2. DARIO_SESSION_IDLE_ROTATE_MS / DARIO_SESSION_JITTER_MS /
164
+ * DARIO_SESSION_MAX_AGE_MS / DARIO_SESSION_PER_CLIENT env vars
165
+ * 3. Defaults: idleRotateMs=15min, jitterMs=0, maxAgeMs=undefined,
166
+ * perClient=false — exactly matches the hardcoded v3.27 behaviour.
167
+ *
168
+ * Invalid numeric strings fall through to the next source. For perClient,
169
+ * '1' / 'true' / 'yes' (case-insensitive) enable; anything else stays at
170
+ * the explicit or default value.
171
+ */
172
+ export function resolveSessionRotationConfig(explicit = {}, env = process.env) {
173
+ const idleRotateMs = pickNonNegativeInt(explicit.idleRotateMs, env.DARIO_SESSION_IDLE_ROTATE_MS) ?? 15 * 60 * 1000;
174
+ const jitterMs = pickNonNegativeInt(explicit.jitterMs, env.DARIO_SESSION_JITTER_MS) ?? 0;
175
+ const maxAgeMs = pickPositiveInt(explicit.maxAgeMs, env.DARIO_SESSION_MAX_AGE_MS);
176
+ const perClient = pickBool(explicit.perClient, env.DARIO_SESSION_PER_CLIENT) ?? false;
177
+ return { idleRotateMs, jitterMs, maxAgeMs, perClient };
178
+ }
179
+ function pickNonNegativeInt(...candidates) {
180
+ for (const c of candidates) {
181
+ if (c === undefined || c === null || c === '')
182
+ continue;
183
+ const n = typeof c === 'number' ? c : parseInt(c, 10);
184
+ if (Number.isFinite(n) && n >= 0)
185
+ return Math.floor(n);
186
+ }
187
+ return undefined;
188
+ }
189
+ function pickPositiveInt(...candidates) {
190
+ for (const c of candidates) {
191
+ if (c === undefined || c === null || c === '')
192
+ continue;
193
+ const n = typeof c === 'number' ? c : parseInt(c, 10);
194
+ if (Number.isFinite(n) && n > 0)
195
+ return Math.floor(n);
196
+ }
197
+ return undefined;
198
+ }
199
+ function pickBool(...candidates) {
200
+ for (const c of candidates) {
201
+ if (c === undefined || c === null)
202
+ continue;
203
+ if (typeof c === 'boolean')
204
+ return c;
205
+ const s = c.trim().toLowerCase();
206
+ if (s === '')
207
+ continue;
208
+ if (s === '1' || s === 'true' || s === 'yes' || s === 'on')
209
+ return true;
210
+ if (s === '0' || s === 'false' || s === 'no' || s === 'off')
211
+ return false;
212
+ }
213
+ return undefined;
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.27.0",
3
+ "version": "3.28.0",
4
4
  "description": "A local LLM router. One endpoint, every provider — Claude subscriptions, OpenAI, OpenRouter, Groq, local LiteLLM, any OpenAI-compat endpoint — your tools don't need to change.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "build": "tsc && cp src/cc-template-data.json dist/ && node -e \"require('fs').mkdirSync('dist/shim',{recursive:true})\" && cp src/shim/runtime.cjs dist/shim/",
24
- "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/proxy-body-order.mjs && node test/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/mcp-protocol.mjs && node test/mcp-tools.mjs && node test/mcp-e2e.mjs && node test/drift-detection.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs",
24
+ "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/proxy-body-order.mjs && node test/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/mcp-protocol.mjs && node test/mcp-tools.mjs && node test/mcp-e2e.mjs && node test/session-rotation.mjs && node test/drift-detection.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs",
25
25
  "audit": "npm audit --production --audit-level=high",
26
26
  "prepublishOnly": "npm run build",
27
27
  "start": "node dist/cli.js",