@mlx-node/server 0.0.9 → 0.0.12

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.
Files changed (53) hide show
  1. package/dist/chat-session-warm-reuse.d.ts +10 -12
  2. package/dist/chat-session-warm-reuse.d.ts.map +1 -1
  3. package/dist/chat-session-warm-reuse.js +10 -12
  4. package/dist/endpoints/messages.d.ts +2 -2
  5. package/dist/endpoints/messages.d.ts.map +1 -1
  6. package/dist/endpoints/messages.js +492 -349
  7. package/dist/endpoints/responses.d.ts +1 -1
  8. package/dist/endpoints/responses.d.ts.map +1 -1
  9. package/dist/endpoints/responses.js +1149 -1055
  10. package/dist/handler.d.ts.map +1 -1
  11. package/dist/handler.js +1 -1
  12. package/dist/health.d.ts +4 -6
  13. package/dist/health.d.ts.map +1 -1
  14. package/dist/host/discover.d.ts +1 -2
  15. package/dist/host/discover.d.ts.map +1 -1
  16. package/dist/host/discover.js +3 -6
  17. package/dist/host/index.d.ts +6 -1
  18. package/dist/host/index.d.ts.map +1 -1
  19. package/dist/host/index.js +3 -2
  20. package/dist/index.d.ts +2 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -9
  23. package/dist/mappers/anthropic-request.d.ts.map +1 -1
  24. package/dist/mappers/anthropic-request.js +5 -1
  25. package/dist/mappers/request.d.ts +12 -2
  26. package/dist/mappers/request.d.ts.map +1 -1
  27. package/dist/mappers/request.js +26 -2
  28. package/dist/model-work-coordinator.d.ts +50 -0
  29. package/dist/model-work-coordinator.d.ts.map +1 -1
  30. package/dist/model-work-coordinator.js +161 -0
  31. package/dist/registry.d.ts +15 -0
  32. package/dist/registry.d.ts.map +1 -1
  33. package/dist/registry.js +51 -0
  34. package/dist/server.d.ts +21 -3
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +30 -6
  37. package/dist/session-registry.d.ts +266 -86
  38. package/dist/session-registry.d.ts.map +1 -1
  39. package/dist/session-registry.js +421 -107
  40. package/dist/streaming.d.ts +37 -2
  41. package/dist/streaming.d.ts.map +1 -1
  42. package/dist/streaming.js +122 -1
  43. package/dist/transport-visibility.d.ts +5 -4
  44. package/dist/transport-visibility.d.ts.map +1 -1
  45. package/dist/transport-visibility.js +5 -4
  46. package/dist/types-anthropic.d.ts +7 -0
  47. package/dist/types-anthropic.d.ts.map +1 -1
  48. package/dist/types.d.ts +9 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +4 -4
  51. package/dist/presets.d.ts +0 -82
  52. package/dist/presets.d.ts.map +0 -1
  53. package/dist/presets.js +0 -98
@@ -21,18 +21,13 @@
21
21
  * by response id — no secondary keying on model name because the
22
22
  * registry is already scoped per model.
23
23
  *
24
- * - **Single-warm-session invariant.** `ChatSession<M>` is a thin
25
- * JS wrapper it does NOT own any native KV cache. The cache
26
- * lives on the underlying `SessionCapableModel` (one shared
27
- * `cached_token_history` / `caches` vector per model instance).
28
- * Any call that runs a turn overwrites that shared native state,
29
- * silently invalidating every other `ChatSession` wrapper
30
- * pointing at the same model. Caching multiple wrappers per
31
- * model is therefore an illusion: at most ONE matches real
32
- * native state (whichever ran most recently). To prevent
33
- * cross-session corruption this registry holds at most ONE
34
- * entry — both `getOrCreate` and `adopt` clear the map before
35
- * returning or inserting.
24
+ * - **Single-warm-session invariant.** The JS warm registry retains at
25
+ * most ONE `ChatSession` entry both `getOrCreate` and `adopt` clear
26
+ * the map before returning or inserting. This remains the sole safe
27
+ * reuse mechanism for flat-cache models whose native cache is one
28
+ * mutable vector. Block-paged schedulers instead isolate live turns by
29
+ * cache owner and reuse verified physical blocks through the native
30
+ * prefix table; those models may run fresh JS sessions concurrently.
36
31
  *
37
32
  * - **Lease semantics on hit.** Clear-on-hit also gives single-
38
33
  * flight lease semantics: two overlapping requests referencing
@@ -43,14 +38,13 @@
43
38
  * `ChatSession`'s single-flight "concurrent send() not allowed"
44
39
  * guard.
45
40
  *
46
- * - **Instructions / prefix-state change also misses.** Each entry
47
- * records the `instructions` string used to adopt it.
48
- * `getOrCreate` compares the caller's `requestedInstructions`
49
- * against the cached value; mismatch forces cold replay so the
50
- * new prefix state is re-primed instead of silently reusing a
51
- * stale warmed prompt. The OpenAI `instructions` field and the
52
- * Anthropic `system` field both flow through the same parameter
53
- * — the registry does not care which is which.
41
+ * - **Prefix compatibility changes miss.** Each entry records its
42
+ * `instructions` plus an opaque fingerprint of the cache salt used to
43
+ * adopt it. `getOrCreate` compares both against the new request;
44
+ * mismatch forces owner release and cold replay instead of reusing a
45
+ * stale prompt or changing the security domain of one live native
46
+ * request. OpenAI `instructions` and Anthropic `system` share the same
47
+ * parameter; both endpoints also thread their mapped `cache_salt`.
54
48
  *
55
49
  * - **Cache miss fallback.** On a miss (eviction, interleaved turn
56
50
  * on a different chain, restart, lease-on-hit) the endpoint
