@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
@@ -31,7 +31,7 @@
31
31
  * * **Non-paged path** (Qwen3.5 dense + MoE — default-off pending a
32
32
  * perf decision; the Qianfan-OCR VLM — no adapter wired). Each
33
33
  * request looks up the warm slot via
34
- * `SessionRegistry.getOrCreateWarmAny(requestedSystem)`. On a
34
+ * `SessionRegistry.getOrCreateWarmAny(requestedSystem, cacheSalt)`. On a
35
35
  * HIT we keep the underlying native KV cache alive
36
36
  * (`resetPreservingNativeCacheForWarmReuse` wipes only JS-side
37
37
  * session state) so the native `verify_cache_prefix_direct` can
@@ -62,9 +62,10 @@ import { sendAnthropicBadRequest, sendAnthropicInternalError, sendAnthropicNotFo
62
62
  import { canonicalizeSystemForCacheKey, mapAnthropicRequest } from '../mappers/anthropic-request.js';
63
63
  import { buildAnthropicResponse, buildContentBlockDelta, buildContentBlockStart, buildContentBlockStop, buildMessageDelta, buildMessageStartEvent, buildMessageStop, containsToolCallMarkup, internalToolCallIdToAnthropic, recoverSuppressedToolCallText, mapStopReason, } from '../mappers/anthropic-response.js';
64
64
  import { genId } from '../mappers/response.js';
65
+ import { ModelLoadQueueFullError, } from '../model-work-coordinator.js';
65
66
  import { QueueFullError } from '../session-registry.js';
66
67
  import { StopSequenceBuffer } from '../stop-sequence-buffer.js';
67
- import { beginSSE, endSSE, writeSSEEvent } from '../streaming.js';
68
+ import { awaitDrainOrClose, beginSSE, endSSE, trackSSEClientAbort, writeSSEEvent as writeRawSSEEvent, } from '../streaming.js';
68
69
  import { longestSuffixPrefixOverlap } from '../text-recovery.js';
69
70
  import { resolveServerTuningForUsage } from '../timing.js';
70
71
  import { ToolCallTagBuffer } from '../tool-call-buffer.js';
@@ -82,8 +83,16 @@ import { MAX_OUTPUT_TOKENS, validateAndCanonicalizeHistoryToolOrder } from './re
82
83
  */
83
84
  const MESSAGES_WARM_SLOT_ID = '__msg_warm__';
84
85
  const CLAUDE_CODE_TITLE_MAX_TOKENS = 128;
85
- function withAdmissionControlledInference(sessionReg, modelWorkCoordinator, fn) {
86
- return sessionReg.withExclusive(() => (modelWorkCoordinator ? modelWorkCoordinator.withInference(fn) : fn()));
86
+ function withAdmissionControlledInference(sessionReg, modelWorkCoordinator,
87
+ // Pre-dispatch permit handed off ATOMICALLY as this call's admission
88
+ // (the selected admission lane consumes it instead of charging
89
+ // `queuedCount` a second time). See `beginPreDispatchAdmission`. Placed BEFORE `fn`
90
+ // so call sites keep the trailing-closure layout.
91
+ permit, fn) {
92
+ const run = () => (modelWorkCoordinator ? modelWorkCoordinator.withInference(fn) : fn());
93
+ return sessionReg.concurrentAdmissionLimit > 1
94
+ ? sessionReg.withAdmission(run, permit)
95
+ : sessionReg.withExclusive(run, permit);
87
96
  }
88
97
  function requestAllowsToolUse(body) {
89
98
  return Array.isArray(body.tools) && body.tools.length > 0;
@@ -197,14 +206,21 @@ async function handleNonStreaming(res, result, body, visibility, stopSequences,
197
206
  // by default, matching how `cachedTokens` is treated through
198
207
  // `buildAnthropicResponse`.
199
208
  const response = buildAnthropicResponse(responseResult, body, messageId, result.performance, requestAllowsToolUse(body), serverTiming, matchedStopSequence);
200
- // Native `chatSession*` has no AbortSignal surface yet, so a client that
201
- // disconnects mid-decode still burns every remaining token under the
202
- // per-model mutex. Disconnect handling is delegated to `endJson`'s
203
- // pre-entry destroyed check, which rejects synchronously after `responseMode`
204
- // has been committed to 'json' — the outer catch then destroys the socket.
209
+ // The request AbortSignal reaches the normal session method, whose wrapper
210
+ // maps it to native cancellation at the next model safepoint. `endJson` keeps
211
+ // the final pre-entry destroyed check for the transport race after decode.
205
212
  await endJson(res, JSON.stringify(response), visibility);
206
213
  }
207
214
  async function handleStreamingNative(res, chatStream, body, wasCommitted, httpReq, visibility, emitReasoning, stopSequences, serverTiming) {
215
+ const abort = trackSSEClientAbort(res, httpReq);
216
+ try {
217
+ return await handleStreamingNativeWithAbort(res, chatStream, body, wasCommitted, abort, visibility, emitReasoning, stopSequences, serverTiming);
218
+ }
219
+ finally {
220
+ abort.dispose();
221
+ }
222
+ }
223
+ async function handleStreamingNativeWithAbort(res, chatStream, body, wasCommitted, abort, visibility, emitReasoning, stopSequences, serverTiming) {
208
224
  const messageId = genId('msg_');
209
225
  // `runSessionStreaming` completed the exact token/capacity preflight before
210
226
  // handing us this iterator. Commit SSE immediately instead of entering the
@@ -214,7 +230,24 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
214
230
  // Commit SSE wire format now so any throw before the terminal event routes
215
231
  // to the streaming error epilogue instead of corrupting the JSON path.
216
232
  markSSEMode(visibility);
217
- writeSSEEvent(res, 'message_start', buildMessageStartEvent(body, messageId, 0));
233
+ // A native event can expand into several SSE frames. Preserve the first
234
+ // false write until the loop awaits it, and install the drain/close/error
235
+ // listeners immediately so an intervening iterator fetch cannot miss drain.
236
+ let pendingDrain = null;
237
+ const writeSSEEvent = (response, eventType, data) => {
238
+ const ok = writeRawSSEEvent(response, eventType, data);
239
+ if (!ok && pendingDrain === null) {
240
+ pendingDrain = awaitDrainOrClose(response, { onTimeout: () => abort.markAborted() });
241
+ }
242
+ };
243
+ const drainPending = async () => {
244
+ const drain = pendingDrain;
245
+ if (drain === null)
246
+ return;
247
+ await drain;
248
+ if (pendingDrain === drain)
249
+ pendingDrain = null;
250
+ };
218
251
  let contentBlockIndex = 0;
219
252
  let hasEmittedThinking = false;
220
253
  let hasEmittedText = false;
@@ -279,38 +312,17 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
279
312
  let terminalErrorMessage = null;
280
313
  const allowToolUse = requestAllowsToolUse(body);
281
314
  let suppressedToolCalls = false;
282
- // `thrownError` sticks on a generator throw; `clientAborted` sticks on
283
- // HTTP `close`/`error` on req, res, or res.socket. Either one routes the
284
- // post-loop block to the failure epilogue. Native decode has no
285
- // AbortSignal yet, so on a client disconnect we can only stop consuming
286
- // deltas — the native decode still runs to completion under the mutex.
315
+ // `thrownError` sticks on a generator throw. The outer abort tracker remains
316
+ // armed through post-loop residual writes and the terminal flush as well as
317
+ // the decode loop itself.
287
318
  let thrownError = null;
288
- let clientAborted = false;
289
- const onClientClose = () => {
290
- clientAborted = true;
291
- };
292
- const onClientError = (_err) => {
293
- clientAborted = true;
294
- };
295
- const onResClose = () => {
296
- clientAborted = true;
297
- };
298
- const onResError = (_err) => {
299
- clientAborted = true;
300
- };
301
- const resSocketForAbort = res.socket;
302
- if (httpReq) {
303
- httpReq.once('close', onClientClose);
304
- httpReq.once('error', onClientError);
305
- }
306
- res.once('close', onResClose);
307
- res.once('error', onResError);
308
- if (resSocketForAbort != null) {
309
- resSocketForAbort.once('close', onResClose);
310
- }
319
+ // The outer wrapper's abort listeners precede the first body write so an
320
+ // asynchronous socket error is authoritative before the drain wait resumes.
321
+ writeSSEEvent(res, 'message_start', buildMessageStartEvent(body, messageId, 0));
311
322
  try {
312
323
  for await (const event of chatStream) {
313
- if (clientAborted)
324
+ await drainPending();
325
+ if (abort.aborted)
314
326
  break;
315
327
  if (event.done) {
316
328
  sawDone = true;
@@ -636,15 +648,7 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
636
648
  thrownError = err instanceof Error ? err : new Error(String(err));
637
649
  }
638
650
  finally {
639
- if (httpReq) {
640
- httpReq.off('close', onClientClose);
641
- httpReq.off('error', onClientError);
642
- }
643
- res.off('close', onResClose);
644
- res.off('error', onResError);
645
- if (resSocketForAbort != null) {
646
- resSocketForAbort.off('close', onResClose);
647
- }
651
+ await drainPending();
648
652
  }
649
653
  // Success requires ALL of: sawDone, wasCommitted, no terminal error, no thrown
650
654
  // error, no client abort. `terminalErrorMessage` is set when a stream done event
@@ -653,7 +657,7 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
653
657
  // withhold `message_stop`. Every failure path emits a streaming `error` and
654
658
  // withholds `message_stop`.
655
659
  const committed = wasCommitted();
656
- const successful = sawDone && committed && terminalErrorMessage == null && thrownError == null && !clientAborted;
660
+ const successful = sawDone && committed && terminalErrorMessage == null && thrownError == null && !abort.aborted;
657
661
  if (successful) {
658
662
  const stopReason = terminalStopReason ?? 'end_turn';
659
663
  writeSSEEvent(res, 'message_delta', buildMessageDelta(stopReason, terminalNumTokens, terminalPromptTokens, terminalCachedTokens, terminalPerformance, serverTiming, matchedStopSequence));
@@ -675,9 +679,14 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
675
679
  // up front — non-fatal; the SSE usage field still carries the value.
676
680
  }
677
681
  }
678
- await flushTerminalSSE(res, 'message_stop', buildMessageStop(), visibility);
679
- endSSE(res);
680
- return { ok: true, suppressedToolCalls };
682
+ await drainPending();
683
+ // The residual drain may have settled because the transport closed or
684
+ // errored. Never emit/adopt a success terminal from the stale snapshot.
685
+ if (!abort.aborted) {
686
+ await flushTerminalSSE(res, 'message_stop', buildMessageStop(), visibility);
687
+ endSSE(res);
688
+ return { ok: true, suppressedToolCalls };
689
+ }
681
690
  }
682
691
  // Close any dangling content block so the error frame lands at a clean state,
683
692
  // then emit the streaming error. Never emit `message_stop` here — pairing it
@@ -688,11 +697,12 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
688
697
  else if (hasEmittedText) {
689
698
  writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
690
699
  }
700
+ await drainPending();
691
701
  let message;
692
702
  if (thrownError != null) {
693
703
  message = thrownError.message;
694
704
  }
695
- else if (clientAborted) {
705
+ else if (abort.aborted) {
696
706
  message = 'client disconnected before the stream completed';
697
707
  }
698
708
  else if (terminalErrorMessage != null) {
@@ -710,7 +720,7 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
710
720
  return { ok: false, suppressedToolCalls };
711
721
  }
712
722
  /** Prime a session with the full history and run a single turn. */
713
- async function runSessionNonStreaming(session, messages, config, resetNativeCache) {
723
+ async function runSessionNonStreaming(session, messages, config, resetNativeCache, signal) {
714
724
  // Dual-branch reset gated by the caller's native-cache policy:
715
725
  //
716
726
  // * Full native reset (`resetNativeCache === true`) — run a full
@@ -742,7 +752,7 @@ async function runSessionNonStreaming(session, messages, config, resetNativeCach
742
752
  }
743
753
  session.primeHistory(messages);
744
754
  const initialTurns = session.turns;
745
- const result = await session.startFromHistory(config);
755
+ const result = await session.startFromHistory(config, { signal });
746
756
  // Mirror the streaming-side dual-gate (`streamResult.ok &&
747
757
  // outcome.wasCommitted()`) and the sibling `/v1/responses` adopt
748
758
  // gate. `ChatSession.startFromHistory` advances `turnCount`
@@ -782,6 +792,9 @@ async function runSessionStreaming(session, messages, config, signal, resetNativ
782
792
  }
783
793
  // Public handler
784
794
  export async function handleCreateMessage(res, body, registry, httpReq, idleSweeper, resolveModel, modelWorkCoordinator) {
795
+ if (modelWorkCoordinator) {
796
+ registry.setModelLoadAdmissionCoordinator(modelWorkCoordinator);
797
+ }
785
798
  const handlerStartedAt = Date.now();
786
799
  let serverModelResolveMs;
787
800
  // Split observability for the resolve path: a request that arrives
@@ -850,9 +863,50 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
850
863
  sendAnthropicBadRequest(res, err instanceof Error ? err.message : 'Invalid request');
851
864
  return;
852
865
  }
853
- // Lazy-load hook: give the host a chance to register the requested
854
- // model before we look it up. Errors bubble up to the handler's
855
- // top-level catch which returns 500.
866
+ // Pre-dispatch admission gate (H3, host mode). Mirror of the
867
+ // `/v1/responses` gate see the long-form rationale there. In host
868
+ // mode resident arrivals bypass the `ModelWorkCoordinator` writer bracket
869
+ // below and proceed toward the continuous-batching lane, but can still park
870
+ // in pre-lock work. Admit-or-429 (Anthropic envelope) against the same
871
+ // per-model budget so that work stays bounded too. Non-resident (cold-load)
872
+ // requests use the coordinator's bounded pre-resolution permit below
873
+ // because no `SessionRegistry` exists yet. The permit is RETAINED through every pre-lock
874
+ // await and handed to `withExclusive` at placement, which consumes it
875
+ // atomically as this request's admission (one budget, one token,
876
+ // never double-counted); it is released only on bail-out exits —
877
+ // explicitly on the early returns before the outer `try`, and by the
878
+ // outer `finally` for everything inside it (idempotent + no-op after
879
+ // handoff).
880
+ let preDispatchAdmission;
881
+ let modelLoadAdmission;
882
+ const preDispatchRegistry = registry.getSessionRegistry(body.model);
883
+ if (preDispatchRegistry) {
884
+ try {
885
+ preDispatchAdmission = preDispatchRegistry.beginPreDispatchAdmission();
886
+ }
887
+ catch (err) {
888
+ if (err instanceof QueueFullError) {
889
+ sendAnthropicRateLimit(res, `${err.message}. Retry after 1s.`);
890
+ return;
891
+ }
892
+ throw err;
893
+ }
894
+ }
895
+ else if (resolveModel && modelWorkCoordinator) {
896
+ try {
897
+ modelLoadAdmission = modelWorkCoordinator.beginRequestLoadAdmission(body.model);
898
+ }
899
+ catch (err) {
900
+ if (err instanceof ModelLoadQueueFullError) {
901
+ sendAnthropicRateLimit(res, `Model queue full: admission footprint ${err.admissionFootprint} (limit ${err.limit}). Retry after 1s.`);
902
+ return;
903
+ }
904
+ throw err;
905
+ }
906
+ }
907
+ // Lazy-load hook for a requested name with no resident registry: give the
908
+ // host a chance to register it before we look it up. Resident requests skip
909
+ // this writer bracket so they can reach the continuous-batching lane.
856
910
  //
857
911
  // The load is bracketed by `idleSweeper.withSuspendedDrains` so the
858
912
  // post-request drain timer armed by the PREVIOUS request's
@@ -866,7 +920,7 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
866
920
  // The wrapper handles try/finally itself and is a pass-through on
867
921
  // the disabled sweeper, so the bracket is unconditional whenever
868
922
  // a sweeper is supplied.
869
- if (resolveModel) {
923
+ if (resolveModel && !preDispatchRegistry) {
870
924
  // A throw here (bad model path, corrupt weights, native loader failure)
871
925
  // would otherwise bubble up to the outer `createHandler` catch which
872
926
  // emits the OpenAI-shape `{ error: ... }` envelope via `sendInternalError`.
@@ -892,7 +946,8 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
892
946
  // observability fields:
893
947
  // - owner driving a cold load → waitMs ≈ 0, ownMs ≈ load duration
894
948
  // - follower parked behind peer → waitMs ≈ peer load, ownMs ≈ 0
895
- // - already-loaded fast path → waitMs 0, ownMs 0
949
+ // Resident requests bypass this block entirely and therefore omit all
950
+ // three load-timing fields.
896
951
  // This matches the documented contract in `timing.ts` where
897
952
  // `server_model_resolve_ms` excludes peer-wait time.
898
953
  const outcome = await modelWorkCoordinator.withModelLoadInstrumented(runResolve);
@@ -906,12 +961,19 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
906
961
  }
907
962
  }
908
963
  catch (err) {
964
+ preDispatchAdmission?.release();
965
+ modelLoadAdmission?.release();
909
966
  sendAnthropicInternalError(res, err instanceof Error ? err.message : 'Failed to resolve model');
910
967
  return;
911
968
  }
912
969
  }
970
+ // NOTE: the permit is NOT released here. The request must stay counted
971
+ // through the remaining pre-lock work until `withExclusive` consumes
972
+ // the permit at placement. Only bail-out exits release.
913
973
  const model = registry.get(body.model);
914
974
  if (!model) {
975
+ preDispatchAdmission?.release();
976
+ modelLoadAdmission?.release();
915
977
  sendAnthropicNotFound(res, `Model "${body.model}" not found`);
916
978
  return;
917
979
  }
@@ -921,10 +983,26 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
921
983
  // against one shared native model. Must be released in the `finally` below.
922
984
  const lease = registry.acquireDispatchLease(body.model);
923
985
  if (!lease) {
986
+ preDispatchAdmission?.release();
987
+ modelLoadAdmission?.release();
924
988
  sendAnthropicInternalError(res, 'session registry missing for registered model');
925
989
  return;
926
990
  }
927
991
  const leaseModel = lease.model;
992
+ if (modelLoadAdmission) {
993
+ try {
994
+ preDispatchAdmission = modelLoadAdmission.transferToResident(lease.registry);
995
+ }
996
+ catch (err) {
997
+ registry.releaseDispatchLease(leaseModel);
998
+ modelLoadAdmission.release();
999
+ if (err instanceof QueueFullError) {
1000
+ sendAnthropicRateLimit(res, `${err.message}. Retry after 1s.`);
1001
+ return;
1002
+ }
1003
+ throw err;
1004
+ }
1005
+ }
928
1006
  // AbortController wired to disconnect events. Declared at function scope
929
1007
  // so the outer `finally` can detach listeners on early returns; the
930
1008
  // `abortListenersAttached` flag gates the detach so pre-validation exits
@@ -1021,6 +1099,17 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
1021
1099
  httpReq.once('close', onAbortClose);
1022
1100
  httpReq.once('error', onAbortError);
1023
1101
  }
1102
+ // Catch-up abort: a response torn down BEFORE the attach above has
1103
+ // already emitted its terminal event, so the `once('close')`
1104
+ // listeners will never fire. Consult the response-side socket state
1105
+ // directly (the REQUEST side is deliberately excluded — a fully
1106
+ // consumed IncomingMessage auto-destroys after 'end' on every
1107
+ // normal request, so `httpReq.destroyed` is not a disconnect
1108
+ // signal). Makes `streamSignal.aborted` authoritative for the H2
1109
+ // pre-dispatch disconnect check inside the mutex callback.
1110
+ if (res.destroyed || res.writableEnded || abortSocket?.destroyed === true) {
1111
+ abortController.abort();
1112
+ }
1024
1113
  abortListenersAttached = true;
1025
1114
  const streamSignal = abortController.signal;
1026
1115
  // Bracket the native-model dispatch with the idle sweeper.
@@ -1043,309 +1132,357 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
1043
1132
  idleListenersAttached = true;
1044
1133
  try {
1045
1134
  const mutexQueuedAt = Date.now();
1046
- const runInference = () => withAdmissionControlledInference(sessionReg, modelWorkCoordinator, async () => {
1047
- const serverTiming = {
1048
- server_model_resolve_ms: serverModelResolveMs,
1049
- server_load_wait_ms: serverLoadWaitMs,
1050
- server_load_owner: serverLoadOwner,
1051
- server_queue_ms: Date.now() - mutexQueuedAt,
1052
- server_pre_inference_ms: Date.now() - handlerStartedAt,
1053
- ...resolveServerTuningForUsage(),
1054
- };
1055
- // Hot-swap race guard. `ModelRegistry.register()` is not coordinated with
1056
- // `withExclusive`, so a concurrent re-register of the same friendly name
1057
- // could silently dispatch this request through a stale model. Any drift
1058
- // from the pre-lock snapshot is fatal.
1059
- const lockedSessionReg = registry.getSessionRegistry(body.model);
1060
- const lockedInstanceId = registry.getInstanceId(body.model);
1061
- if (lockedSessionReg === undefined ||
1062
- lockedInstanceId === undefined ||
1063
- lockedSessionReg !== sessionReg ||
1064
- lockedInstanceId !== preLockInstanceId) {
1065
- sendAnthropicBadRequest(res, `Model "${body.model}" binding changed while the request was queued behind the per-model ` +
1066
- `execution mutex. A concurrent register() re-pointed the name at a different model instance ` +
1067
- `(or released it entirely) while this waiter was parked, so the session registry and instance ` +
1068
- `id captured before the mutex wait no longer match the live binding. Dispatching anyway would ` +
1069
- `service this request through a stale model object a silent cross-model handoff. Retry the ` +
1070
- `request if the swap was intentional, the new binding will service the retry cleanly.`);
1071
- return;
1072
- }
1073
- // Per-model session selection for `/v1/messages` reuse.
1074
- //
1075
- // Two paths, gated on whether the underlying native model has
1076
- // the block-paged KV cache adapter active
1077
- // (`hasBlockPagedCache()` captured at load time from
1078
- // `<Inner>::paged_adapter.is_some()` and surfaced by the
1079
- // `SessionCapableModel` structural interface):
1080
- //
1081
- // * **Paged-active** (Qwen3 + LFM2 + Gemma4 today; Qwen3.5
1082
- // dense + Qwen3.5 MoE once their perf trade-off is
1083
- // decided). Allocate a fresh `ChatSession` per request via
1084
- // `createFreshSession()`, do NOT touch the warm slot.
1085
- // Cross-turn / cross-conversation prefix reuse is handled
1086
- // entirely by the native `BlockAllocator`'s prefix-hash
1087
- // table: SYS blocks shared across requests are refcounted
1088
- // transparently, so two parallel `/v1/messages` requests
1089
- // sharing a system prompt both run on distinct
1090
- // `ChatSession` objects but reference the SAME physical
1091
- // KV blocks. The JS-side warm slot would only serialize
1092
- // them and force one into cold replay.
1093
- //
1094
- // * **Non-paged** (Qwen3.5 dense + MoE — default-OFF pending
1095
- // a perf decision against the compiled C++ flat path;
1096
- // the Qianfan-OCR VLM no adapter wired). Fall through to
1097
- // `getOrCreateWarmAny`, which is the ONLY cross-conversation
1098
- // reuse mechanism these models have. The Anthropic Messages
1099
- // API is stateless on the wire (no `previous_response_id`,
1100
- // clients don't propagate `prompt_cache_key`), so without
1101
- // the warm slot every turn is a full cold start.
1102
- //
1103
- // The `hasBlockPagedCache?()` getter is optional on the
1104
- // structural interface so the `QianfanOCRModel` VLM (which
1105
- // has no paged-adapter wiring) still satisfies the type
1106
- // contract a missing getter falls into the non-paged branch
1107
- // here.
1108
- //
1109
- // Adoption stays keyed by the literal sentinel
1110
- // `MESSAGES_WARM_SLOT_ID = '__msg_warm__'`. The Anthropic
1111
- // Messages API never produces a `previous_response_id` clients
1112
- // could echo back (and the OpenAI side mints `resp_*` ids),
1113
- // so cross-endpoint capture via tier-1 is impossible by
1114
- // construction no `/v1/responses` request can collide with
1115
- // the sentinel through the tier-1 path.
1116
- //
1117
- // The two endpoints DO share the single warm slot under the
1118
- // registry's single-warm invariant on the non-paged path: a
1119
- // `/v1/messages` turn following a `/v1/responses` turn can
1120
- // evict (and vice versa). On the paged path neither side
1121
- // touches the warm slot, so cross-endpoint contention
1122
- // disappears.
1123
- //
1124
- // The `prompt_cache_key` request field is still NOT exposed
1125
- // on this endpoint. Cross-conversation block-level cache
1126
- // reuse on paged-active models is now driven by native
1127
- // content-addressing instead of the JS warm slot, so adding
1128
- // the field is no longer a prerequisite for that use case.
1129
- const pagedActive = leaseModel.hasBlockPagedCache?.() === true;
1130
- const lookup = pagedActive ? sessionReg.createFreshSession() : sessionReg.getOrCreateWarmAny(requestedSystem);
1131
- const session = lookup.session;
1132
- // `X-Session-Cache` observability header.
1133
- //
1134
- // Non-paged path:
1135
- // * Non-streaming: set the optimistic `prefix_hit` value
1136
- // BEFORE dispatch on `lookup.hit` (so the header is on the
1137
- // wire even if the dispatch throws) and demote
1138
- // post-dispatch to `fresh` when the warm slot was leased
1139
- // but native prefix reuse did not actually happen
1140
- // (`result.cachedTokens === 0`). `res.end` has not fired
1141
- // yet, so the overwrite still lands on the wire.
1142
- // * Streaming: emits `streaming` to signal the authoritative
1143
- // post-dispatch value rides on the SSE stream
1144
- // (`message_delta.usage.cache_read_input_tokens` and the
1145
- // `X-Cached-Tokens` HTTP trailer, set below in
1146
- // `handleStreamingNative` once `terminalCachedTokens` is
1147
- // known). Reporting `fresh` here would be a lie — the
1148
- // paged engine routinely returns `cachedTokens > 0` on
1149
- // turn-2+ and the prior `'fresh'` default falsely advertised
1150
- // a cache miss. The previous comment documented this as
1151
- // intentional but it was a logging bug.
1152
- //
1153
- // Paged path:
1154
- // * Non-streaming: `lookup.hit` is always `false` (we
1155
- // `createFreshSession`); the post-dispatch promotion
1156
- // branch flips `prefix_hit` when the native engine
1157
- // reports `cachedTokens > 0`, which is the authoritative
1158
- // signal that the block allocator's content-addressed
1159
- // reuse picked up shared SYS blocks on this turn.
1160
- // * Streaming: same `streaming` value as non-paged; the SSE
1161
- // `usage.cache_read_input_tokens` field carries the
1162
- // authoritative value.
1163
- //
1164
- // Header values: `'fresh' | 'prefix_hit' | 'streaming'`. The
1165
- // `'streaming'` value tells operators to read the SSE
1166
- // `message_delta.usage.cache_read_input_tokens` for the
1167
- // resolved cache-hit count (or `X-Cached-Tokens` trailer if
1168
- // the client supports HTTP trailers).
1169
- let sessionCacheStatus = body.stream === true ? 'streaming' : lookup.hit ? 'prefix_hit' : 'fresh';
1170
- res.setHeader('X-Session-Cache', sessionCacheStatus);
1171
- // HTTP/1.1 chunked-encoding trailer announcement for streaming.
1172
- // The actual value is filled in by `handleStreamingNative`
1173
- // once it has captured `terminalCachedTokens` from the final
1174
- // SSE chunk.
1175
- if (body.stream === true) {
1176
- res.setHeader('Trailer', 'X-Cached-Tokens');
1177
- }
1178
- // Outer catch branches on `responseMode` (not `res.headersSent`, which
1179
- // flips in `writeHead` before the body lands) so a crash after
1180
- // `writeHead(application/json)` cannot leak SSE frames into a JSON body.
1181
- const visibility = createVisibility();
1182
- try {
1135
+ const runInference = () => {
1136
+ return withAdmissionControlledInference(sessionReg, modelWorkCoordinator, preDispatchAdmission, async () => {
1137
+ const serverTiming = {
1138
+ server_model_resolve_ms: serverModelResolveMs,
1139
+ server_load_wait_ms: serverLoadWaitMs,
1140
+ server_load_owner: serverLoadOwner,
1141
+ server_queue_ms: Date.now() - mutexQueuedAt,
1142
+ server_pre_inference_ms: Date.now() - handlerStartedAt,
1143
+ ...resolveServerTuningForUsage(),
1144
+ };
1145
+ // Hot-swap race guard. `ModelRegistry.register()` is not coordinated with
1146
+ // `withExclusive`, so a concurrent re-register of the same friendly name
1147
+ // could silently dispatch this request through a stale model. Any drift
1148
+ // from the pre-lock snapshot is fatal.
1149
+ const lockedSessionReg = registry.getSessionRegistry(body.model);
1150
+ const lockedInstanceId = registry.getInstanceId(body.model);
1151
+ if (lockedSessionReg === undefined ||
1152
+ lockedInstanceId === undefined ||
1153
+ lockedSessionReg !== sessionReg ||
1154
+ lockedInstanceId !== preLockInstanceId) {
1155
+ sendAnthropicBadRequest(res, `Model "${body.model}" binding changed while the request was queued behind the per-model ` +
1156
+ `execution mutex. A concurrent register() re-pointed the name at a different model instance ` +
1157
+ `(or released it entirely) while this waiter was parked, so the session registry and instance ` +
1158
+ `id captured before the mutex wait no longer match the live binding. Dispatching anyway would ` +
1159
+ `service this request through a stale model object a silent cross-model handoff. Retry the ` +
1160
+ `request — if the swap was intentional, the new binding will service the retry cleanly.`);
1161
+ return;
1162
+ }
1163
+ // Per-model session selection for `/v1/messages` reuse.
1164
+ //
1165
+ // Two paths, gated on whether the underlying native model has
1166
+ // the block-paged KV cache adapter active
1167
+ // (`hasBlockPagedCache()` captured at load time from
1168
+ // `<Inner>::paged_adapter.is_some()` and surfaced by the
1169
+ // `SessionCapableModel` structural interface):
1170
+ //
1171
+ // * **Paged-active** (Qwen3 + LFM2 + Gemma4 today; Qwen3.5
1172
+ // dense + Qwen3.5 MoE once their perf trade-off is
1173
+ // decided). Allocate a fresh `ChatSession` per request via
1174
+ // `createFreshSession()`, do NOT touch the warm slot.
1175
+ // Cross-turn / cross-conversation prefix reuse is handled
1176
+ // entirely by the native `BlockAllocator`'s prefix-hash
1177
+ // table: SYS blocks shared across requests are refcounted
1178
+ // transparently, so two parallel `/v1/messages` requests
1179
+ // sharing a system prompt both run on distinct
1180
+ // `ChatSession` objects but reference the SAME physical
1181
+ // KV blocks. The JS-side warm slot would only serialize
1182
+ // them and force one into cold replay.
1183
+ //
1184
+ // * **Non-paged** (Qwen3.5 dense + MoE default-OFF pending
1185
+ // a perf decision against the compiled C++ flat path;
1186
+ // the Qianfan-OCR VLM no adapter wired). Fall through to
1187
+ // `getOrCreateWarmAny`, which is the ONLY cross-conversation
1188
+ // reuse mechanism these models have. The Anthropic Messages
1189
+ // API is stateless on the wire (no `previous_response_id`,
1190
+ // clients don't propagate `prompt_cache_key`), so without
1191
+ // the warm slot every turn is a full cold start.
1192
+ //
1193
+ // The `hasBlockPagedCache?()` getter is optional on the
1194
+ // structural interface so the `QianfanOCRModel` VLM (which
1195
+ // has no paged-adapter wiring) still satisfies the type
1196
+ // contract — a missing getter falls into the non-paged branch
1197
+ // here.
1198
+ //
1199
+ // Adoption stays keyed by the literal sentinel
1200
+ // `MESSAGES_WARM_SLOT_ID = '__msg_warm__'`. The Anthropic
1201
+ // Messages API never produces a `previous_response_id` clients
1202
+ // could echo back (and the OpenAI side mints `resp_*` ids),
1203
+ // so cross-endpoint capture via tier-1 is impossible by
1204
+ // construction no `/v1/responses` request can collide with
1205
+ // the sentinel through the tier-1 path.
1206
+ //
1207
+ // The two endpoints DO share the single warm slot under the
1208
+ // registry's single-warm invariant on the non-paged path: a
1209
+ // `/v1/messages` turn following a `/v1/responses` turn can
1210
+ // evict (and vice versa). On the paged path neither side
1211
+ // touches the warm slot, so cross-endpoint contention
1212
+ // disappears.
1213
+ //
1214
+ // The `prompt_cache_key` request field is still NOT exposed
1215
+ // on this endpoint. Cross-conversation block-level cache
1216
+ // reuse on paged-active models is now driven by native
1217
+ // content-addressing instead of the JS warm slot, so adding
1218
+ // the field is no longer a prerequisite for that use case.
1219
+ // H2 pre-dispatch disconnect check (non-streaming only). A
1220
+ // client that vanished while this request was parked behind
1221
+ // the per-model mutex must not burn a whole prefill+decode
1222
+ // budget producing a JSON body nobody can receive. Checked
1223
+ // BEFORE the warm-slot lease so no session state is consumed
1224
+ // or mutated the early return composes with the permit
1225
+ // lifecycle exactly like the binding-changed return above
1226
+ // (the pre-dispatch permit was already consumed atomically by
1227
+ // `withExclusive`; the outer `finally` release is an
1228
+ // idempotent no-op after handoff). Streaming keeps its
1229
+ // existing paths: the signal fast-aborts `_runChatStream` and
1230
+ // the SSE drain loop breaks on `clientAborted` at loop-top.
1231
+ if (body.stream !== true && streamSignal.aborted) {
1232
+ return;
1233
+ }
1234
+ const pagedActive = leaseModel.hasBlockPagedCache?.() === true;
1235
+ const lookup = pagedActive
1236
+ ? sessionReg.createFreshSession()
1237
+ : sessionReg.getOrCreateWarmAny(requestedSystem, config.cacheSalt ?? null);
1238
+ const session = lookup.session;
1239
+ await sessionReg.flushPendingDisposals();
1240
+ let sessionRetained = false;
1241
+ // `X-Session-Cache` observability header.
1242
+ //
1243
+ // Non-paged path:
1244
+ // * Non-streaming: set the optimistic `prefix_hit` value
1245
+ // BEFORE dispatch on `lookup.hit` (so the header is on the
1246
+ // wire even if the dispatch throws) and demote
1247
+ // post-dispatch to `fresh` when the warm slot was leased
1248
+ // but native prefix reuse did not actually happen
1249
+ // (`result.cachedTokens === 0`). `res.end` has not fired
1250
+ // yet, so the overwrite still lands on the wire.
1251
+ // * Streaming: emits `streaming` to signal the authoritative
1252
+ // post-dispatch value rides on the SSE stream
1253
+ // (`message_delta.usage.cache_read_input_tokens` and the
1254
+ // `X-Cached-Tokens` HTTP trailer, set below in
1255
+ // `handleStreamingNative` once `terminalCachedTokens` is
1256
+ // known). Reporting `fresh` here would be a lie — the
1257
+ // paged engine routinely returns `cachedTokens > 0` on
1258
+ // turn-2+ and the prior `'fresh'` default falsely advertised
1259
+ // a cache miss. The previous comment documented this as
1260
+ // intentional but it was a logging bug.
1261
+ //
1262
+ // Paged path:
1263
+ // * Non-streaming: `lookup.hit` is always `false` (we
1264
+ // `createFreshSession`); the post-dispatch promotion
1265
+ // branch flips `prefix_hit` when the native engine
1266
+ // reports `cachedTokens > 0`, which is the authoritative
1267
+ // signal that the block allocator's content-addressed
1268
+ // reuse picked up shared SYS blocks on this turn.
1269
+ // * Streaming: same `streaming` value as non-paged; the SSE
1270
+ // `usage.cache_read_input_tokens` field carries the
1271
+ // authoritative value.
1272
+ //
1273
+ // Header values: `'fresh' | 'prefix_hit' | 'streaming'`. The
1274
+ // `'streaming'` value tells operators to read the SSE
1275
+ // `message_delta.usage.cache_read_input_tokens` for the
1276
+ // resolved cache-hit count (or `X-Cached-Tokens` trailer if
1277
+ // the client supports HTTP trailers).
1278
+ let sessionCacheStatus = body.stream === true ? 'streaming' : lookup.hit ? 'prefix_hit' : 'fresh';
1279
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
1280
+ // HTTP/1.1 chunked-encoding trailer announcement for streaming.
1281
+ // The actual value is filled in by `handleStreamingNative`
1282
+ // once it has captured `terminalCachedTokens` from the final
1283
+ // SSE chunk.
1183
1284
  if (body.stream === true) {
1184
- // On the paged path the underlying native cache is the
1185
- // sole reuse mechanism, so preserve it even though the JS
1186
- // `ChatSession` is freshly allocated. The native paged
1187
- // adapter validates reuse by token/hash before any cached
1188
- // prefix is trusted, and the MoE GDN checkpoint layer now
1189
- // follows the same content-checked policy. Non-paged keeps
1190
- // the original `!lookup.hit` semantics so only warm-slot
1191
- // hits preserve native cache.
1192
- const resetNativeCache = pagedActive ? false : !lookup.hit;
1193
- const outcome = await runSessionStreaming(session, messages, config, streamSignal, resetNativeCache);
1194
- const streamResult = await handleStreamingNative(res, outcome.stream, body, outcome.wasCommitted, httpReq, visibility, config.includeReasoning !== false, stopSequences, serverTiming);
1195
- // Warm-slot adopt/drop only applies to the non-paged
1196
- // path. On the paged path the JS-side warm slot plays no
1197
- // role (block reuse is content-addressed in native), so
1198
- // we never touch it the fresh `ChatSession` allocated
1199
- // for this request is dropped on the floor and GC'd once
1200
- // the handler scope exits.
1201
- //
1202
- // Non-paged dual-gate adopt: BOTH the producer-side commit
1203
- // signal (`outcome.wasCommitted()`, which reads
1204
- // `session.turns` bumped in `startFromHistoryStream`'s
1205
- // `finally`) AND the handler-side success signal
1206
- // (`streamResult.ok`, true only when we reached the clean
1207
- // `message_stop` terminal) must be true to adopt. The
1208
- // producer's `finally` runs on every break including
1209
- // client abort, mid-decode throw, and
1210
- // `finishReason=error` — so `wasCommitted()` alone is NOT
1211
- // sufficient: it can return `true` after the SSE side
1212
- // emitted an `error` terminal (not re-thrown by
1213
- // `handleStreamingNative`), leaving a session whose
1214
- // observable wire state is failure but whose `turns`
1215
- // counter advanced. Adopting in that window would seed the
1216
- // warm slot with a session the next request can lease but
1217
- // whose history does not match what the client received.
1218
- //
1219
- // Mirrors `responses.ts` (around line 3277) where the
1220
- // analogous gate combines `committed`, `handlerError`, and
1221
- // `streamFailureMode === null` the producer-side commit
1222
- // and a clean handler-side terminal must both hold before
1223
- // the session is reachable from a subsequent request.
1224
- if (!pagedActive) {
1225
- if (streamResult.ok && outcome.wasCommitted() && !streamResult.suppressedToolCalls) {
1226
- sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null);
1285
+ res.setHeader('Trailer', 'X-Cached-Tokens');
1286
+ }
1287
+ // Outer catch branches on `responseMode` (not `res.headersSent`, which
1288
+ // flips in `writeHead` before the body lands) so a crash after
1289
+ // `writeHead(application/json)` cannot leak SSE frames into a JSON body.
1290
+ const visibility = createVisibility();
1291
+ try {
1292
+ if (body.stream === true) {
1293
+ // On the paged path the underlying native cache is the
1294
+ // sole reuse mechanism, so preserve it even though the JS
1295
+ // `ChatSession` is freshly allocated. The native paged
1296
+ // adapter validates reuse by token/hash before any cached
1297
+ // prefix is trusted, and the MoE GDN checkpoint layer now
1298
+ // follows the same content-checked policy. Non-paged keeps
1299
+ // the original `!lookup.hit` semantics so only warm-slot
1300
+ // hits preserve native cache.
1301
+ const resetNativeCache = pagedActive ? false : !lookup.hit;
1302
+ const outcome = await runSessionStreaming(session, messages, config, streamSignal, resetNativeCache);
1303
+ const streamResult = await handleStreamingNative(res, outcome.stream, body, outcome.wasCommitted, httpReq, visibility, config.includeReasoning !== false, stopSequences, serverTiming);
1304
+ // Warm-slot adopt/drop only applies to the non-paged
1305
+ // path. On the paged path the JS-side warm slot plays no
1306
+ // role (block reuse is content-addressed in native), so
1307
+ // we never touch it. The fresh `ChatSession` is explicitly
1308
+ // disposed in the `finally` below so its native scheduler owner
1309
+ // and live paged request are released before the handler leaves
1310
+ // the admission lane; GC alone cannot perform that native
1311
+ // lifecycle transition.
1312
+ //
1313
+ // Non-paged dual-gate adopt: BOTH the producer-side commit
1314
+ // signal (`outcome.wasCommitted()`, which reads
1315
+ // `session.turns` bumped in `startFromHistoryStream`'s
1316
+ // `finally`) AND the handler-side success signal
1317
+ // (`streamResult.ok`, true only when we reached the clean
1318
+ // `message_stop` terminal) must be true to adopt. The
1319
+ // producer's `finally` runs on every break — including
1320
+ // client abort, mid-decode throw, and
1321
+ // `finishReason=error` so `wasCommitted()` alone is NOT
1322
+ // sufficient: it can return `true` after the SSE side
1323
+ // emitted an `error` terminal (not re-thrown by
1324
+ // `handleStreamingNative`), leaving a session whose
1325
+ // observable wire state is failure but whose `turns`
1326
+ // counter advanced. Adopting in that window would seed the
1327
+ // warm slot with a session the next request can lease but
1328
+ // whose history does not match what the client received.
1329
+ //
1330
+ // Mirrors `responses.ts` (around line 3277) where the
1331
+ // analogous gate combines `committed`, `handlerError`, and
1332
+ // `streamFailureMode === null` — the producer-side commit
1333
+ // and a clean handler-side terminal must both hold before
1334
+ // the session is reachable from a subsequent request.
1335
+ if (!pagedActive) {
1336
+ if (streamResult.ok && outcome.wasCommitted() && !streamResult.suppressedToolCalls) {
1337
+ sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null, config.cacheSalt ?? null);
1338
+ sessionRetained = true;
1339
+ }
1340
+ else {
1341
+ sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1342
+ }
1227
1343
  }
1228
- else {
1229
- sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1344
+ }
1345
+ else {
1346
+ // See the streaming branch above for the rationale on
1347
+ // preserving native cache on the paged path.
1348
+ const resetNativeCache = pagedActive ? false : !lookup.hit;
1349
+ // Non-streaming cancellation (H2): `streamSignal` threads
1350
+ // through `ChatSession.startFromHistory` into the normal public
1351
+ // method; the wrapper maps it to the internal native operation — a mid-turn
1352
+ // disconnect flips the controller, the native turn unwinds
1353
+ // at the next safepoint, and the dispatch rejects with
1354
+ // "chat session cancelled" (routed through the catch below:
1355
+ // warm slot dropped, nothing persisted). The
1356
+ // disconnect-aware skip inside `handleNonStreaming` /
1357
+ // `endJson` remains the last line of defense for a
1358
+ // disconnect racing the final flush.
1359
+ const outcome = await runSessionNonStreaming(session, messages, config, resetNativeCache, streamSignal);
1360
+ const result = outcome.result;
1361
+ // Re-classify the `X-Session-Cache` header.
1362
+ //
1363
+ // Non-paged: a warm-slot hit that did NOT actually produce
1364
+ // native prefix reuse (`cachedTokens === 0` — e.g.
1365
+ // tokenizer change, system prompt drift squeaking past
1366
+ // the byte-equal compare via some upstream rewrite) gets
1367
+ // demoted from `prefix_hit` back to `fresh`.
1368
+ //
1369
+ // Paged: `lookup.hit` is always `false` so we entered
1370
+ // with `sessionCacheStatus = 'fresh'`. Promote to
1371
+ // `prefix_hit` when the native engine reports
1372
+ // `cachedTokens > 0` — that's the authoritative signal
1373
+ // that `BlockAllocator`'s content-addressed prefix lookup
1374
+ // recovered shared SYS blocks on this turn. `res.end` has
1375
+ // not fired yet (`handleNonStreaming` is what flushes via
1376
+ // `endJson`), so the overwrite still lands on the wire.
1377
+ if (lookup.hit && result.cachedTokens === 0) {
1378
+ sessionCacheStatus = 'fresh';
1379
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
1380
+ }
1381
+ else if (pagedActive && result.cachedTokens > 0) {
1382
+ sessionCacheStatus = 'prefix_hit';
1383
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
1384
+ }
1385
+ // Companion `X-Cached-Tokens` header: emitted only when
1386
+ // reuse genuinely happened, so operators can spot a stale
1387
+ // `prefix_hit` claim from telemetry alone.
1388
+ if (result.cachedTokens > 0) {
1389
+ res.setHeader('X-Cached-Tokens', String(result.cachedTokens));
1390
+ }
1391
+ await handleNonStreaming(res, result, body, visibility, stopSequences, serverTiming);
1392
+ // Non-paged success: adopt the warm slot only when the
1393
+ // dispatch actually committed. Mirrors the streaming-side
1394
+ // dual-gate at `streamResult.ok && outcome.wasCommitted()`
1395
+ // above and the sibling `/v1/responses` adopt gate, so the
1396
+ // local invariant — "never adopt an uncommitted session"
1397
+ // — is enforced by the same check on both wire formats and
1398
+ // both endpoints. Today every native failure throws (and
1399
+ // routes through the inner catch below), so the gate is
1400
+ // dead code on the current Rust paths; it defends the
1401
+ // invariant LOCALLY so a future native change that
1402
+ // resolves `chat_session_start_sync` with
1403
+ // `Ok(finish_reason="error")` cannot silently poison the
1404
+ // warm slot. Drop on the uncommitted branch matches the
1405
+ // streaming-side `else { drop(...) }` so the sentinel does
1406
+ // not accumulate stale entries from earlier turns.
1407
+ //
1408
+ // Paged success: never adopt — block-level reuse is
1409
+ // already in the native cache, and adopting would
1410
+ // re-introduce the cross-endpoint warm-slot eviction
1411
+ // that paged is supposed to eliminate.
1412
+ if (!pagedActive) {
1413
+ if (outcome.committed && !hasSuppressedToolCalls(result, body)) {
1414
+ sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null, config.cacheSalt ?? null);
1415
+ sessionRetained = true;
1416
+ }
1417
+ else {
1418
+ sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1419
+ }
1230
1420
  }
1231
1421
  }
1232
1422
  }
1233
- else {
1234
- // See the streaming branch above for the rationale on
1235
- // preserving native cache on the paged path.
1236
- const resetNativeCache = pagedActive ? false : !lookup.hit;
1237
- // Native `chatSessionStart` has no AbortSignal yet — disconnect handling
1238
- // lives inside `handleNonStreaming` / `endJson`.
1239
- const outcome = await runSessionNonStreaming(session, messages, config, resetNativeCache);
1240
- const result = outcome.result;
1241
- // Re-classify the `X-Session-Cache` header.
1242
- //
1243
- // Non-paged: a warm-slot hit that did NOT actually produce
1244
- // native prefix reuse (`cachedTokens === 0` — e.g.
1245
- // tokenizer change, system prompt drift squeaking past
1246
- // the byte-equal compare via some upstream rewrite) gets
1247
- // demoted from `prefix_hit` back to `fresh`.
1248
- //
1249
- // Paged: `lookup.hit` is always `false` so we entered
1250
- // with `sessionCacheStatus = 'fresh'`. Promote to
1251
- // `prefix_hit` when the native engine reports
1252
- // `cachedTokens > 0` — that's the authoritative signal
1253
- // that `BlockAllocator`'s content-addressed prefix lookup
1254
- // recovered shared SYS blocks on this turn. `res.end` has
1255
- // not fired yet (`handleNonStreaming` is what flushes via
1256
- // `endJson`), so the overwrite still lands on the wire.
1257
- if (lookup.hit && result.cachedTokens === 0) {
1258
- sessionCacheStatus = 'fresh';
1259
- res.setHeader('X-Session-Cache', sessionCacheStatus);
1260
- }
1261
- else if (pagedActive && result.cachedTokens > 0) {
1262
- sessionCacheStatus = 'prefix_hit';
1263
- res.setHeader('X-Session-Cache', sessionCacheStatus);
1264
- }
1265
- // Companion `X-Cached-Tokens` header: emitted only when
1266
- // reuse genuinely happened, so operators can spot a stale
1267
- // `prefix_hit` claim from telemetry alone.
1268
- if (result.cachedTokens > 0) {
1269
- res.setHeader('X-Cached-Tokens', String(result.cachedTokens));
1270
- }
1271
- await handleNonStreaming(res, result, body, visibility, stopSequences, serverTiming);
1272
- // Non-paged success: adopt the warm slot only when the
1273
- // dispatch actually committed. Mirrors the streaming-side
1274
- // dual-gate at `streamResult.ok && outcome.wasCommitted()`
1275
- // above and the sibling `/v1/responses` adopt gate, so the
1276
- // local invariant — "never adopt an uncommitted session"
1277
- // — is enforced by the same check on both wire formats and
1278
- // both endpoints. Today every native failure throws (and
1279
- // routes through the inner catch below), so the gate is
1280
- // dead code on the current Rust paths; it defends the
1281
- // invariant LOCALLY so a future native change that
1282
- // resolves `chat_session_start_sync` with
1283
- // `Ok(finish_reason="error")` cannot silently poison the
1284
- // warm slot. Drop on the uncommitted branch matches the
1285
- // streaming-side `else { drop(...) }` so the sentinel does
1286
- // not accumulate stale entries from earlier turns.
1287
- //
1288
- // Paged success: never adopt — block-level reuse is
1289
- // already in the native cache, and adopting would
1290
- // re-introduce the cross-endpoint warm-slot eviction
1291
- // that paged is supposed to eliminate.
1292
- if (!pagedActive) {
1293
- if (outcome.committed && !hasSuppressedToolCalls(result, body)) {
1294
- sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null);
1423
+ catch (err) {
1424
+ // A failed turn on the non-paged path must not leave a
1425
+ // poisoned warm slot for the next request to lease — drop
1426
+ // the sentinel before emitting the error response.
1427
+ // Streaming half-failures are already covered by the
1428
+ // `wasCommitted()` gate above; this catch handles
1429
+ // non-streaming throws and any pre-handler failures from
1430
+ // the streaming path. The paged path never adopts, so the
1431
+ // drop is a no-op there but kept unconditional for
1432
+ // simplicity (the registry treats `drop` of an absent key
1433
+ // as a no-op).
1434
+ sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1435
+ const message = err instanceof Error ? err.message : 'Unknown error during inference';
1436
+ if (visibility.responseMode === null) {
1437
+ if (isContextCapacityError(err)) {
1438
+ sendAnthropicBadRequest(res, message);
1295
1439
  }
1296
1440
  else {
1297
- sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1441
+ sendAnthropicInternalError(res, message);
1298
1442
  }
1299
1443
  }
1300
- }
1301
- }
1302
- catch (err) {
1303
- // A failed turn on the non-paged path must not leave a
1304
- // poisoned warm slot for the next request to lease — drop
1305
- // the sentinel before emitting the error response.
1306
- // Streaming half-failures are already covered by the
1307
- // `wasCommitted()` gate above; this catch handles
1308
- // non-streaming throws and any pre-handler failures from
1309
- // the streaming path. The paged path never adopts, so the
1310
- // drop is a no-op there but kept unconditional for
1311
- // simplicity (the registry treats `drop` of an absent key
1312
- // as a no-op).
1313
- sessionReg.drop(MESSAGES_WARM_SLOT_ID);
1314
- const message = err instanceof Error ? err.message : 'Unknown error during inference';
1315
- if (visibility.responseMode === null) {
1316
- if (isContextCapacityError(err)) {
1317
- sendAnthropicBadRequest(res, message);
1444
+ else if (visibility.responseMode === 'json') {
1445
+ // Already committed to JSON — destroy the socket rather than corrupt the body.
1446
+ try {
1447
+ res.destroy(err instanceof Error ? err : new Error(message));
1448
+ }
1449
+ catch {
1450
+ // Socket may already be gone.
1451
+ }
1318
1452
  }
1319
1453
  else {
1320
- sendAnthropicInternalError(res, message);
1321
- }
1322
- }
1323
- else if (visibility.responseMode === 'json') {
1324
- // Already committed to JSON — destroy the socket rather than corrupt the body.
1325
- try {
1326
- res.destroy(err instanceof Error ? err : new Error(message));
1327
- }
1328
- catch {
1329
- // Socket may already be gone.
1454
+ // SSE: best-effort streaming `error`, but only if no terminal landed
1455
+ // (a double terminal would confuse the client state machine).
1456
+ if (!visibility.terminalEmitted) {
1457
+ writeFallbackErrorSSE(res, 'error', {
1458
+ error: { type: 'api_error', message },
1459
+ });
1460
+ }
1461
+ try {
1462
+ endSSE(res);
1463
+ }
1464
+ catch {
1465
+ // Already closed.
1466
+ }
1330
1467
  }
1331
1468
  }
1332
- else {
1333
- // SSE: best-effort streaming `error`, but only if no terminal landed
1334
- // (a double terminal would confuse the client state machine).
1335
- if (!visibility.terminalEmitted) {
1336
- writeFallbackErrorSSE(res, 'error', {
1337
- error: { type: 'api_error', message },
1338
- });
1339
- }
1340
- try {
1341
- endSSE(res);
1342
- }
1343
- catch {
1344
- // Already closed.
1469
+ finally {
1470
+ // Every session that was not retained in the warm registry owns
1471
+ // request-local native state and must be released before leaving
1472
+ // the admission lane. Cleanup failure must not replace a terminal
1473
+ // response already delivered to the client.
1474
+ if (!sessionRetained) {
1475
+ try {
1476
+ await sessionReg.disposeSession(session);
1477
+ }
1478
+ catch (error) {
1479
+ console.error('[messages] failed to release an unretained chat-session cache owner:', error);
1480
+ }
1345
1481
  }
1482
+ await sessionReg.flushPendingDisposals();
1346
1483
  }
1347
- }
1348
- });
1484
+ });
1485
+ };
1349
1486
  await runInference();
1350
1487
  }
1351
1488
  catch (err) {
@@ -1360,7 +1497,7 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
1360
1497
  // still routes through the handler's existing error paths.
1361
1498
  if (err instanceof QueueFullError) {
1362
1499
  if (!res.headersSent) {
1363
- sendAnthropicRateLimit(res, `Model queue full: ${err.queuedCount} waiting (limit ${err.limit}). Retry after 1s.`);
1500
+ sendAnthropicRateLimit(res, `${err.message}. Retry after 1s.`);
1364
1501
  }
1365
1502
  }
1366
1503
  else {
@@ -1369,6 +1506,12 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
1369
1506
  }
1370
1507
  }
1371
1508
  finally {
1509
+ // Balance the pre-dispatch admission on EVERY exit that never handed
1510
+ // the permit to `withExclusive`: binding-changed 400s, disconnects,
1511
+ // and any validation early-return inside the outer `try`. Idempotent
1512
+ // and a no-op after handoff, so the unconditional call is safe.
1513
+ preDispatchAdmission?.release();
1514
+ modelLoadAdmission?.release();
1372
1515
  // Drop disconnect listeners so they don't pin the request past handler
1373
1516
  // return. Only detach if we actually attached (gated by the flag).
1374
1517
  if (abortListenersAttached) {