@@ -73,23 +67,12 @@
73
67
  * concurrent mutation by design. `sweep()` can be scheduled
74
68
  * via `setInterval` without colliding with in-flight calls.
75
69
  *
76
- * - **Per-model execution mutex.** A dispatch that spans multiple
77
- * awaits (map -> prefill -> decode -> persist -> adopt) is NOT
78
- * atomic from the registry's POV. Two requests against the
79
- * same model would both receive a `ChatSession` pointing at
80
- * the same native model; even though the lease-on-hit clear
81
- * prevents sharing one `ChatSession` object, the native KV
82
- * cache is a single mutable resource and two parallel
83
- * `primeHistory()` / `send*()` calls would race. Whichever
84
- * finished last would win `adopt()`, poisoning the hot path
85
- * for every subsequent chained turn.
86
- *
87
- * `withExclusive(fn)` serializes every per-model dispatch via
88
- * a FIFO `execLock` chain. `/v1/responses` and `/v1/messages`
89
- * wrap the full `getOrCreate -> run -> adopt/drop` span in one
90
- * `withExclusive` so at most one request holds the model at a
91
- * time. A weaker epoch-token scheme would let the losing
92
- * `adopt()` no-op but the native KV would already be wrong.
70
+ * - **Per-model admission lane.** Flat and not-yet-batched families use
71
+ * `withExclusive(fn)`, the original FIFO mutex across the full dispatch.
72
+ * A model that explicitly reports `maxConcurrentSequences() > 1` uses
73
+ * `withAdmission(fn)`, a counting semaphore sized to that native
74
+ * scheduler. Per-session serialization still lives in `ChatSession`;
75
+ * only independent sessions share the model lane.
93
76
  */
94
77
  import { createHash, createHmac, randomBytes } from 'node:crypto';
95
78
  import { ChatSession } from '@mlx-node/lm';
@@ -160,12 +143,17 @@ function getNonce() {
160
143
  }
161
144
  return cachedNonce;
162
145
  }
146
+ /** Opaque equality token for a cache salt; the caller's raw value is never retained. */
147
+ function fingerprintCacheSalt(cacheSalt) {
148
+ if (cacheSalt == null)
149
+ return null;
150
+ return createHmac('sha256', getNonce()).update(cacheSalt).digest('hex').slice(0, 32);
151
+ }
163
152
  /**
164
- * Test-only hook used by the scoping unit tests to simulate a
165
- * server restart: resets the module-scoped HMAC nonce (so every
166
- * previously stored tier-2 key misses) and clears the silent-miss
167
- * dedupe cache so tests can re-exercise the once-per-key diagnostic
168
- * path.
153
+ * Test-only hook used by the scoping unit tests to simulate a server
154
+ * restart: resets the module-scoped HMAC nonce (so every previously stored
155
+ * tier-2 key and cache-salt fingerprint misses) and clears the silent-miss
156
+ * dedupe cache so tests can re-exercise the once-per-key diagnostic path.
169
157
  *
170
158
  * **Not exported from the package's public `index.ts` surface** —
171
159
  * exporting it there would let downstream consumers nuke tier-2
@@ -296,12 +284,18 @@ export function maybeWarnPromptCacheKeyIneligible(rawKey) {
296
284
  * can reliably catch it without racing the chain.
297
285
  */
298
286
  export class QueueFullError extends Error {
299
- queuedCount;
287
+ queueDepth;
288
+ preDispatchAdmissions;
289
+ admissionFootprint;
300
290
  limit;
301
- constructor(queuedCount, limit) {
302
- super(`Model queue full: ${queuedCount} waiting (limit ${limit})`);
291
+ constructor(queueDepth, preDispatchAdmissions, limit) {
292
+ const admissionFootprint = queueDepth + preDispatchAdmissions;
293
+ super(`Model queue full: ${queueDepth} queued, ${preDispatchAdmissions} pre-dispatch ` +
294
+ `(${admissionFootprint} admitted outside the active runner; waiter limit ${limit})`);
303
295
  this.name = 'QueueFullError';
304
- this.queuedCount = queuedCount;
296
+ this.queueDepth = queueDepth;
297
+ this.preDispatchAdmissions = preDispatchAdmissions;
298
+ this.admissionFootprint = admissionFootprint;
305
299
  this.limit = limit;
306
300
  }
307
301
  }
@@ -313,6 +307,7 @@ export class SessionRegistry {
313
307
  model;
314
308
  ttlSec;
315
309
  maxQueueDepth;
310
+ maxConcurrentDispatches;
316
311
  /**
317
312
  * Per-model sampling defaults forwarded into every new `ChatSession`
318
313
  * via its `defaultConfig` constructor option. `undefined` preserves
@@ -342,6 +337,25 @@ export class SessionRegistry {
342
337
  * runner-slot caller against the waiter cap.
343
338
  */
344
339
  queuedCount = 0;
340
+ /**
341
+ * Requests admitted by {@link beginPreDispatchAdmission} whose permit
342
+ * is still outstanding — parked in the `ModelWorkCoordinator` writer
343
+ * queue (host mode), blocked in pre-lock store lookups
344
+ * (`previous_response_id` continuations), or anywhere else between the
345
+ * endpoint gate and resident-lane placement. None of that parking is
346
+ * visible to `queuedCount`; this counter is what lets the gate bound
347
+ * it. Decremented ONLY by the permit itself: `release()` on a bail-out
348
+ * or the atomic consume inside the selected lane on handoff.
349
+ */
350
+ preDispatchAdmits = 0;
351
+ /**
352
+ * Consume hooks for outstanding permits, keyed by permit identity.
353
+ * Registry-scoped on purpose: both execution lanes consult THIS map, so a
354
+ * permit minted by a different registry is simply not found and the
355
+ * call falls back to normal waiter charging — a cross-registry handoff
356
+ * cannot corrupt either registry's counters.
357
+ */
358
+ permitConsumers = new WeakMap();
345
359
  /**
346
360
  * Holds AT MOST ONE entry under the single-warm invariant (see the
347
361
  * module-level rustdoc). `getOrCreate` and `adopt` both clear the
@@ -350,6 +364,15 @@ export class SessionRegistry {
350
364
  * turn on another cached entry.
351
365
  */
352
366
  entries = new Map();
367
+ /**
368
+ * Eviction is synchronous at the map boundary, but releasing a native
369
+ * scheduler owner is asynchronous. Start every disposal immediately and
370
+ * retain its promise so endpoint admission lanes can wait for command-order
371
+ * visibility before dispatching a replacement turn.
372
+ */
373
+ pendingDisposals = new Set();
374
+ disposalBySession = new WeakMap();
375
+ failedDisposals = new Set();
353
376
  /**
354
377
  * Shared sentinel representing "the execution chain is idle" — a
355
378
  * pre-resolved promise. `execLock` starts at this value and is
@@ -375,10 +398,17 @@ export class SessionRegistry {
375
398
  * cleanly from the idle state.
376
399
  */
377
400
  execLock = this.initialLock;
401
+ /** Active holders in the continuous-batching admission lane. */
402
+ activeAdmissions = 0;
403
+ /** FIFO waiters parked behind the continuous-batching admission limit. */
404
+ admissionWaiters = [];
378
405
  constructor(opts) {
379
406
  this.model = opts.model;
380
407
  this.ttlSec = opts.ttlSec ?? 1800;
381
408
  this.maxQueueDepth = opts.maxQueueDepth;
409
+ const requestedConcurrency = opts.maxConcurrentDispatches ?? 1;
410
+ this.maxConcurrentDispatches =
411
+ Number.isSafeInteger(requestedConcurrency) && requestedConcurrency > 1 ? requestedConcurrency : 1;
382
412
  this.samplingDefaults = opts.samplingDefaults;
383
413
  this.maxOutputTokens = opts.maxOutputTokens;
384
414
  }
@@ -398,16 +428,88 @@ export class SessionRegistry {
398
428
  defaultConfig: this.samplingDefaults,
399
429
  });
400
430
  }
431
+ scheduleDispose(session) {
432
+ if (this.disposalBySession.has(session))
433
+ return;
434
+ const disposal = this.disposeSession(session)
435
+ .catch((error) => {
436
+ console.error('[server] failed to release an evicted chat-session cache owner:', error);
437
+ })
438
+ .finally(() => {
439
+ this.pendingDisposals.delete(disposal);
440
+ this.disposalBySession.delete(session);
441
+ });
442
+ this.disposalBySession.set(session, disposal);
443
+ this.pendingDisposals.add(disposal);
444
+ }
445
+ /**
446
+ * Dispose one leased session while retaining failed cleanup for a later
447
+ * registry flush. `ChatSession.dispose()` removes successful owners as it
448
+ * goes, so a retry only revisits owners whose native release failed.
449
+ */
450
+ async disposeSession(session) {
451
+ try {
452
+ await session.dispose();
453
+ this.failedDisposals.delete(session);
454
+ }
455
+ catch (error) {
456
+ this.failedDisposals.add(session);
457
+ throw error;
458
+ }
459
+ }
460
+ /** Remove every cached entry except an optional session being leased. */
461
+ evictEntriesExcept(keep) {
462
+ for (const entry of this.entries.values()) {
463
+ if (entry.session !== keep)
464
+ this.scheduleDispose(entry.session);
465
+ }
466
+ this.entries.clear();
467
+ }
468
+ /**
469
+ * Wait until every disposal scheduled so far has settled, retrying each
470
+ * failure discovered during this flush once. A persistent failure remains
471
+ * recorded for a later flush instead of spinning forever. Disposals log and
472
+ * absorb their own failures so cleanup cannot rewrite a response that has
473
+ * already reached the client.
474
+ */
475
+ async flushPendingDisposals() {
476
+ const retried = new Set();
477
+ while (true) {
478
+ const retries = Array.from(this.failedDisposals).filter((session) => !retried.has(session));
479
+ for (const session of retries) {
480
+ this.failedDisposals.delete(session);
481
+ retried.add(session);
482
+ this.scheduleDispose(session);
483
+ }
484
+ if (this.pendingDisposals.size === 0)
485
+ return;
486
+ await Promise.all(Array.from(this.pendingDisposals));
487
+ }
488
+ }
489
+ /**
490
+ * Disposals started but not yet settled — each one is an in-flight
491
+ * native `releaseCacheOwner` round-trip — plus disposals whose initial
492
+ * attempt and bounded retry both failed and remain owed to the native
493
+ * scheduler ({@link failedDisposals}), retried by the next
494
+ * {@link flushPendingDisposals}. Endpoints await that flush before
495
+ * leaving the admission lane, but it runs after the response has
496
+ * finished, so an observer keyed on request completion can still beat
497
+ * the release. `adopt`/`drop`/`sweep` schedule synchronously, so once
498
+ * the request counters read zero any disposal those requests will ever
499
+ * cause is already counted here. Primarily for diagnostics/tests.
500
+ */
501
+ get pendingDisposalCount() {
502
+ return this.pendingDisposals.size + this.failedDisposals.size;
503
+ }
401
504
  /**
402
- * Number of requests currently WAITING to acquire the per-model
403
- * execution mutex. Does NOT include the one actively running inside
404
- * `fn`. Primarily for tests and diagnostics.
505
+ * Number of requests waiting for this model's selected admission lane.
506
+ * Active dispatches are not included. Primarily for diagnostics/tests.
405
507
  */
406
508
  get queueDepth() {
407
509
  return this.queuedCount;
408
510
  }
409
511
  /**
410
- * Configured waiter cap for this model's execution mutex, or `undefined`
512
+ * Configured waiter cap for this model's admission lane, or `undefined`
411
513
  * when unbounded. Paired with {@link queueDepth} so a readiness probe can
412
514
  * tell "3 waiters, unbounded" (fine) from "3 waiters, cap of 3" (the next
413
515
  * request gets a 429) without reaching into private state.
@@ -415,6 +517,108 @@ export class SessionRegistry {
415
517
  get queueDepthLimit() {
416
518
  return this.maxQueueDepth;
417
519
  }
520
+ /** Native continuous-batching capacity used by the endpoint route switch. */
521
+ get concurrentAdmissionLimit() {
522
+ return this.maxConcurrentDispatches;
523
+ }
524
+ /**
525
+ * Outstanding pre-dispatch permits — requests admitted by
526
+ * {@link beginPreDispatchAdmission} that have neither handed their
527
+ * permit to the selected execution lane nor released it yet. These permits,
528
+ * queued callers, and active dispatches share one bounded budget; see
529
+ * {@link assertAdmissionCapacity}. For probes and diagnostics.
530
+ */
531
+ get preDispatchAdmitCount() {
532
+ return this.preDispatchAdmits;
533
+ }
534
+ /**
535
+ * Single source of truth for the per-model admission budget — the
536
+ * ONLY place the cap arithmetic lives. Every admission path calls
537
+ * this: {@link beginPreDispatchAdmission} before minting a permit,
538
+ * and both execution lanes for every non-handed-off caller. A handed-off
539
+ * permit skips the call for its OWN
540
+ * token only: that token was charged here at acquisition and its
541
+ * conversion keeps the total constant, so re-checking would
542
+ * double-charge an already-admitted request. It never exempts anyone
543
+ * else — all other outstanding state stays counted for every caller
544
+ * that did not pay.
545
+ *
546
+ * Budget invariant: active dispatches + queued callers + outstanding
547
+ * permits never exceed `maxConcurrentDispatches + maxQueueDepth`.
548
+ * On the exclusive lane `maxConcurrentDispatches` is one, exactly the
549
+ * original runner entitlement. On the batched lane it is the native
550
+ * scheduler's sequence capacity.
551
+ *
552
+ * Charging stays at the call sites (`preDispatchAdmits += 1` at the
553
+ * gate, `queuedCount += 1` for waiters, and the selected lane's active
554
+ * count for runners); every admitted unit is counted by exactly one
555
+ * at any time, which is what makes the footprint sum complete across
556
+ * any interleaving of permitted and permitless callers.
557
+ *
558
+ * Throws {@link QueueFullError} — reporting the footprint and the
559
+ * cap — when the caller does not fit; returns normally otherwise.
560
+ * No-op when the registry is unbounded.
561
+ */
562
+ assertAdmissionCapacity() {
563
+ if (this.maxQueueDepth === undefined)
564
+ return;
565
+ const activeDispatches = this.maxConcurrentDispatches > 1 ? this.activeAdmissions : this.execLock === this.initialLock ? 0 : 1;
566
+ const footprint = activeDispatches + this.queuedCount + this.preDispatchAdmits;
567
+ if (footprint >= this.maxQueueDepth + this.maxConcurrentDispatches) {
568
+ throw new QueueFullError(this.queuedCount, this.preDispatchAdmits, this.maxQueueDepth);
569
+ }
570
+ }
571
+ /**
572
+ * Endpoint-side early admission against this registry's cap, taken BEFORE
573
+ * the request enters any pre-dispatch parking spot the resident lane cannot
574
+ * see. Resident host traffic deliberately bypasses the model-load writer so
575
+ * continuous batching remains reachable, but a continuation can still block
576
+ * in `await store.getChain(...)` (or other pre-lock work) with `queuedCount`
577
+ * unchanged. This permit keeps all such work inside the same bounded budget.
578
+ *
579
+ * Accounting: pre-dispatch permits, active dispatches, and queued callers
580
+ * draw from ONE budget: `maxQueueDepth` waiter slots plus the selected
581
+ * lane's active capacity. Both lanes and this early gate route through
582
+ * {@link assertAdmissionCapacity}, so permitted and permitless arrivals
583
+ * cannot double-spend a slot.
584
+ *
585
+ * Throws {@link QueueFullError} synchronously when over cap (the
586
+ * caller maps it to the same 429 envelope as resident-lane rejection
587
+ * reject). On admission returns a {@link PreDispatchAdmission} permit
588
+ * the caller must RETAIN through ALL pre-lock asynchronous work and
589
+ * then hand to `withExclusive(fn, permit)` or `withAdmission(fn, permit)`,
590
+ * which consumes it
591
+ * atomically as that call's admission — one budget, one token per
592
+ * request, never double-counted. Releasing the permit early instead
593
+ * of handing it off re-opens the hole this gate closes: the request
594
+ * would be counted by NEITHER counter while parked, arrivals would
595
+ * refill the budget, and the resident lane would then admit a second
596
+ * full waiter budget on top. `release()` belongs on bail-out paths
597
+ * only (idempotent, no-op after handoff — an unconditional `finally`
598
+ * release is the recommended shape).
599
+ */
600
+ beginPreDispatchAdmission() {
601
+ this.assertAdmissionCapacity();
602
+ this.preDispatchAdmits += 1;
603
+ let settled = false;
604
+ const settle = () => {
605
+ if (settled)
606
+ return false;
607
+ settled = true;
608
+ this.preDispatchAdmits -= 1;
609
+ if (this.preDispatchAdmits < 0)
610
+ this.preDispatchAdmits = 0;
611
+ this.permitConsumers.delete(permit);
612
+ return true;
613
+ };
614
+ const permit = {
615
+ release: () => {
616
+ void settle();
617
+ },
618
+ };
619
+ this.permitConsumers.set(permit, settle);
620
+ return permit;
621
+ }
418
622
  /**
419
623
  * Current sampling defaults applied to every new `ChatSession` this
420
624
  * registry allocates. Exposed primarily for tests and diagnostics.
@@ -453,11 +657,11 @@ export class SessionRegistry {
453
657
  *
454
658
  * 1. **Tier 1 — `previousResponseId`.** The existing hot path:
455
659
  * exact id match on a live, non-expired entry whose stored
456
- * `instructions` are byte-equal to `requestedInstructions`. On
660
+ * `instructions` and cache-salt fingerprint match the request. On
457
661
  * a match the entry is leased out (single-use: removed from the
458
662
  * map so a concurrent second request cannot share the live
459
- * `ChatSession`). On a miss — unknown id, expired, or
460
- * instructions drift — the method falls through to a FRESH
663
+ * `ChatSession`). On a miss — unknown id, expired, instructions
664
+ * drift, or cache-salt drift — the method falls through to a FRESH
461
665
  * session regardless of whether tier 2 would have hit.
462
666
  *
463
667
  * `previousResponseId` wins unconditionally when supplied. The
@@ -477,9 +681,9 @@ export class SessionRegistry {
477
681
  * is to key on the client-supplied `prompt_cache_key`. Scans
478
682
  * for any live, non-expired entry whose stored
479
683
  * `promptCacheKey` is non-null AND byte-equal to the caller's
480
- * `promptCacheKey` AND whose stored `instructions` are byte-
481
- * equal. Empty string is treated as a distinct key from
482
- * `null` — an opt-out sentinel from a client that forgot to
684
+ * `promptCacheKey`, whose stored `instructions` are byte-equal,
685
+ * AND whose cache-salt fingerprint matches. Empty string is treated
686
+ * as a distinct key from `null` — an opt-out sentinel from a client that forgot to
483
687
  * thread the key must NOT collide with another client that
484
688
  * did set it to empty. On a match the entry is leased out
485
689
  * (same single-use semantics as tier 1). On a miss, fall
@@ -495,7 +699,8 @@ export class SessionRegistry {
495
699
  * native `cachedTokens > 0` confirms the prefix-cache machinery
496
700
  * actually reused the cached tokens).
497
701
  */
498
- getOrCreate(previousResponseId, requestedInstructions, promptCacheKey = null) {
702
+ getOrCreate(previousResponseId, requestedInstructions, promptCacheKey = null, requestedCacheSalt = null) {
703
+ const requestedCacheSaltFingerprint = fingerprintCacheSalt(requestedCacheSalt);
499
704
  // Tier 1: previousResponseId exact match.
500
705
  //
501
706
  // Every call is about to overwrite native KV state, so drop any
@@ -506,18 +711,22 @@ export class SessionRegistry {
506
711
  if (previousResponseId !== null) {
507
712
  const entry = this.entries.get(previousResponseId);
508
713
  if (entry === undefined) {
509
- this.entries.clear();
714
+ this.evictEntriesExcept();
510
715
  return { session: this.newSession(), hit: false };
511
716
  }
512
717
  if (entry.expiresAt < nowSec()) {
513
- this.entries.clear();
718
+ this.evictEntriesExcept();
514
719
  return { session: this.newSession(), hit: false };
515
720
  }
516
- // Prefix-state mismatch forces cold replay so the new
517
- // instructions are re-primed; without this guard, output would
518
- // silently depend on cache state instead of request contents.
721
+ // Prefix-state mismatch forces cold replay so new instructions are
722
+ // re-primed; cache-salt mismatch below likewise prevents a live native
723
+ // request from crossing prefix-cache security domains.
519
724
  if (entry.instructions !== requestedInstructions) {
520
- this.entries.clear();
725
+ this.evictEntriesExcept();
726
+ return { session: this.newSession(), hit: false };
727
+ }
728
+ if (entry.cacheSaltFingerprint !== requestedCacheSaltFingerprint) {
729
+ this.evictEntriesExcept();
521
730
  return { session: this.newSession(), hit: false };
522
731
  }
523
732
  // Tier-1 hit: clear and hand the session out as a single-use
@@ -526,7 +735,7 @@ export class SessionRegistry {
526
735
  // that even on a prev-id tier-1 MISS we do NOT fall through to
527
736
  // tier 2 — see the docstring above for the precedence
528
737
  // rationale.
529
- this.entries.clear();
738
+ this.evictEntriesExcept(entry.session);
530
739
  return { session: entry.session, hit: true };
531
740
  }
532
741
  // Tier 2: promptCacheKey scan (only reached when previousResponseId is null).
@@ -535,7 +744,7 @@ export class SessionRegistry {
535
744
  // invariant, so the "scan" is actually a single lookup — walk the
536
745
  // map, check the one entry if present, hit or miss. A non-null
537
746
  // scoped key on both the request and the entry plus byte-equal
538
- // instructions is the match condition.
747
+ // instructions and cache-salt fingerprints is the match condition.
539
748
  //
540
749
  // SECURITY: raw caller-supplied keys never touch the map. They
541
750
  // are run through {@link scopePromptCacheKey}, which (a) returns
@@ -556,16 +765,18 @@ export class SessionRegistry {
556
765
  continue;
557
766
  if (entry.instructions !== requestedInstructions)
558
767
  continue;
768
+ if (entry.cacheSaltFingerprint !== requestedCacheSaltFingerprint)
769
+ continue;
559
770
  // Tier-2 hit: clear and lease (same single-warm / single-use
560
771
  // semantics as tier 1).
561
- this.entries.clear();
772
+ this.evictEntriesExcept(entry.session);
562
773
  return { session: entry.session, hit: true };
563
774
  }
564
775
  }
565
776
  // Fall through: fresh session. Clear any leftover entry so a
566
777
  // later lookup cannot hand out a wrapper whose assumed state has
567
778
  // been overwritten by this dispatch.
568
- this.entries.clear();
779
+ this.evictEntriesExcept();
569
780
  return { session: this.newSession(), hit: false };
570
781
  }
571
782
  /**
@@ -634,17 +845,18 @@ export class SessionRegistry {
634
845
  * registry's own warm slot.
635
846
  *
636
847
  * Behaviour: walk the registry's at-most-one warm entry. If it is
637
- * non-expired AND its stored `instructions` are byte-equal to
638
- * `requestedInstructions`, lease it out (single-use `entries.clear()`
639
- * before return, mirroring the tier-1 / tier-2 lease-on-hit
640
- * semantics). Otherwise clear the map and return a fresh session.
848
+ * non-expired, its stored `instructions` are byte-equal to
849
+ * `requestedInstructions`, AND its stored cache salt equals
850
+ * `requestedCacheSalt`, lease it out (single-use `entries.clear()`
851
+ * before return, mirroring the tier-1 / tier-2 lease-on-hit semantics).
852
+ * Otherwise clear the map and return a fresh session.
641
853
  *
642
854
  * Crucially, this lookup IGNORES `entry.promptCacheKey` and ignores
643
855
  * the entry's prior `previousResponseId` keying — any warm slot is
644
- * fair game for `/v1/messages` reuse. The byte-equal `instructions`
645
- * compare is the SOLE correctness gate: a system prompt change
646
- * forces cold replay so the new prefix state is re-primed instead
647
- * of silently reusing a stale warmed prompt.
856
+ * fair game for `/v1/messages` reuse. Byte-equal instructions and cache
857
+ * salt are the correctness gates: a system prompt or prefix-cache
858
+ * security-domain change forces cold replay instead of reusing stale state
859
+ * or asking the native adapter to mutate a live request's salt.
648
860
  *
649
861
  * **Adoption sentinel.** `/v1/messages` adopts back under the literal
650
862
  * sentinel id `'__msg_warm__'`. That sentinel will never appear as a
@@ -680,9 +892,10 @@ export class SessionRegistry {
680
892
  * `responses.ts` (around the `runSessionNonStreaming` /
681
893
  * `runSessionStreaming` branches) describes.
682
894
  */
683
- getOrCreateWarmAny(requestedInstructions) {
895
+ getOrCreateWarmAny(requestedInstructions, requestedCacheSalt = null) {
896
+ const requestedCacheSaltFingerprint = fingerprintCacheSalt(requestedCacheSalt);
684
897
  // Single-warm invariant: at most one entry. Walk it once, lease
685
- // on a fresh + instructions-matched hit, otherwise clear and
898
+ // on a fresh + instructions-and-salt-matched hit, otherwise clear and
686
899
  // cold-start. The ignored fields (promptCacheKey,
687
900
  // previousResponseId-keying) are deliberate — see the docstring.
688
901
  for (const entry of this.entries.values()) {
@@ -690,14 +903,16 @@ export class SessionRegistry {
690
903
  continue;
691
904
  if (entry.instructions !== requestedInstructions)
692
905
  continue;
906
+ if (entry.cacheSaltFingerprint !== requestedCacheSaltFingerprint)
907
+ continue;
693
908
  // Hit: clear and lease (single-use semantics, same as tiers 1/2).
694
- this.entries.clear();
909
+ this.evictEntriesExcept(entry.session);
695
910
  return { session: entry.session, hit: true };
696
911
  }
697
- // Miss (no entry, expired, or instructions drift). Clear the map
912
+ // Miss (no entry, expired, instructions drift, or salt drift). Clear the map
698
913
  // so a stale wrapper cannot leak into a later lookup, and return
699
914
  // a fresh session.
700
- this.entries.clear();
915
+ this.evictEntriesExcept();
701
916
  return { session: this.newSession(), hit: false };
702
917
  }
703
918
  /**
@@ -708,6 +923,8 @@ export class SessionRegistry {
708
923
  * `instructions` is the prefix/system state used for this turn;
709
924
  * stored on the entry and compared on the next `getOrCreate` to
710
925
  * detect prefix changes that must force a cold replay.
926
+ * `cacheSalt` is HMAC-fingerprinted before storage and compared alongside
927
+ * the prefix so the raw security-domain key is not retained.
711
928
  *
712
929
  * `promptCacheKey` is the client-supplied conversation-chain key
713
930
  * that enables the registry's tier-2 lookup for stateless agent
@@ -718,17 +935,18 @@ export class SessionRegistry {
718
935
  * key-equality on both sides can hit tier 2). See
719
936
  * {@link SessionRegistry.getOrCreate} for the precedence rules.
720
937
  */
721
- adopt(responseId, session, instructions, promptCacheKey = null) {
938
+ adopt(responseId, session, instructions, promptCacheKey = null, cacheSalt = null) {
722
939
  // Scope the caller-supplied key BEFORE storing so a later
723
940
  // `getOrCreate` can only resolve entries via the same opt-in +
724
941
  // HMAC path. When tier-2 reuse is disabled (or the key is too
725
942
  // short / absent) `scopePromptCacheKey` returns `null`, which
726
943
  // disables this entry from ever matching a tier-2 lookup — the
727
944
  // raw caller-supplied key is NEVER stored.
728
- this.entries.clear();
945
+ this.evictEntriesExcept(session);
729
946
  this.entries.set(responseId, {
730
947
  session,
731
948
  instructions,
949
+ cacheSaltFingerprint: fingerprintCacheSalt(cacheSalt),
732
950
  promptCacheKey: scopePromptCacheKey(promptCacheKey ?? null),
733
951
  expiresAt: nowSec() + this.ttlSec,
734
952
  });
@@ -737,7 +955,11 @@ export class SessionRegistry {
737
955
  * Remove a session by response id. No-op if the key is not present.
738
956
  */
739
957
  drop(responseId) {
958
+ const entry = this.entries.get(responseId);
959
+ if (entry === undefined)
960
+ return;
740
961
  this.entries.delete(responseId);
962
+ this.scheduleDispose(entry.session);
741
963
  }
742
964
  /**
743
965
  * Walk the map and drop the entry if its TTL has expired.
@@ -749,12 +971,13 @@ export class SessionRegistry {
749
971
  for (const [key, entry] of this.entries) {
750
972
  if (entry.expiresAt < cutoff) {
751
973
  this.entries.delete(key);
974
+ this.scheduleDispose(entry.session);
752
975
  }
753
976
  }
754
977
  }
755
978
  /** Empty the registry. Useful at shutdown and in tests. */
756
979
  clear() {
757
- this.entries.clear();
980
+ this.evictEntriesExcept();
758
981
  }
759
982
  /**
760
983
  * Serialize `fn` against every other dispatch through this
@@ -770,33 +993,50 @@ export class SessionRegistry {
770
993
  * the new tail, awaits the old tail, then runs `fn`. The
771
994
  * `finally` releases regardless of whether `fn` threw.
772
995
  *
773
- * **Admission control.** When `maxQueueDepth` is configured and the
774
- * current number of waiters (`queuedCount`, excluding the active
775
- * holder) is already at or above the cap, the call throws
776
- * {@link QueueFullError} synchronously SYNCHRONOUSLY from the
777
- * caller's perspective, not merely before `await prev`. The wrapper
778
- * is deliberately NOT declared `async` so the admission gate
779
- * throws on the caller's stack frame, letting endpoint handlers
780
- * wrap the call site in a plain try/catch without racing promise
996
+ * **Admission control.** Every non-handed-off call waiter AND
997
+ * runner routes through {@link assertAdmissionCapacity}, the single
998
+ * source of truth for the budget: the combined admission footprint
999
+ * (`queuedCount + preDispatchAdmits`) plus this caller must fit
1000
+ * within `maxQueueDepth` plus the idle-chain runner entitlement.
1001
+ * Over-budget calls throw {@link QueueFullError} SYNCHRONOUSLY
1002
+ * from the caller's perspective, not merely before `await prev`. The
1003
+ * wrapper is deliberately NOT declared `async` so the admission gate
1004
+ * throws on the caller's stack frame, letting endpoint handlers wrap
1005
+ * the call site in a plain try/catch without racing promise
781
1006
  * microtasks. On acceptance the async body takes over via the
782
1007
  * returned `Promise<T>`.
783
1008
  *
784
- * The cap is "waiters-only" — a cap of N permits one running
785
- * dispatch plus N queued ones, rejecting the (N+1)th waiter. The
786
- * default (undefined) preserves the original unbounded behaviour.
1009
+ * A cap of N permits one running dispatch plus N admitted-but-not-
1010
+ * running requests (queued waiters and outstanding permits
1011
+ * combined), rejecting the next. The default (undefined) preserves
1012
+ * the original unbounded behaviour.
787
1013
  *
788
1014
  * **Runner-slot admission.** Whether a given caller counts as the
789
1015
  * runner slot or as a waiter is decided up front by comparing
790
1016
  * `execLock` against the idle sentinel `initialLock`. If they are
791
- * identical, nobody is currently in-flight and this caller wins
792
- * the runner slot: it is not counted against the waiter cap and
793
- * never touches `queuedCount`. Otherwise it is a waiter and the
794
- * normal cap check / increment / decrement cycle applies. This is
795
- * what keeps a synchronous burst such as `Promise.all([fn, fn])`
1017
+ * identical, nobody is currently in-flight and this caller is
1018
+ * admitted against the extra runner entitlement rather than a waiter
1019
+ * slot — it never touches `queuedCount`. Otherwise it is a waiter
1020
+ * and the normal charge / increment / decrement cycle applies. This
1021
+ * is what keeps a synchronous burst such as `Promise.all([fn, fn])`
796
1022
  * admissible under `maxQueueDepth = 1` — Call 1 is the runner,
797
- * Call 2 is the one allowed waiter, Call 3 would throw.
1023
+ * Call 2 is the one allowed waiter, Call 3 would throw. The runner
1024
+ * path is NOT exempt from the budget: while the chain is idle,
1025
+ * {@link beginPreDispatchAdmission} lends out `maxQueueDepth + 1`
1026
+ * permits precisely because one of them is entitled to become the
1027
+ * runner, so a permitless call that would take that seat while the
1028
+ * whole runner-plus-waiter capacity is already spoken for must
1029
+ * reject — otherwise the outstanding permits would later convert on
1030
+ * top of it and breach the cap.
1031
+ *
1032
+ * **Permit handoff.** A caller that was already admitted by
1033
+ * {@link beginPreDispatchAdmission} passes its permit as the second
1034
+ * argument; the permit is consumed atomically as this call's
1035
+ * admission instead of charging `queuedCount` a second time — one
1036
+ * budget, one token per request. See the handoff comment in the
1037
+ * body and {@link PreDispatchAdmission}.
798
1038
  */
799
- withExclusive(fn) {
1039
+ withExclusive(fn, permit) {
800
1040
  // Distinguish runner-slot from waiter admission. If the chain is
801
1041
  // idle (`execLock === initialLock`) the current caller is about
802
1042
  // to become the active holder on its very first `await prev`
@@ -804,13 +1044,37 @@ export class SessionRegistry {
804
1044
  // must NOT touch `queuedCount`. Only chained callers (someone
805
1045
  // else still holds or is ahead in the FIFO) count as waiters.
806
1046
  const asWaiter = this.execLock !== this.initialLock;
1047
+ // Atomic permit handoff (see `beginPreDispatchAdmission`). A caller
1048
+ // handing in a still-outstanding permit from THIS registry already
1049
+ // owns one unit of the shared budget: consuming it here — in the
1050
+ // same synchronous block as the waiter accounting below — converts
1051
+ // that unit into this call's admission (waiter path increments
1052
+ // `queuedCount`, keeping the total constant) or retires it (runner
1053
+ // path). The handoff skips only the caller's OWN token: its budget
1054
+ // share was charged at acquisition and conversion keeps the total
1055
+ // constant, so the handoff itself can never create a breach —
1056
+ // whereas re-checking would double-charge and could reject a
1057
+ // request that was already admitted at the endpoint gate. A permit
1058
+ // minted by a DIFFERENT registry is not in `permitConsumers` and
1059
+ // falls through to normal charging (its own budget stays balanced
1060
+ // by the caller's `finally` release).
1061
+ const consume = permit === undefined ? undefined : this.permitConsumers.get(permit);
1062
+ const handedOff = consume !== undefined && consume();
807
1063
  // Admission check — raised synchronously so endpoint handlers
808
- // can reliably catch `QueueFullError` without racing any
809
- // `await`. Only waiters can trip the cap; the runner slot is
810
- // always admitted. The counter is NOT mutated on the reject
811
- // path; the request never queued.
812
- if (asWaiter && this.maxQueueDepth !== undefined && this.queuedCount >= this.maxQueueDepth) {
813
- throw new QueueFullError(this.queuedCount, this.maxQueueDepth);
1064
+ // can reliably catch `QueueFullError` without racing any `await`.
1065
+ // Every non-handed-off caller runner AND waiter runs the same
1066
+ // shared predicate as `beginPreDispatchAdmission`: the combined
1067
+ // footprint (queued waiters PLUS outstanding pre-dispatch permits)
1068
+ // against the cap plus the idle-chain runner entitlement. Checking
1069
+ // `queuedCount` alone would let a permitless caller spend a slot
1070
+ // an outstanding permit already owns (e.g. a continuation parked
1071
+ // in `store.getChain`); exempting the runner path would let a
1072
+ // permitless call take the runner seat the gate already lent to
1073
+ // one of `maxQueueDepth + 1` idle-chain permits — either way the
1074
+ // cap breaches once those permits convert. Nothing is mutated on
1075
+ // the reject path; the request never queued.
1076
+ if (!handedOff) {
1077
+ this.assertAdmissionCapacity();
814
1078
  }
815
1079
  const prev = this.execLock;
816
1080
  let release;
@@ -823,6 +1087,56 @@ export class SessionRegistry {
823
1087
  }
824
1088
  return this._runExclusive(prev, myLock, release, fn, asWaiter);
825
1089
  }
1090
+ /**
1091
+ * Admit one dispatch to the model's continuous-batching lane.
1092
+ *
1093
+ * Admission rejection is synchronous, matching {@link withExclusive} and
1094
+ * preserving the endpoint's existing QueueFullError-to-429 mapping. Once
1095
+ * accepted, at most {@link concurrentAdmissionLimit} closures run at once;
1096
+ * excess accepted callers wait FIFO and contribute to {@link queueDepth}.
1097
+ * A pre-dispatch permit is consumed atomically into either an active slot or
1098
+ * a queued slot, so the early endpoint gate and this semaphore share one
1099
+ * bounded budget.
1100
+ */
1101
+ withAdmission(fn, permit) {
1102
+ const consume = permit === undefined ? undefined : this.permitConsumers.get(permit);
1103
+ const handedOff = consume !== undefined && consume();
1104
+ if (!handedOff) {
1105
+ this.assertAdmissionCapacity();
1106
+ }
1107
+ let acquire;
1108
+ const acquired = new Promise((resolve) => {
1109
+ acquire = resolve;
1110
+ });
1111
+ if (this.activeAdmissions < this.maxConcurrentDispatches) {
1112
+ this.activeAdmissions += 1;
1113
+ acquire();
1114
+ }
1115
+ else {
1116
+ this.queuedCount += 1;
1117
+ this.admissionWaiters.push(() => {
1118
+ this.queuedCount -= 1;
1119
+ if (this.queuedCount < 0)
1120
+ this.queuedCount = 0;
1121
+ this.activeAdmissions += 1;
1122
+ acquire();
1123
+ });
1124
+ }
1125
+ return this._runAdmission(acquired, fn);
1126
+ }
1127
+ async _runAdmission(acquired, fn) {
1128
+ await acquired;
1129
+ try {
1130
+ return await fn();
1131
+ }
1132
+ finally {
1133
+ this.activeAdmissions -= 1;
1134
+ if (this.activeAdmissions < 0)
1135
+ this.activeAdmissions = 0;
1136
+ const next = this.admissionWaiters.shift();
1137
+ next?.();
1138
+ }
1139
+ }
826
1140
  /**
827
1141
  * Async tail of {@link withExclusive}. Kept separate so the public
828
1142
  * wrapper stays a plain (non-async) function whose admission-gate