@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
@@ -13,9 +13,10 @@ import { resetPreservingNativeCacheForWarmReuse } from '../chat-session-warm-reu
13
13
  import { sendBadRequest, sendInternalError, sendNotFound, sendRateLimit, sendStorageTimeout } from '../errors.js';
14
14
  import { mapRequest, reconstructMessagesFromChain, stringifyStoredInputMessages } from '../mappers/request.js';
15
15
  import { buildPartialResponse, buildResponseObject, computeOutputText, genId, mapFinishReasonToStatus, } from '../mappers/response.js';
16
+ import { ModelLoadQueueFullError, } from '../model-work-coordinator.js';
16
17
  import { getPendingWritesFor } from '../pending-writes.js';
17
- import { maybeWarnPromptCacheKeyIneligible, QueueFullError } from '../session-registry.js';
18
- import { beginSSE, endSSE, writeSSEEvent } from '../streaming.js';
18
+ import { maybeWarnPromptCacheKeyIneligible, QueueFullError, } from '../session-registry.js';
19
+ import { awaitDrainOrClose, beginSSE, endSSE, trackSSEClientAbort, writeSSEEvent as writeRawSSEEvent, } from '../streaming.js';
19
20
  import { longestSuffixPrefixOverlap } from '../text-recovery.js';
20
21
  import { mergeTimingUsageExtensions, resolveServerTuningForUsage } from '../timing.js';
21
22
  import { ToolCallTagBuffer } from '../tool-call-buffer.js';
@@ -37,8 +38,16 @@ const RESPONSE_TTL_SECONDS = 1800;
37
38
  * `/v1/messages` (`messages.ts`).
38
39
  */
39
40
  export const MAX_OUTPUT_TOKENS = 2147483647; // i32::MAX — native ChatConfig.max_new_tokens is i32
40
- function withAdmissionControlledInference(sessionReg, modelWorkCoordinator, fn) {
41
- return sessionReg.withExclusive(() => (modelWorkCoordinator ? modelWorkCoordinator.withInference(fn) : fn()));
41
+ function withAdmissionControlledInference(sessionReg, modelWorkCoordinator,
42
+ // Pre-dispatch permit handed off ATOMICALLY as this call's admission
43
+ // (the selected admission lane consumes it instead of charging
44
+ // `queuedCount` a second time). See `beginPreDispatchAdmission`. Placed BEFORE `fn`
45
+ // so call sites keep the trailing-closure layout.
46
+ permit, fn) {
47
+ const run = () => (modelWorkCoordinator ? modelWorkCoordinator.withInference(fn) : fn());
48
+ return sessionReg.concurrentAdmissionLimit > 1
49
+ ? sessionReg.withAdmission(run, permit)
50
+ : sessionReg.withExclusive(run, permit);
42
51
  }
43
52
  /**
44
53
  * Upper bound (ms) on how long the recovery path waits for an in-flight
@@ -156,10 +165,9 @@ export function __setServerBootIdForTesting(id) {
156
165
  async function handleNonStreaming(res, result, req, responseId, previousResponseId, visibility, serverTiming) {
157
166
  const response = buildResponseObject(result, req, responseId, previousResponseId);
158
167
  mergeTimingUsageExtensions(response.usage, result.performance, result.promptTokens, result.numTokens, result.cachedTokens, serverTiming);
159
- // `chatSession*` has no AbortSignal surface yet, so a mid-decode
160
- // client disconnect still burns the full decode budget peer loss
161
- // is only observable when native decode resolves. Disconnect
162
- // detection is delegated to `endJson`'s `isSocketGone(res)` check:
168
+ // The request AbortSignal reaches the normal session method, whose wrapper
169
+ // maps it to native cancellation at the next model safepoint.
170
+ // `endJson`'s `isSocketGone(res)` remains the final transport race check:
163
171
  // on a dead peer it rejects AFTER committing `responseMode = 'json'`
164
172
  // so the outer catch routes to the JSON error / socket-destroy
165
173
  // shape; `responseBodyWritten` flips only from `res.end`'s write
@@ -211,6 +219,15 @@ function buildFailedTerminal(partial, outputItems, reason, usage, errorMessage)
211
219
  };
212
220
  }
213
221
  async function handleStreamingNative(res, chatStream, req, responseId, previousResponseId, wasCommitted, httpReq, visibility, serverTiming) {
222
+ const abort = trackSSEClientAbort(res, httpReq);
223
+ try {
224
+ return await handleStreamingNativeWithAbort(res, chatStream, req, responseId, previousResponseId, wasCommitted, abort, visibility, serverTiming);
225
+ }
226
+ finally {
227
+ abort.dispose();
228
+ }
229
+ }
230
+ async function handleStreamingNativeWithAbort(res, chatStream, req, responseId, previousResponseId, wasCommitted, abort, visibility, serverTiming) {
214
231
  // `runSessionStreaming` completed the exact token/capacity preflight before
215
232
  // handing us this iterator. Commit SSE immediately instead of entering the
216
233
  // generator here: its first `next()` also starts image processing/prefill and
@@ -222,8 +239,24 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
222
239
  // instead of corrupting the JSON path.
223
240
  markSSEMode(visibility);
224
241
  const partial = buildPartialResponse(req, responseId, previousResponseId);
225
- writeSSEEvent(res, 'response.created', { response: partial });
226
- writeSSEEvent(res, 'response.in_progress', { response: partial });
242
+ // Arm the close-safe drain listener synchronously on the FIRST false write.
243
+ // A native event can expand into several SSE frames, so the promise stays
244
+ // sticky until the loop awaits it; no later true return may erase the gate.
245
+ let pendingDrain = null;
246
+ const writeSSEEvent = (response, eventType, data) => {
247
+ const ok = writeRawSSEEvent(response, eventType, data);
248
+ if (!ok && pendingDrain === null) {
249
+ pendingDrain = awaitDrainOrClose(response, { onTimeout: () => abort.markAborted() });
250
+ }
251
+ };
252
+ const drainPending = async () => {
253
+ const drain = pendingDrain;
254
+ if (drain === null)
255
+ return;
256
+ await drain;
257
+ if (pendingDrain === drain)
258
+ pendingDrain = null;
259
+ };
227
260
  const outputItems = [];
228
261
  let outputIndex = 0;
229
262
  // State tracking for streaming
@@ -255,42 +288,22 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
255
288
  // terminal chunk carries `cachedTokens`. Today it never does; a
256
289
  // future native plumbing change can lift it through.
257
290
  let cachedTokens;
258
- // Fault state. `thrownError` sticks on a generator throw;
259
- // `clientAborted` sticks on any `close`/`error` from `httpReq`, `res`,
260
- // or `res.socket`. Either flips the post-loop block to the failure
261
- // epilogue. Listening on `res` and `res.socket` matters because
262
- // non-terminal SSE writes can silently "succeed" on a dead socket.
291
+ // Fault state. `thrownError` sticks on a generator throw. The outer
292
+ // `SSEClientAbortTracker` remains armed through every residual write, drain,
293
+ // classification, and terminal flush not merely through this loop.
263
294
  let thrownError = null;
264
- let clientAborted = false;
265
- const onClientClose = () => {
266
- clientAborted = true;
267
- };
268
- const onClientError = (_err) => {
269
- clientAborted = true;
270
- };
271
- const onResClose = () => {
272
- clientAborted = true;
273
- };
274
- const onResError = (_err) => {
275
- clientAborted = true;
276
- };
277
- const resSocketForAbort = res.socket;
278
- if (httpReq) {
279
- httpReq.once('close', onClientClose);
280
- httpReq.once('error', onClientError);
281
- }
282
- res.once('close', onResClose);
283
- res.once('error', onResError);
284
- if (resSocketForAbort != null) {
285
- resSocketForAbort.once('close', onResClose);
286
- }
295
+ // The outer wrapper installed abort listeners before the first body write.
296
+ // If that write queues an asynchronous transport error, `abort.aborted` must
297
+ // flip before its drain promise settles and the loop evaluates the gate.
298
+ writeSSEEvent(res, 'response.created', { response: partial });
299
+ writeSSEEvent(res, 'response.in_progress', { response: partial });
287
300
  try {
288
301
  for await (const event of chatStream) {
289
- // Honor client disconnect at loop-top. Native decode has no
290
- // AbortSignal yet; `break` drops the generator reference so
291
- // the producer's `finally` releases per-model locks and the
292
- // post-loop block routes to the failure epilogue.
293
- if (clientAborted)
302
+ await drainPending();
303
+ // Honor disconnect or a bounded drain timeout at loop-top. Breaking
304
+ // drops the generator reference; its AbortSignal cancels native work
305
+ // and its finally releases the per-model lock.
306
+ if (abort.aborted)
294
307
  break;
295
308
  if (event.done) {
296
309
  sawDone = true;
@@ -761,15 +774,9 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
761
774
  console.error(`[responses] native dispatch failed for ${req.model} (response ${responseId}):`, thrownError.message);
762
775
  }
763
776
  finally {
764
- if (httpReq) {
765
- httpReq.off('close', onClientClose);
766
- httpReq.off('error', onClientError);
767
- }
768
- res.off('close', onResClose);
769
- res.off('error', onResError);
770
- if (resSocketForAbort != null) {
771
- resSocketForAbort.off('close', onResClose);
772
- }
777
+ // Cover done/break/continue/generator-throw paths. Abort listeners belong
778
+ // to the outer wrapper and intentionally remain installed after this.
779
+ await drainPending();
773
780
  }
774
781
  // Post-loop terminal emission. The producer's finally has run so
775
782
  // `wasCommitted()` reads an authoritative baseline. On success emit
@@ -778,7 +785,7 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
778
785
  // `stream_exhausted`. `response.failed` is emitted even on
779
786
  // `client_abort` so a tee/proxy that stays connected sees a terminal.
780
787
  const committed = wasCommitted();
781
- const successful = sawDone && committed && thrownError == null && !clientAborted;
788
+ const successful = sawDone && committed && thrownError == null && !abort.aborted;
782
789
  if (successful) {
783
790
  const terminal = completedResponse;
784
791
  // Emit deferred function_call events now that the commit gate
@@ -802,16 +809,21 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
802
809
  writeSSEEvent(res, 'response.output_item.done', { output_index: fcIndex, item });
803
810
  }
804
811
  }
805
- // The terminal SSE flushes inside the per-model mutex (client
806
- // expects it ordered against prior deltas); the `ResponseStore`
807
- // write is deferred to the outer handler so a slow SQLite write
808
- // does not pin the next waiter. `flushTerminalSSE` flips
809
- // `terminalEmitted` only once the kernel acks the frame a
810
- // callback-reported error rejects so the outer catch refuses to
811
- // adopt under an unseen responseId.
812
- await flushTerminalSSE(res, 'response.completed', { response: terminal }, visibility);
813
- endSSE(res);
814
- return { terminalToPersist: terminal, failureMode: null, cachedTokens };
812
+ await drainPending();
813
+ // A close/error can be the event that settled the residual drain. Recheck
814
+ // after the await; the pre-drain success snapshot is no longer sufficient.
815
+ if (!abort.aborted) {
816
+ // The terminal SSE flushes inside the per-model mutex (client
817
+ // expects it ordered against prior deltas); the `ResponseStore`
818
+ // write is deferred to the outer handler so a slow SQLite write
819
+ // does not pin the next waiter. `flushTerminalSSE` flips
820
+ // `terminalEmitted` only once the kernel acks the frame — a
821
+ // callback-reported error rejects so the outer catch refuses to
822
+ // adopt under an unseen responseId.
823
+ await flushTerminalSSE(res, 'response.completed', { response: terminal }, visibility);
824
+ endSSE(res);
825
+ return { terminalToPersist: terminal, failureMode: null, cachedTokens };
826
+ }
815
827
  }
816
828
  // Failure epilogue. Close any dangling message items BEFORE the
817
829
  // terminal so clients tracking `output_index` see matching closes.
@@ -819,7 +831,7 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
819
831
  // deferred to the success path); reasoning items have no `status`.
820
832
  const reason = thrownError
821
833
  ? 'error'
822
- : clientAborted
834
+ : abort.aborted
823
835
  ? 'client_abort'
824
836
  : sawDone
825
837
  ? 'finish_reason_error'
@@ -890,6 +902,7 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
890
902
  writeSSEEvent(res, 'response.output_item.done', { output_index: riIndex, item: reasoningItem });
891
903
  }
892
904
  }
905
+ await drainPending();
893
906
  const failedTerminal = buildFailedTerminal(partial, finalOutput, reason, usage, reason === 'error' && thrownError ? thrownError.message : null);
894
907
  await flushTerminalSSE(res, 'response.failed', { response: failedTerminal }, visibility);
895
908
  endSSE(res);
@@ -1125,14 +1138,14 @@ export function validateAndCanonicalizeHistoryToolOrder(messages, apiSurface = '
1125
1138
  * is responsible for rejecting partial tool-result submissions
1126
1139
  * against a fan-out (`handleCreateResponse` fan-out gate).
1127
1140
  *
1128
- * `isFreshSession` is the MISS / HIT signal from `SessionRegistry`:
1129
- * `true` when `lookup.hit === false` (a truly new session minted via
1130
- * `newSession()`), `false` when a tier-1 or tier-2 warm lease was
1131
- * handed out. On a MISS we must wipe the shared native model's
1141
+ * `resetNativeCache` is the cache-isolation decision made by the caller.
1142
+ * A non-paged registry miss wipes the shared native model's
1132
1143
  * leftover `cached_token_history` + KV caches before re-priming, or
1133
1144
  * a previous UNRELATED request's cache could silently get reused as
1134
- * a prefix (cross-request cache-affinity side channel). On a HIT we
1135
- * must NOT wipe the whole point of the warm lease is that
1145
+ * a prefix (cross-request cache-affinity side channel). Registry hits
1146
+ * and block-paged models preserve native state: the former owns the
1147
+ * leased cache, while the latter validates reusable blocks by content
1148
+ * hash and isolates live requests by cache owner. In both cases
1136
1149
  * `verify_cache_prefix_direct` can recover the reused prefix on the
1137
1150
  * next `chat_session_start_sync`. The HIT branch calls the
1138
1151
  * server-private `resetPreservingNativeCacheForWarmReuse(session)`
@@ -1143,7 +1156,7 @@ export function validateAndCanonicalizeHistoryToolOrder(messages, apiSurface = '
1143
1156
  * public surface, so downstream consumers cannot discover or invoke
1144
1157
  * it.
1145
1158
  */
1146
- async function runSessionNonStreaming(session, messages, newInputMessages, config, isFreshSession) {
1159
+ async function runSessionNonStreaming(session, messages, newInputMessages, config, resetNativeCache, signal) {
1147
1160
  if (session.turns === 0) {
1148
1161
  // Fresh JS session does NOT imply a fresh native cache — the
1149
1162
  // underlying `SessionCapableModel` is shared across every
@@ -1155,14 +1168,11 @@ async function runSessionNonStreaming(session, messages, newInputMessages, confi
1155
1168
  // `primeHistory() + startFromHistory()` on a fresh session would
1156
1169
  // inherit the PREVIOUS request's native cache and silently reuse
1157
1170
  // whatever prefix happened to overlap — a cross-request
1158
- // cache-affinity side channel. Only registry HITS (tier-1 /
1159
- // tier-2) are authorized for cache reuse, and a leased session
1160
- // almost always has `turns > 0` so this branch is nearly always
1161
- // a MISS. The `isFreshSession` flag is the authoritative signal
1162
- // on `false` we still need to clear JS-side state so
1163
- // `primeHistory()` accepts the replay, but we keep the native
1164
- // cache intact so the prefix verifier can recover it.
1165
- if (isFreshSession) {
1171
+ // cache-affinity side channel. The caller permits native reuse only
1172
+ // for a registry hit or a content-addressed block-paged model. In
1173
+ // both cases we still clear JS-side state so `primeHistory()` accepts
1174
+ // the replay while the native prefix verifier decides what is reusable.
1175
+ if (resetNativeCache) {
1166
1176
  await session.reset();
1167
1177
  }
1168
1178
  else {
@@ -1170,7 +1180,7 @@ async function runSessionNonStreaming(session, messages, newInputMessages, confi
1170
1180
  }
1171
1181
  session.primeHistory(messages);
1172
1182
  const initialTurns = session.turns;
1173
- const result = await session.startFromHistory(config);
1183
+ const result = await session.startFromHistory(config, { signal });
1174
1184
  return { result, committed: session.turns > initialTurns };
1175
1185
  }
1176
1186
  // Hot path — session's KV cache is already warmed for this chain.
@@ -1189,7 +1199,7 @@ async function runSessionNonStreaming(session, messages, newInputMessages, confi
1189
1199
  if (last.role === 'user') {
1190
1200
  const initialTurns = session.turns;
1191
1201
  const images = last.images ?? undefined;
1192
- const result = await session.send(last.content, images ? { images, config } : { config });
1202
+ const result = await session.send(last.content, images ? { images, config, signal } : { config, signal });
1193
1203
  return { result, committed: session.turns > initialTurns };
1194
1204
  }
1195
1205
  if (last.role === 'tool') {
@@ -1202,7 +1212,11 @@ async function runSessionNonStreaming(session, messages, newInputMessages, confi
1202
1212
  // with the Anthropic `tool_result.is_error === true` source field
1203
1213
  // (the structured channel is the authoritative signal — see
1204
1214
  // `ChatMessage.isError` rustdoc).
1205
- const result = await session.sendToolResult(last.toolCallId, last.content, { config, isError: last.isError });
1215
+ const result = await session.sendToolResult(last.toolCallId, last.content, {
1216
+ config,
1217
+ isError: last.isError,
1218
+ signal,
1219
+ });
1206
1220
  return { result, committed: session.turns > initialTurns };
1207
1221
  }
1208
1222
  // Non-user / non-tool single-message continuation (assistant /
@@ -1218,9 +1232,10 @@ async function runSessionNonStreaming(session, messages, newInputMessages, confi
1218
1232
  // warm session falls through to this branch), we MUST keep the
1219
1233
  // native KV cache so the native `verify_cache_prefix_direct` can
1220
1234
  // recover the reused prefix — wiping it would neutralize the
1221
- // entire warm-lease feature on multi-message hits. On a MISS we
1222
- // still wipe to prevent cross-request cache-affinity leakage.
1223
- if (isFreshSession) {
1235
+ // entire warm-lease feature on multi-message hits. Block-paged models
1236
+ // also preserve their content-verified per-owner cache; only a non-paged
1237
+ // miss wipes to prevent cross-request cache-affinity leakage.
1238
+ if (resetNativeCache) {
1224
1239
  await session.reset();
1225
1240
  }
1226
1241
  else {
@@ -1228,11 +1243,11 @@ async function runSessionNonStreaming(session, messages, newInputMessages, confi
1228
1243
  }
1229
1244
  session.primeHistory(messages);
1230
1245
  const initialTurns = session.turns;
1231
- const result = await session.startFromHistory(config);
1246
+ const result = await session.startFromHistory(config, { signal });
1232
1247
  return { result, committed: session.turns > initialTurns };
1233
1248
  }
1234
1249
  /** Streaming counterpart to {@link runSessionNonStreaming}. */
1235
- async function runSessionStreaming(session, messages, newInputMessages, config, signal, isFreshSession) {
1250
+ async function runSessionStreaming(session, messages, newInputMessages, config, signal, resetNativeCache) {
1236
1251
  // Preserve the startFromHistoryStream precondition outside the lazy
1237
1252
  // generator. Without this guard an accepted `input: []` request would commit
1238
1253
  // SSE and only then throw when iteration begins; the former eager-first-item
@@ -1250,11 +1265,10 @@ async function runSessionStreaming(session, messages, newInputMessages, config,
1250
1265
  // prior requests; without an explicit `reset()` here the native
1251
1266
  // prefix verifier can silently reuse a previous request's cache
1252
1267
  // on any prompt-prefix overlap (a cross-request cache-affinity
1253
- // side channel). On MISS (`isFreshSession === true`) we wipe
1254
- // native cache + JS state. On tier-1 / tier-2 HIT we keep the
1255
- // native cache so the prefix verifier can recover the reused
1256
- // prefix, while still clearing JS-side state for `primeHistory`.
1257
- if (isFreshSession) {
1268
+ // side channel). The caller requests a full native wipe only for a
1269
+ // non-paged registry miss; warm hits and content-addressed paged models
1270
+ // keep verified native state while still clearing JS state for replay.
1271
+ if (resetNativeCache) {
1258
1272
  await session.reset();
1259
1273
  }
1260
1274
  else {
@@ -1312,10 +1326,11 @@ async function runSessionStreaming(session, messages, newInputMessages, config,
1312
1326
  // captured AFTER reset. On tier-1 / tier-2 HIT (warm lease that
1313
1327
  // cannot use the delta API because the input spans multiple
1314
1328
  // messages), keep the native KV cache so the prefix verifier can
1315
- // reuse it on the replayed `chat_session_start_sync`; on MISS, wipe
1329
+ // reuse it on the replayed `chat_session_start_sync`. Block-paged models
1330
+ // likewise preserve content-verified native state; non-paged misses wipe
1316
1331
  // to block cross-request cache-affinity leakage.
1317
1332
  const constrainedConfig = await session.preflightContextCapacity(messages, config);
1318
- if (isFreshSession) {
1333
+ if (resetNativeCache) {
1319
1334
  await session.reset();
1320
1335
  }
1321
1336
  else {
@@ -1416,6 +1431,9 @@ function readStoredModelIdentity(record) {
1416
1431
  export async function handleCreateResponse(res, body, registry, store, httpReq, responseRetentionSec, idleSweeper, modelWorkCoordinator,
1417
1432
  /** Lazy-load hook. See the call site below and `ServerConfig.resolveModel`. */
1418
1433
  resolveModel) {
1434
+ if (modelWorkCoordinator) {
1435
+ registry.setModelLoadAdmissionCoordinator(modelWorkCoordinator);
1436
+ }
1419
1437
  const handlerStartedAt = Date.now();
1420
1438
  // Validate required fields
1421
1439
  if (body == null || typeof body !== 'object') {
@@ -1475,8 +1493,53 @@ resolveModel) {
1475
1493
  }
1476
1494
  }
1477
1495
  const effectiveRetentionSec = requestedRetentionSec ?? responseRetentionSec;
1478
- // Lazy load, exactly as the Anthropic endpoints do. Nothing is resident at
1479
- // boot `createInferenceHost` only discovers so without this the very
1496
+ // Pre-dispatch admission gate (H3, host mode). Resident requests bypass
1497
+ // the model-load writer below and proceed toward the continuous-batching
1498
+ // lane, but can still park in pre-lock store work before reaching it. Admit
1499
+ // or 429 up front against the same per-model budget so that work stays
1500
+ // bounded too. A non-resident model has no `SessionRegistry` yet, so it
1501
+ // takes the coordinator's bounded pre-resolution permit below instead of
1502
+ // entering its writer queue uncounted.
1503
+ //
1504
+ // The applicable permit is RETAINED through every pre-lock await — the
1505
+ // writer bracket for a cold model, or the `store.getChain` continuation
1506
+ // lookups for a resident model — and handed to the selected resident lane
1507
+ // at placement, which consumes it
1508
+ // atomically as this request's admission (one budget, one token,
1509
+ // never double-counted). It is released only on the bail-out exits:
1510
+ // explicitly on the early returns before the outer `try`, and by the
1511
+ // outer `finally` for everything inside it (idempotent + no-op after
1512
+ // handoff, so the unconditional release is always safe).
1513
+ let preDispatchAdmission;
1514
+ let modelLoadAdmission;
1515
+ const preDispatchRegistry = registry.getSessionRegistry(body.model);
1516
+ if (preDispatchRegistry) {
1517
+ try {
1518
+ preDispatchAdmission = preDispatchRegistry.beginPreDispatchAdmission();
1519
+ }
1520
+ catch (err) {
1521
+ if (err instanceof QueueFullError) {
1522
+ sendRateLimit(res, `${err.message}. Retry after 1s.`);
1523
+ return;
1524
+ }
1525
+ throw err;
1526
+ }
1527
+ }
1528
+ else if (resolveModel && modelWorkCoordinator) {
1529
+ try {
1530
+ modelLoadAdmission = modelWorkCoordinator.beginRequestLoadAdmission(body.model);
1531
+ }
1532
+ catch (err) {
1533
+ if (err instanceof ModelLoadQueueFullError) {
1534
+ sendRateLimit(res, `Model queue full: admission footprint ${err.admissionFootprint} (limit ${err.limit}). Retry after 1s.`);
1535
+ return;
1536
+ }
1537
+ throw err;
1538
+ }
1539
+ }
1540
+ // Lazy load, exactly as the Anthropic endpoints do, only when the requested
1541
+ // name has no resident registry. Nothing is resident at boot —
1542
+ // `createInferenceHost` only discovers — so without this the very
1480
1543
  // first `/v1/responses` 404s against a `/v1/models` list that advertises the
1481
1544
  // model, and a client id that exists only as an alias 404s forever.
1482
1545
  //
@@ -1484,7 +1547,7 @@ resolveModel) {
1484
1547
  // 400 cannot burn a 30 s load or evict the resident model. BEFORE
1485
1548
  // `registry.get` below, and therefore before the dispatch lease, which needs
1486
1549
  // a registered name.
1487
- if (resolveModel) {
1550
+ if (resolveModel && !preDispatchRegistry) {
1488
1551
  // Errors serialize through the OpenAI envelope here. Letting them reach
1489
1552
  // the outer `createHandler` catch would be right for this endpoint by
1490
1553
  // accident and wrong for the Anthropic one — `messages.ts` has the mirror
@@ -1501,13 +1564,21 @@ resolveModel) {
1501
1564
  await (idleSweeper ? idleSweeper.withSuspendedDrains(load) : load());
1502
1565
  }
1503
1566
  catch (err) {
1567
+ preDispatchAdmission?.release();
1568
+ modelLoadAdmission?.release();
1504
1569
  sendInternalError(res, err instanceof Error ? err.message : 'Failed to resolve model');
1505
1570
  return;
1506
1571
  }
1507
1572
  }
1573
+ // NOTE: the permit is NOT released here. Pre-lock work continues below
1574
+ // (`store.getChain` on continuations can block indefinitely on a slow
1575
+ // store) and the request must stay counted until `withExclusive`
1576
+ // consumes the permit at placement. Only bail-out exits release.
1508
1577
  // Look up model
1509
1578
  const model = registry.get(body.model);
1510
1579
  if (!model) {
1580
+ preDispatchAdmission?.release();
1581
+ modelLoadAdmission?.release();
1511
1582
  sendNotFound(res, `Model "${body.model}" not found. Available models: ${registry
1512
1583
  .list()
1513
1584
  .map((m) => m.id)
@@ -1521,10 +1592,26 @@ resolveModel) {
1521
1592
  // chains against one native model. Released in `finally` below.
1522
1593
  const lease = registry.acquireDispatchLease(body.model);
1523
1594
  if (!lease) {
1595
+ preDispatchAdmission?.release();
1596
+ modelLoadAdmission?.release();
1524
1597
  sendInternalError(res, 'session registry missing for registered model');
1525
1598
  return;
1526
1599
  }
1527
1600
  const leaseModel = lease.model;
1601
+ if (modelLoadAdmission) {
1602
+ try {
1603
+ preDispatchAdmission = modelLoadAdmission.transferToResident(lease.registry);
1604
+ }
1605
+ catch (err) {
1606
+ registry.releaseDispatchLease(leaseModel);
1607
+ modelLoadAdmission.release();
1608
+ if (err instanceof QueueFullError) {
1609
+ sendRateLimit(res, `${err.message}. Retry after 1s.`);
1610
+ return;
1611
+ }
1612
+ throw err;
1613
+ }
1614
+ }
1528
1615
  // AbortController wired to disconnect events, declared at handler
1529
1616
  // scope so the outer `finally` can always detach even on early
1530
1617
  // return. Listeners attach only after the pre-lock validation gates
@@ -2023,6 +2110,17 @@ resolveModel) {
2023
2110
  httpReq.once('close', onAbortClose);
2024
2111
  httpReq.once('error', onAbortError);
2025
2112
  }
2113
+ // Catch-up abort: a response torn down BEFORE the attach above has
2114
+ // already emitted its terminal event, so the `once('close')`
2115
+ // listeners will never fire. Consult the response-side socket state
2116
+ // directly (the REQUEST side is deliberately excluded — a fully
2117
+ // consumed IncomingMessage auto-destroys after 'end' on every normal
2118
+ // request, so `httpReq.destroyed` is not a disconnect signal). This
2119
+ // makes `streamSignal.aborted` authoritative for the H2 pre-dispatch
2120
+ // disconnect check inside the mutex callback.
2121
+ if (res.destroyed || res.writableEnded || abortSocket?.destroyed === true) {
2122
+ abortController.abort();
2123
+ }
2026
2124
  abortListenersAttached = true;
2027
2125
  const streamSignal = abortController.signal;
2028
2126
  // Persistence is a two-step dance.
@@ -2110,998 +2208,987 @@ resolveModel) {
2110
2208
  idleListenersAttached = true;
2111
2209
  try {
2112
2210
  const mutexQueuedAt = Date.now();
2113
- const runInference = () => withAdmissionControlledInference(sessionReg, modelWorkCoordinator, async () => {
2114
- const serverTiming = {
2115
- server_queue_ms: Date.now() - mutexQueuedAt,
2116
- server_pre_inference_ms: Date.now() - handlerStartedAt,
2117
- ...resolveServerTuningForUsage(),
2118
- };
2119
- // Hot-swap race guard inside the mutex.
2120
- //
2121
- // `withExclusive` can park this waiter behind a long-running
2122
- // dispatch on the same model, and `ModelRegistry.register()` is
2123
- // NOT coordinated with that lock a concurrent
2124
- // `registry.register(body.model, newModel)` can re-point the
2125
- // friendly name while we are parked. Without this in-lock re-read
2126
- // the closure would still lease a session out of the already-
2127
- // captured `preLockSessionReg`, adopt under the dead
2128
- // `preLockInstanceId`, and persist the new chain under a binding
2129
- // that `body.model` no longer resolves to. The pre-lock
2130
- // re-read only covered the `store.getChain()` await window; the
2131
- // mutex-wait window is strictly later and equally unsafe.
2132
- //
2133
- // Compare the live binding to the pre-lock snapshot (captured
2134
- // just before entering the mutex already refreshed on the
2135
- // continuation path, identical to the handler-top snapshot
2136
- // on the stateless path). Any drift nullable or value — is
2137
- // fatal and rejected with the same 400 envelope the pre-lock
2138
- // guard uses, so clients see a consistent "binding changed"
2139
- // error regardless of which await window caught the race.
2140
- const lockedSessionReg = registry.getSessionRegistry(body.model);
2141
- const lockedInstanceId = registry.getInstanceId(body.model);
2142
- if (lockedSessionReg === undefined ||
2143
- lockedInstanceId === undefined ||
2144
- lockedSessionReg !== preLockSessionReg ||
2145
- lockedInstanceId !== preLockInstanceId) {
2146
- sendBadRequest(res, `Model "${body.model}" binding changed while the request was queued behind the per-model ` +
2147
- `execution mutex. A concurrent register() re-pointed the name at a different model instance ` +
2148
- `(or released it entirely) while this waiter was parked, so the session registry and instance ` +
2149
- `id captured before the mutex wait no longer match the live binding. Dispatching anyway would ` +
2150
- `route the request through the wrong model priming, decoding, and persisting under a dead ` +
2151
- `binding. Retry the request if the swap was intentional, the new binding will service the ` +
2152
- `retry cleanly.`, 'model');
2153
- return;
2154
- }
2155
- // Route the request through a `ChatSession` looked up by the prior
2156
- // response id. A miss (null id, unknown id, expired entry, or
2157
- // prefix-state mismatch) returns a fresh session; a hit leases the
2158
- // cached session out of the registry (single-use — the entry is
2159
- // removed on hit so overlapping requests against the same prior id
2160
- // cannot race on the same single-flight ChatSession).
2161
- //
2162
- // Hot-path eligibility gate: the high-level chat-session API only
2163
- // serves a SINGLE `user` or `tool` continuation message — the
2164
- // `send` / `sendToolResult` entry points cover exactly that
2165
- // shape. A single `assistant` / `system` continuation cannot
2166
- // be advanced incrementally against the warm KV cache and
2167
- // must be handled via reset + cold re-prime. That branch is
2168
- // still VALID — it just routes through `runSession*`'s
2169
- // `session.turns === 0` fall-through (`primeHistory` +
2170
- // `startFromHistory*`) instead of the `send` / `sendToolResult`
2171
- // session continuation path. Crucially, a tier-1 HIT on this branch is still
2172
- // useful: `resetPreservingNativeCacheForWarmReuse(session)` keeps
2173
- // the warm native KV cache, and the subsequent
2174
- // `chat_session_start_sync` -> `verify_cache_prefix_direct`
2175
- // recovers the reused prefix even across a full-history
2176
- // replay. So we must NOT rewrite `previousResponseId` to null
2177
- // to force a cold-replay lookup — the tier-1 lease is exactly
2178
- // what makes warm reuse work. (Pre-Round 5 this branch passed
2179
- // `null` to force a miss, but that was wrong: it threw away a
2180
- // usable warm lease AND mislabeled the turn as `cold_replay`
2181
- // when the native prefix verifier was about to reuse the
2182
- // entire previous-turn prefix.) The hot-path-ineligibility
2183
- // condition (single non-user/non-tool continuation) is no
2184
- // longer consulted at lookup time — it's just the natural
2185
- // fall-through to the `session.turns === 0 || multi-message`
2186
- // cold-re-prime branch in `runSession*`, which correctly
2187
- // preserves the warm native cache on HIT via
2188
- // `resetPreservingNativeCacheForWarmReuse(session)`.
2189
- // Normalize the caller-supplied `prompt_cache_key` into the
2190
- // `string | null` shape the registry expects. `undefined` and
2191
- // missing both map to `null`; an explicit empty string is
2192
- // preserved distinct from `null` so the registry's tier-2
2193
- // scan treats "no key" and "empty key" as different tenants
2194
- // (prevents an unkeyed client from accidentally colliding
2195
- // with one that explicitly empty-keyed).
2196
- const promptCacheKey = typeof body.prompt_cache_key === 'string' ? body.prompt_cache_key : null;
2197
- // Precedence gate: when `previous_response_id` is present, tier-2
2198
- // (prompt-cache-key) lookup is DISABLED — even if the request is
2199
- // hot-path ineligible (single `assistant` / `system` continuation).
2200
- // The documented precedence is "prev-id wins; tier-1 miss falls
2201
- // through to FRESH, not tier-2", and that rule has to hold whether
2202
- // we take the hot path or the ineligible cold-replay branch. If we
2203
- // let the ineligible branch fall through to tier-2, a mis-routed
2204
- // prev-id request could lease an UNRELATED warm session that
2205
- // happens to share `prompt_cache_key`, then cold-replay on top of
2206
- // it — which `session.reset()` + `primeHistory()` would destroy,
2207
- // corrupting an unrelated chain. Force `null` for the cache key on
2208
- // both branches whenever a prev-id is set; tier-2 only runs for
2209
- // requests with no prev-id at all.
2210
- const effectivePromptCacheKey = previousResponseId != null ? null : promptCacheKey;
2211
- // Integrator nudge: if the caller supplied a non-empty
2212
- // `prompt_cache_key` but tier-2 prerequisites are missing
2213
- // (env gate off or key below the min-length floor) the turn
2214
- // silently cold-starts. Emit a once-per-distinct-raw-key
2215
- // stderr warning so `X-Session-Cache: fresh` on every request
2216
- // can be diagnosed without reading source. Gated on
2217
- // `effectivePromptCacheKey` (not raw `promptCacheKey`) so a
2218
- // request that suppresses the key via `previous_response_id`
2219
- // precedence does not also log a misleading "key ignored"
2220
- // message — that case is documented precedence, not a
2221
- // misconfiguration.
2222
- if (effectivePromptCacheKey !== null) {
2223
- maybeWarnPromptCacheKeyIneligible(effectivePromptCacheKey);
2224
- }
2225
- const lookup = sessionReg.getOrCreate(previousResponseId ?? null, requestedInstructions, effectivePromptCacheKey);
2226
- const session = lookup.session;
2227
- // `X-Session-Cache` observability header: classify this turn as
2228
- // `fresh` (no `previous_response_id` on the request and tier-2
2229
- // prompt-cache-key did not hit), `hit` (prev-id warm-cache
2230
- // lease consumed on tier 1), `prefix_hit` (tier-2 warm-cache
2231
- // lease consumed — only promoted from `fresh` later once the
2232
- // native `cachedTokens > 0` confirms real prefix reuse), or
2233
- // `cold_replay` (request carried `previous_response_id` but
2234
- // the warm entry was missing / expired / instructions-
2235
- // mismatched / already leased, OR the request shape is
2236
- // ineligible for the hot path — the endpoint will rebuild the
2237
- // session from the `ResponseStore` below). Set before any
2238
- // `writeHead` / SSE `beginSSE` so both JSON and SSE responses
2239
- // carry it. See `endpoints/messages.ts` for the matching
2240
- // emission on `/v1/messages`.
2241
- //
2242
- // The `prefix_hit` promotion and the companion
2243
- // `X-Cached-Tokens: N` header both depend on the native
2244
- // ChatResult's `cachedTokens` field, which is only authoritative
2245
- // AFTER the native dispatch completes. We therefore emit the
2246
- // initial `fresh` / `hit` / `cold_replay` value here and let
2247
- // the post-dispatch branch below promote a `fresh`+tier2-hit
2248
- // classification to `prefix_hit` once the cached-tokens count
2249
- // is known.
2250
- const tier2Hit = previousResponseId == null && lookup.hit;
2251
- // Optimistic pre-dispatch classification. SSE flushes headers on
2252
- // `beginSSE` inside `handleStreamingNative`, so the streaming
2253
- // path has exactly one shot to commit the header value — before
2254
- // the dispatch runs, i.e. BEFORE the native prefix verifier has
2255
- // reported whether any tokens were actually reused.
2256
- //
2257
- // For non-streaming we still commit optimistically to
2258
- // `prefix_hit` on tier-2 hit and demote to `fresh` post-dispatch
2259
- // if `cachedTokens === 0` (template drift, tokenizer change,
2260
- // image-set change, etc.) — `res.end` has not fired yet so the
2261
- // header is still settable.
2262
- //
2263
- // For streaming we deliberately do NOT promote to `prefix_hit`
2264
- // on tier-2 hit. Once SSE headers flush they cannot be
2265
- // corrected, and a false-positive `prefix_hit` would contradict
2266
- // consumers that read `cachedTokens` from the terminal event.
2267
- // Approach B (this path): emit `fresh` on streaming even when
2268
- // tier-2 found a warm session — the cache reuse still happens,
2269
- // only the observability header is conservative. A future
2270
- // refactor can thread `cached_tokens` through the native
2271
- // streaming `start` chunk so the server knows authoritatively
2272
- // before `beginSSE()` flushes, at which point streaming can
2273
- // commit `prefix_hit` too (Approach A). Until then,
2274
- // `prefix_hit` is a non-streaming-only signal.
2275
- const isStreaming = mappedBody.stream === true;
2276
- // Prev-id branch classification. A tier-1 HIT is labeled `hit`
2277
- // regardless of whether the request is hot-path-eligible: the
2278
- // warm native KV cache is reused in both paths (the cheap
2279
- // `send` / `sendToolResult` delta on the eligible branch; the
2280
- // full-history `primeHistory` + `startFromHistory*` replay on
2281
- // the ineligible branch, where `resetPreservingNativeCacheForWarmReuse`
2282
- // keeps the cache alive for `verify_cache_prefix_direct` to
2283
- // recover). Only a registry MISS on the prev-id branch
2284
- // downgrades to `cold_replay` (no warm cache to reuse, the
2285
- // request must rebuild from `ResponseStore`).
2286
- let sessionCacheStatus = previousResponseId == null
2287
- ? tier2Hit && !isStreaming
2288
- ? 'prefix_hit'
2289
- : 'fresh'
2290
- : lookup.hit
2291
- ? 'hit'
2292
- : 'cold_replay';
2293
- res.setHeader('X-Session-Cache', sessionCacheStatus);
2294
- // Multi-tool-call fan-out gate.
2295
- //
2296
- // The chat-session API cannot interleave tool results for a
2297
- // multi-call fan-out turn (each `sendToolResult` dispatch re-opens
2298
- // the assistant turn, so responding to the siblings would weave new
2299
- // assistant replies between the results — see
2300
- // `ChatSession.pendingUnresolvedToolCallCount`). The only valid forward
2301
- // progress from such a turn is an atomic replay that resolves every
2302
- // sibling call in one cold-restart, so we reject any continuation
2303
- // whose submitted `function_call_output` set does not exactly match
2304
- // the outstanding call ids.
2305
- //
2306
- // The gate only runs for `previous_response_id` continuations, where
2307
- // the STORED prior chain (`priorMessages`, reconstructed via
2308
- // `reconstructMessagesFromChain`) is the authoritative view of the
2309
- // trailing assistant turn and `newInputMessages` contains only the
2310
- // caller's continuation delta. Stateless requests (no
2311
- // `previous_response_id`) carry a full self-contained history in
2312
- // `input`, and historical tool outputs for prior resolved turns
2313
- // would otherwise be misclassified against the latest assistant's
2314
- // outstanding id set — leave cold-start histories to the jinja
2315
- // template / chat-session prefill to handle as-is.
2316
- const expectedOutstandingIds = priorMessages ? extractOutstandingToolCallIds(priorMessages) : null;
2317
- // Forged-tool-output guard. A `previous_response_id` continuation that
2318
- // submits any `function_call_output` when the stored prior chain has
2319
- // ZERO outstanding tool calls is structurally invalid: there is no
2320
- // assistant tool call for the result to resolve, so dispatching it
2321
- // would inject a synthetic `<tool_response>` delta into a thread the
2322
- // model never asked to call. Native backends do not authenticate
2323
- // `tool_call_id` against prior state — several just append the
2324
- // delta verbatim — so the gate must live here. Stateless requests
2325
- // (no `previous_response_id`) carry a full self-contained history
2326
- // and are left to the jinja template / chat-session prefill.
2327
- if (previousResponseId && expectedOutstandingIds === null) {
2328
- for (const m of newInputMessages) {
2329
- if (m.role === 'tool') {
2330
- sendBadRequest(res, `function_call_output submitted against a thread with no outstanding tool call. ` +
2331
- `The prior assistant turn either never emitted a tool call or every sibling call has ` +
2332
- `already been resolved, so there is nothing for this function_call_output to answer. ` +
2333
- `Dispatching it anyway would synthesize a tool-response delta for a call the model ` +
2334
- `never made and corrupt the conversation structure. Drop the function_call_output, ` +
2335
- `or start a new chain without previous_response_id.`, 'input');
2336
- return;
2337
- }
2211
+ const runInference = () => {
2212
+ return withAdmissionControlledInference(sessionReg, modelWorkCoordinator, preDispatchAdmission, async () => {
2213
+ const serverTiming = {
2214
+ server_queue_ms: Date.now() - mutexQueuedAt,
2215
+ server_pre_inference_ms: Date.now() - handlerStartedAt,
2216
+ ...resolveServerTuningForUsage(),
2217
+ };
2218
+ // Hot-swap race guard inside the mutex.
2219
+ //
2220
+ // `withExclusive` can park this waiter behind a long-running
2221
+ // dispatch on the same model, and `ModelRegistry.register()` is
2222
+ // NOT coordinated with that lock — a concurrent
2223
+ // `registry.register(body.model, newModel)` can re-point the
2224
+ // friendly name while we are parked. Without this in-lock re-read
2225
+ // the closure would still lease a session out of the already-
2226
+ // captured `preLockSessionReg`, adopt under the dead
2227
+ // `preLockInstanceId`, and persist the new chain under a binding
2228
+ // that `body.model` no longer resolves to. The pre-lock
2229
+ // re-read only covered the `store.getChain()` await window; the
2230
+ // mutex-wait window is strictly later and equally unsafe.
2231
+ //
2232
+ // Compare the live binding to the pre-lock snapshot (captured
2233
+ // just before entering the mutex — already refreshed on the
2234
+ // continuation path, identical to the handler-top snapshot
2235
+ // on the stateless path). Any drift nullable or value — is
2236
+ // fatal and rejected with the same 400 envelope the pre-lock
2237
+ // guard uses, so clients see a consistent "binding changed"
2238
+ // error regardless of which await window caught the race.
2239
+ const lockedSessionReg = registry.getSessionRegistry(body.model);
2240
+ const lockedInstanceId = registry.getInstanceId(body.model);
2241
+ if (lockedSessionReg === undefined ||
2242
+ lockedInstanceId === undefined ||
2243
+ lockedSessionReg !== preLockSessionReg ||
2244
+ lockedInstanceId !== preLockInstanceId) {
2245
+ sendBadRequest(res, `Model "${body.model}" binding changed while the request was queued behind the per-model ` +
2246
+ `execution mutex. A concurrent register() re-pointed the name at a different model instance ` +
2247
+ `(or released it entirely) while this waiter was parked, so the session registry and instance ` +
2248
+ `id captured before the mutex wait no longer match the live binding. Dispatching anyway would ` +
2249
+ `route the request through the wrong model — priming, decoding, and persisting under a dead ` +
2250
+ `binding. Retry the request — if the swap was intentional, the new binding will service the ` +
2251
+ `retry cleanly.`, 'model');
2252
+ return;
2338
2253
  }
2339
- }
2340
- if (expectedOutstandingIds !== null) {
2341
- // Contiguous-prefix guard: function_call_output items must appear
2342
- // as an unbroken prefix of the continuation delta, before any
2343
- // user/assistant/system message. A shape like
2344
- // `[tool(call_a), user(hi), tool(call_b)]` would otherwise pass
2345
- // every id-set check below (both outstanding ids present, no
2346
- // duplicates, no stale ids) while still orphaning the fan-out,
2347
- // because the interleaved user turn re-opens the assistant turn
2348
- // between the two tool results. Reject early so the caller cannot
2349
- // smuggle a user turn into the middle of a resolved fan-out.
2350
- let seenNonTool = false;
2351
- for (const m of newInputMessages) {
2352
- if (m.role === 'tool') {
2353
- if (seenNonTool) {
2354
- sendBadRequest(res, `function_call_output items must appear as a contiguous prefix of the continuation ` +
2355
- `before any user, assistant, or system message. Interleaving a non-tool message ` +
2356
- `between sibling function_call_output items orphans the fan-out by weaving a new ` +
2357
- `assistant turn between the tool results. Reorder the submission so every ` +
2358
- `function_call_output precedes any subsequent message, or start a new chain ` +
2359
- `without previous_response_id.`, 'input');
2254
+ // Route the request through a `ChatSession` looked up by the prior
2255
+ // response id. A miss (null id, unknown id, expired entry, or
2256
+ // prefix-state mismatch) returns a fresh session; a hit leases the
2257
+ // cached session out of the registry (single-use the entry is
2258
+ // removed on hit so overlapping requests against the same prior id
2259
+ // cannot race on the same single-flight ChatSession).
2260
+ //
2261
+ // Hot-path eligibility gate: the high-level chat-session API only
2262
+ // serves a SINGLE `user` or `tool` continuation message — the
2263
+ // `send` / `sendToolResult` entry points cover exactly that
2264
+ // shape. A single `assistant` / `system` continuation cannot
2265
+ // be advanced incrementally against the warm KV cache and
2266
+ // must be handled via reset + cold re-prime. That branch is
2267
+ // still VALID — it just routes through `runSession*`'s
2268
+ // `session.turns === 0` fall-through (`primeHistory` +
2269
+ // `startFromHistory*`) instead of the `send` / `sendToolResult`
2270
+ // session continuation path. Crucially, a tier-1 HIT on this branch is still
2271
+ // useful: `resetPreservingNativeCacheForWarmReuse(session)` keeps
2272
+ // the warm native KV cache, and the subsequent
2273
+ // `chat_session_start_sync` -> `verify_cache_prefix_direct`
2274
+ // recovers the reused prefix even across a full-history
2275
+ // replay. So we must NOT rewrite `previousResponseId` to null
2276
+ // to force a cold-replay lookup — the tier-1 lease is exactly
2277
+ // what makes warm reuse work. (Pre-Round 5 this branch passed
2278
+ // `null` to force a miss, but that was wrong: it threw away a
2279
+ // usable warm lease AND mislabeled the turn as `cold_replay`
2280
+ // when the native prefix verifier was about to reuse the
2281
+ // entire previous-turn prefix.) The hot-path-ineligibility
2282
+ // condition (single non-user/non-tool continuation) is no
2283
+ // longer consulted at lookup time — it's just the natural
2284
+ // fall-through to the `session.turns === 0 || multi-message`
2285
+ // cold-re-prime branch in `runSession*`, which correctly
2286
+ // preserves the warm native cache on HIT via
2287
+ // `resetPreservingNativeCacheForWarmReuse(session)`.
2288
+ // Normalize the caller-supplied `prompt_cache_key` into the
2289
+ // `string | null` shape the registry expects. `undefined` and
2290
+ // missing both map to `null`; an explicit empty string is
2291
+ // preserved distinct from `null` so the registry's tier-2
2292
+ // scan treats "no key" and "empty key" as different tenants
2293
+ // (prevents an unkeyed client from accidentally colliding
2294
+ // with one that explicitly empty-keyed).
2295
+ const promptCacheKey = typeof body.prompt_cache_key === 'string' ? body.prompt_cache_key : null;
2296
+ // Precedence gate: when `previous_response_id` is present, tier-2
2297
+ // (prompt-cache-key) lookup is DISABLED — even if the request is
2298
+ // hot-path ineligible (single `assistant` / `system` continuation).
2299
+ // The documented precedence is "prev-id wins; tier-1 miss falls
2300
+ // through to FRESH, not tier-2", and that rule has to hold whether
2301
+ // we take the hot path or the ineligible cold-replay branch. If we
2302
+ // let the ineligible branch fall through to tier-2, a mis-routed
2303
+ // prev-id request could lease an UNRELATED warm session that
2304
+ // happens to share `prompt_cache_key`, then cold-replay on top of
2305
+ // it — which `session.reset()` + `primeHistory()` would destroy,
2306
+ // corrupting an unrelated chain. Force `null` for the cache key on
2307
+ // both branches whenever a prev-id is set; tier-2 only runs for
2308
+ // requests with no prev-id at all.
2309
+ const effectivePromptCacheKey = previousResponseId != null ? null : promptCacheKey;
2310
+ // Integrator nudge: if the caller supplied a non-empty
2311
+ // `prompt_cache_key` but tier-2 prerequisites are missing
2312
+ // (env gate off or key below the min-length floor) the turn
2313
+ // silently cold-starts. Emit a once-per-distinct-raw-key
2314
+ // stderr warning so `X-Session-Cache: fresh` on every request
2315
+ // can be diagnosed without reading source. Gated on
2316
+ // `effectivePromptCacheKey` (not raw `promptCacheKey`) so a
2317
+ // request that suppresses the key via `previous_response_id`
2318
+ // precedence does not also log a misleading "key ignored"
2319
+ // message — that case is documented precedence, not a
2320
+ // misconfiguration.
2321
+ if (effectivePromptCacheKey !== null) {
2322
+ maybeWarnPromptCacheKeyIneligible(effectivePromptCacheKey);
2323
+ }
2324
+ // H2 pre-dispatch disconnect check (non-streaming only). A
2325
+ // client that vanished while this request was parked behind the
2326
+ // per-model mutex must not burn a whole prefill+decode budget
2327
+ // producing a JSON body nobody can receive. Checked BEFORE the
2328
+ // session lease so no warm entry is consumed and no native
2329
+ // state is touched — the early return composes with the permit
2330
+ // lifecycle exactly like the binding-changed return above (the
2331
+ // pre-dispatch permit was already consumed atomically by
2332
+ // `withExclusive`, and the handler's outer `finally` release is
2333
+ // an idempotent no-op after that handoff). Streaming keeps its
2334
+ // existing paths: the signal fast-aborts `_runChatStream` and
2335
+ // the SSE drain loop breaks on `clientAborted` at loop-top.
2336
+ if (mappedBody.stream !== true && abortController.signal.aborted) {
2337
+ return;
2338
+ }
2339
+ // Multi-tool-call fan-out gate.
2340
+ //
2341
+ // The chat-session API cannot interleave tool results for a
2342
+ // multi-call fan-out turn (each `sendToolResult` dispatch re-opens
2343
+ // the assistant turn, so responding to the siblings would weave new
2344
+ // assistant replies between the results — see
2345
+ // `ChatSession.pendingUnresolvedToolCallCount`). The only valid forward
2346
+ // progress from such a turn is an atomic replay that resolves every
2347
+ // sibling call in one cold-restart, so we reject any continuation
2348
+ // whose submitted `function_call_output` set does not exactly match
2349
+ // the outstanding call ids.
2350
+ //
2351
+ // The gate only runs for `previous_response_id` continuations, where
2352
+ // the STORED prior chain (`priorMessages`, reconstructed via
2353
+ // `reconstructMessagesFromChain`) is the authoritative view of the
2354
+ // trailing assistant turn and `newInputMessages` contains only the
2355
+ // caller's continuation delta. Stateless requests (no
2356
+ // `previous_response_id`) carry a full self-contained history in
2357
+ // `input`, and historical tool outputs for prior resolved turns
2358
+ // would otherwise be misclassified against the latest assistant's
2359
+ // outstanding id set — leave cold-start histories to the jinja
2360
+ // template / chat-session prefill to handle as-is.
2361
+ const expectedOutstandingIds = priorMessages ? extractOutstandingToolCallIds(priorMessages) : null;
2362
+ // Forged-tool-output guard. A `previous_response_id` continuation that
2363
+ // submits any `function_call_output` when the stored prior chain has
2364
+ // ZERO outstanding tool calls is structurally invalid: there is no
2365
+ // assistant tool call for the result to resolve, so dispatching it
2366
+ // would inject a synthetic `<tool_response>` delta into a thread the
2367
+ // model never asked to call. Native backends do not authenticate
2368
+ // `tool_call_id` against prior state — several just append the
2369
+ // delta verbatim — so the gate must live here. Stateless requests
2370
+ // (no `previous_response_id`) carry a full self-contained history
2371
+ // and are left to the jinja template / chat-session prefill.
2372
+ if (previousResponseId && expectedOutstandingIds === null) {
2373
+ for (const m of newInputMessages) {
2374
+ if (m.role === 'tool') {
2375
+ sendBadRequest(res, `function_call_output submitted against a thread with no outstanding tool call. ` +
2376
+ `The prior assistant turn either never emitted a tool call or every sibling call has ` +
2377
+ `already been resolved, so there is nothing for this function_call_output to answer. ` +
2378
+ `Dispatching it anyway would synthesize a tool-response delta for a call the model ` +
2379
+ `never made and corrupt the conversation structure. Drop the function_call_output, ` +
2380
+ `or start a new chain without previous_response_id.`, 'input');
2360
2381
  return;
2361
2382
  }
2362
2383
  }
2363
- else {
2364
- seenNonTool = true;
2365
- }
2366
2384
  }
2367
- const submittedIds = [];
2368
- for (const m of newInputMessages) {
2369
- if (m.role === 'tool' && typeof m.toolCallId === 'string' && m.toolCallId.length > 0) {
2370
- submittedIds.push(m.toolCallId);
2385
+ if (expectedOutstandingIds !== null) {
2386
+ // Contiguous-prefix guard: function_call_output items must appear
2387
+ // as an unbroken prefix of the continuation delta, before any
2388
+ // user/assistant/system message. A shape like
2389
+ // `[tool(call_a), user(hi), tool(call_b)]` would otherwise pass
2390
+ // every id-set check below (both outstanding ids present, no
2391
+ // duplicates, no stale ids) while still orphaning the fan-out,
2392
+ // because the interleaved user turn re-opens the assistant turn
2393
+ // between the two tool results. Reject early so the caller cannot
2394
+ // smuggle a user turn into the middle of a resolved fan-out.
2395
+ let seenNonTool = false;
2396
+ for (const m of newInputMessages) {
2397
+ if (m.role === 'tool') {
2398
+ if (seenNonTool) {
2399
+ sendBadRequest(res, `function_call_output items must appear as a contiguous prefix of the continuation ` +
2400
+ `before any user, assistant, or system message. Interleaving a non-tool message ` +
2401
+ `between sibling function_call_output items orphans the fan-out by weaving a new ` +
2402
+ `assistant turn between the tool results. Reorder the submission so every ` +
2403
+ `function_call_output precedes any subsequent message, or start a new chain ` +
2404
+ `without previous_response_id.`, 'input');
2405
+ return;
2406
+ }
2407
+ }
2408
+ else {
2409
+ seenNonTool = true;
2410
+ }
2371
2411
  }
2372
- }
2373
- // Short-circuit: a plain user continuation (zero tool results)
2374
- // would orphan the outstanding call(s) just as surely as a
2375
- // partial tool-result submission. Reject both paths with the
2376
- // same 400.
2377
- const plural = expectedOutstandingIds.length > 1;
2378
- if (submittedIds.length === 0) {
2379
- sendBadRequest(res, `Previous assistant turn has ${expectedOutstandingIds.length} unresolved tool call${plural ? 's' : ''} ` +
2380
- `(${expectedOutstandingIds.join(', ')}); the chat-session API requires every outstanding ` +
2381
- `function_call_output to be submitted before the thread can advance. A plain user turn ` +
2382
- `would orphan the unresolved call${plural ? 's' : ''}. Submit function_call_output items for ` +
2383
- `every outstanding id, or start a new chain without previous_response_id.`, 'input');
2384
- return;
2385
- }
2386
- const expectedSet = new Set(expectedOutstandingIds);
2387
- const seen = new Set();
2388
- for (const id of submittedIds) {
2389
- if (seen.has(id)) {
2390
- sendBadRequest(res, `Duplicate function_call_output call_id "${id}" — each outstanding tool call must be answered exactly once.`, 'input');
2412
+ const submittedIds = [];
2413
+ for (const m of newInputMessages) {
2414
+ if (m.role === 'tool' && typeof m.toolCallId === 'string' && m.toolCallId.length > 0) {
2415
+ submittedIds.push(m.toolCallId);
2416
+ }
2417
+ }
2418
+ // Short-circuit: a plain user continuation (zero tool results)
2419
+ // would orphan the outstanding call(s) just as surely as a
2420
+ // partial tool-result submission. Reject both paths with the
2421
+ // same 400.
2422
+ const plural = expectedOutstandingIds.length > 1;
2423
+ if (submittedIds.length === 0) {
2424
+ sendBadRequest(res, `Previous assistant turn has ${expectedOutstandingIds.length} unresolved tool call${plural ? 's' : ''} ` +
2425
+ `(${expectedOutstandingIds.join(', ')}); the chat-session API requires every outstanding ` +
2426
+ `function_call_output to be submitted before the thread can advance. A plain user turn ` +
2427
+ `would orphan the unresolved call${plural ? 's' : ''}. Submit function_call_output items for ` +
2428
+ `every outstanding id, or start a new chain without previous_response_id.`, 'input');
2391
2429
  return;
2392
2430
  }
2393
- seen.add(id);
2394
- if (!expectedSet.has(id)) {
2395
- sendBadRequest(res, `Unexpected function_call_output call_id "${id}"; the outstanding multi-tool-call set is ` +
2396
- `${expectedOutstandingIds.join(', ')}. Submitting an unrelated or stale call_id would advance ` +
2397
- `the chain past an unresolved turn.`, 'input');
2431
+ const expectedSet = new Set(expectedOutstandingIds);
2432
+ const seen = new Set();
2433
+ for (const id of submittedIds) {
2434
+ if (seen.has(id)) {
2435
+ sendBadRequest(res, `Duplicate function_call_output call_id "${id}" each outstanding tool call must be answered exactly once.`, 'input');
2436
+ return;
2437
+ }
2438
+ seen.add(id);
2439
+ if (!expectedSet.has(id)) {
2440
+ sendBadRequest(res, `Unexpected function_call_output call_id "${id}"; the outstanding multi-tool-call set is ` +
2441
+ `${expectedOutstandingIds.join(', ')}. Submitting an unrelated or stale call_id would advance ` +
2442
+ `the chain past an unresolved turn.`, 'input');
2443
+ return;
2444
+ }
2445
+ }
2446
+ if (seen.size !== expectedSet.size) {
2447
+ const missing = [];
2448
+ for (const id of expectedOutstandingIds) {
2449
+ if (!seen.has(id))
2450
+ missing.push(id);
2451
+ }
2452
+ sendBadRequest(res, `Missing function_call_output items for outstanding tool calls: ${missing.join(', ')}. ` +
2453
+ `Partial submissions would orphan the sibling tool calls and advance the chain past an ` +
2454
+ `unresolved turn. Resubmit with every sibling output, or start a new chain without ` +
2455
+ `previous_response_id.`, 'input');
2398
2456
  return;
2399
2457
  }
2400
- }
2401
- if (seen.size !== expectedSet.size) {
2402
- const missing = [];
2403
- for (const id of expectedOutstandingIds) {
2404
- if (!seen.has(id))
2405
- missing.push(id);
2458
+ // All outstanding ids are accounted for. Canonicalize the submitted
2459
+ // tool-message order to the stored sibling order before the replay
2460
+ // runs both `messages` (primed into the fresh session on the cold
2461
+ // path) and `newInputMessages` (persisted verbatim into the store
2462
+ // for future chain reconstruction) must reflect the canonical
2463
+ // order, otherwise a caller can swap outputs and silently poison
2464
+ // replay even after the id-set gate passes.
2465
+ //
2466
+ // Compute the tool block's end as the contiguous-prefix run of
2467
+ // `role === 'tool'` messages starting at `priorOffset`. The
2468
+ // contiguous-prefix guard above already rejected any shape that
2469
+ // interleaves a non-tool message inside the delta's tool block,
2470
+ // so this simple forward scan matches the exact block the gate
2471
+ // just authenticated. Passing an explicit `blockEnd` keeps the
2472
+ // helper from accidentally walking into any later turn that
2473
+ // `mapRequest` may have appended to `messages`.
2474
+ let deltaBlockEnd = priorOffset;
2475
+ while (deltaBlockEnd < messages.length && messages[deltaBlockEnd].role === 'tool') {
2476
+ deltaBlockEnd++;
2406
2477
  }
2407
- sendBadRequest(res, `Missing function_call_output items for outstanding tool calls: ${missing.join(', ')}. ` +
2408
- `Partial submissions would orphan the sibling tool calls and advance the chain past an ` +
2409
- `unresolved turn. Resubmit with every sibling output, or start a new chain without ` +
2410
- `previous_response_id.`, 'input');
2411
- return;
2478
+ canonicalizeToolMessageOrder(messages, priorOffset, deltaBlockEnd, expectedOutstandingIds);
2479
+ newInputMessages = messages.slice(priorOffset);
2412
2480
  }
2413
- // All outstanding ids are accounted for. Canonicalize the submitted
2414
- // tool-message order to the stored sibling order before the replay
2415
- // runs — both `messages` (primed into the fresh session on the cold
2416
- // path) and `newInputMessages` (persisted verbatim into the store
2417
- // for future chain reconstruction) must reflect the canonical
2418
- // order, otherwise a caller can swap outputs and silently poison
2419
- // replay even after the id-set gate passes.
2481
+ // Walk the full merged history and canonicalize every assistant
2482
+ // fan-out's trailing tool block against its declared sibling order.
2420
2483
  //
2421
- // Compute the tool block's end as the contiguous-prefix run of
2422
- // `role === 'tool'` messages starting at `priorOffset`. The
2423
- // contiguous-prefix guard above already rejected any shape that
2424
- // interleaves a non-tool message inside the delta's tool block,
2425
- // so this simple forward scan matches the exact block the gate
2426
- // just authenticated. Passing an explicit `blockEnd` keeps the
2427
- // helper from accidentally walking into any later turn that
2428
- // `mapRequest` may have appended to `messages`.
2429
- let deltaBlockEnd = priorOffset;
2430
- while (deltaBlockEnd < messages.length && messages[deltaBlockEnd].role === 'tool') {
2431
- deltaBlockEnd++;
2432
- }
2433
- canonicalizeToolMessageOrder(messages, priorOffset, deltaBlockEnd, expectedOutstandingIds);
2434
- newInputMessages = messages.slice(priorOffset);
2435
- }
2436
- // Walk the full merged history and canonicalize every assistant
2437
- // fan-out's trailing tool block against its declared sibling order.
2438
- //
2439
- // The multi-tool-call gate above only fires on `previous_response_id`
2440
- // continuations, and even there it only handles the caller's delta
2441
- // block against the STORED prior chain's trailing assistant. That
2442
- // leaves two cases uncovered:
2443
- //
2444
- // 1. Stateless cold-start histories (no `previous_response_id`).
2445
- // The caller ships a full self-contained conversation through
2446
- // `input`; the gate is skipped entirely and the caller-supplied
2447
- // tool-message order flows straight into `primeHistory()`. A
2448
- // caller can reverse two sibling tool outputs, and since
2449
- // several native session backends pair tool results to
2450
- // fan-out calls POSITIONALLY (not by id), each result binds
2451
- // to the wrong sibling call.
2452
- // 2. Earlier fan-outs embedded inside the stored prior history
2453
- // on a continuation. Those came from the server's own store
2454
- // so they should already be canonical, but defense in depth
2455
- // is cheap — a single full-history walk covers every shape.
2456
- //
2457
- // Malformed histories (missing/duplicate/unknown ids, orphan tool
2458
- // messages, unresolved trailing fan-out in a stateless request)
2459
- // are rejected with a clear 400 instead of silently rewritten.
2460
- const historyError = validateAndCanonicalizeHistoryToolOrder(messages);
2461
- if (historyError !== null) {
2462
- sendBadRequest(res, historyError, 'input');
2463
- return;
2464
- }
2465
- // Canonicalization may have reordered tool messages inside the
2466
- // continuation delta (on the stateless-history walk over the
2467
- // post-priorOffset portion), so recompute `newInputMessages` from
2468
- // the now-canonical `messages`.
2469
- newInputMessages = messages.slice(priorOffset);
2470
- // Visibility / wire-format tracker shared between the handler
2471
- // body and the outer catch. Declared outside the `try` so the
2472
- // catch can branch on `responseMode` (JSON vs SSE) and know
2473
- // whether a terminal artefact already landed — both signals
2474
- // are authoritative, unlike `res.headersSent`.
2475
- const visibility = createVisibility();
2476
- try {
2477
- // `runSession*` plumbs an honest commit signal out of the helper:
2478
- // `ChatSession` only advances `turns` on a successful non-error
2479
- // final chunk (streaming) or a resolved native promise
2480
- // (non-streaming). The streaming safety-net path (generator
2481
- // exhausts without a `done` event, see `handleStreamingNative`
2482
- // fallback) and the `finishReason === 'error'` final chunk both
2483
- // leave `turns` unchanged. The helper captures its baseline
2484
- // AFTER any internal `session.reset()` on the multi-message
2485
- // reset-and-cold-restart branch, so the signal is honest there
2486
- // too — a pre-helper snapshot would be stale.
2487
- let committed;
2488
- // Pass `mappedBody` (not the raw `body`) so the response
2489
- // object and the persisted record carry the EFFECTIVE
2490
- // instructions, including any value inherited from the
2491
- // trailing stored record via instruction inheritance.
2492
- // Using `body` here
2493
- // would re-drop the inherited value on the wire — the
2494
- // client's response would report `instructions: null` even
2495
- // though the turn was run against the inherited system
2496
- // context, and the next cold replay would have nothing to
2497
- // re-inherit from.
2498
- // Wrap the handler call in its own try/catch so that a
2499
- // post-commit persistence failure does not prevent adopt.
2500
- // Post-commit store failures are caught inside the handlers
2501
- // themselves (handleNonStreaming / handleStreamingNative) and
2502
- // demoted to log-only. A handlerError at this level therefore
2503
- // comes from non-persistence failures (response construction,
2504
- // SSE write, res.writeHead/end crash).
2484
+ // The multi-tool-call gate above only fires on `previous_response_id`
2485
+ // continuations, and even there it only handles the caller's delta
2486
+ // block against the STORED prior chain's trailing assistant. That
2487
+ // leaves two cases uncovered:
2505
2488
  //
2506
- // `res.headersSent` is NOT a reliable proxy for "the client
2507
- // received the response": Node's `writeHead` flips
2508
- // `headersSent = true` synchronously before any body bytes
2509
- // leave the buffer, and the sync return of `res.end()` /
2510
- // `writeSSEEvent` only proves the bytes were queued — an
2511
- // async socket failure after the queue could still leave
2512
- // the client with no terminal. Picking JSON-vs-SSE fallback
2513
- // from `res.headersSent` is also unsafe because a
2514
- // `writeHead(200, 'application/json')` `res.end()` crash
2515
- // would otherwise emit SSE frames into a JSON-declared
2516
- // response.
2489
+ // 1. Stateless cold-start histories (no `previous_response_id`).
2490
+ // The caller ships a full self-contained conversation through
2491
+ // `input`; the gate is skipped entirely and the caller-supplied
2492
+ // tool-message order flows straight into `primeHistory()`. A
2493
+ // caller can reverse two sibling tool outputs, and since
2494
+ // several native session backends pair tool results to
2495
+ // fan-out calls POSITIONALLY (not by id), each result binds
2496
+ // to the wrong sibling call.
2497
+ // 2. Earlier fan-outs embedded inside the stored prior history
2498
+ // on a continuation. Those came from the server's own store
2499
+ // so they should already be canonical, but defense in depth
2500
+ // is cheap — a single full-history walk covers every shape.
2517
2501
  //
2518
- // The `TransportVisibility` record instead tracks both the
2519
- // wire format the handler committed to (`responseMode`)
2520
- // AND whether the client observed a terminal artefact
2521
- // (`responseBodyWritten` / `terminalEmitted`). Both flags
2522
- // are flipped only from the kernel-ack callback of the
2523
- // underlying `res.end` / `res.write` — synchronous return
2524
- // is NOT treated as proof of visibility. The outer catch
2525
- // branches on `responseMode` to choose the clean-up shape
2526
- // (JSON error, SSE `error` frame, or socket destroy).
2527
- let handlerError = null;
2528
- if (mappedBody.stream) {
2529
- const outcome = await runSessionStreaming(session, messages, newInputMessages, config, streamSignal, !lookup.hit);
2530
- const streamingWasCommitted = () => outcome.wasCommitted();
2502
+ // Malformed histories (missing/duplicate/unknown ids, orphan tool
2503
+ // messages, unresolved trailing fan-out in a stateless request)
2504
+ // are rejected with a clear 400 instead of silently rewritten.
2505
+ const historyError = validateAndCanonicalizeHistoryToolOrder(messages);
2506
+ if (historyError !== null) {
2507
+ sendBadRequest(res, historyError, 'input');
2508
+ return;
2509
+ }
2510
+ // Canonicalization may have reordered tool messages inside the
2511
+ // continuation delta (on the stateless-history walk over the
2512
+ // post-priorOffset portion), so recompute `newInputMessages` from
2513
+ // the now-canonical `messages`.
2514
+ newInputMessages = messages.slice(priorOffset);
2515
+ // Lease only after every request-shape validation has passed. Once a
2516
+ // session is removed from the warm slot, the try/finally below owns
2517
+ // it until a clean turn adopts it again.
2518
+ const pagedActive = leaseModel.hasBlockPagedCache?.() === true;
2519
+ const lookup = sessionReg.getOrCreate(previousResponseId ?? null, requestedInstructions, effectivePromptCacheKey, config.cacheSalt ?? null);
2520
+ const session = lookup.session;
2521
+ await sessionReg.flushPendingDisposals();
2522
+ let sessionRetained = false;
2523
+ let sessionCleanupStarted = false;
2524
+ const disposeUnretainedSession = async () => {
2525
+ if (sessionRetained || sessionCleanupStarted)
2526
+ return;
2527
+ sessionCleanupStarted = true;
2531
2528
  try {
2532
- const handlerOutcome = await handleStreamingNative(res, outcome.stream, mappedBody, responseId, previousResponseId, streamingWasCommitted, httpReq, visibility, serverTiming);
2533
- streamFailureMode = handlerOutcome.failureMode;
2534
- if (handlerOutcome.terminalToPersist != null && store && body.store !== false) {
2535
- // Initiate the write SYNCHRONOUSLY inside the mutex so
2536
- // the pending-write tracker observes it before the
2537
- // mutex releases. The promise is awaited off-lock in
2538
- // the outer finally block.
2539
- const record = buildResponseRecord(handlerOutcome.terminalToPersist, newInputMessages, previousResponseId, currentInstanceId, effectiveRetentionSec);
2540
- // Pair a `retainBinding` against the persist promise
2541
- // so the binding's `modelInstanceId` survives a
2542
- // concurrent same-model unregister + re-register that
2543
- // races the post-commit write. `releaseBinding` runs
2544
- // in the persist's `.finally(...)` regardless of
2545
- // outcome, so the retention counter stays balanced
2546
- // whether the write fulfils or rejects.
2547
- //
2548
- // Leaving the retain pinned forever on a wedged write
2549
- // would make the binding unreclaimable until process
2550
- // restart, so an INDEPENDENT hard-timeout timer is
2551
- // armed alongside the persist (see
2552
- // `getPostCommitPersistHardTimeoutMs` for the default).
2553
- // If the persist settles naturally the timer is
2554
- // cancelled via `clearTimeout` inside the same
2555
- // `.finally(...)` — slow-but-eventual writes are
2556
- // unaffected. If the persist is still wedged past the
2557
- // hard bound, the timer fires and force-releases the
2558
- // retain via the idempotent `persistRetainBox`. The
2559
- // hard timer is armed off the handler's await path, so
2560
- // the response is never delayed by it.
2561
- //
2562
- // Before the hard timeout force-releases the retain
2563
- // (which unblocks binding teardown), it calls
2564
- // `registry.retireInstanceIdForForceRelease(leaseModel)`
2565
- // to tombstone the binding's current instance id on
2566
- // the model object. A subsequent `register()` of the
2567
- // SAME model object inherits that retired id rather
2568
- // than minting fresh so the late-landing persist's
2569
- // record (stamped with the retired id) still matches
2570
- // the live binding and stays chainable through
2571
- // `previous_response_id`. Only a true hot-swap
2572
- // (re-register with a DIFFERENT model object) mints a
2573
- // fresh id, and the 400 instance-mismatch that results
2574
- // is the correct semantic outcome because the new
2575
- // model is semantically different from the one that
2576
- // produced the stored record. Retirement MUST happen
2577
- // BEFORE release so `instanceIds.get(model)` still
2578
- // returns the live id the record carries.
2579
- //
2580
- // The tombstone's lifetime is scoped to the pending
2581
- // persists that installed it — the `.finally(...)`
2582
- // calls `registry.releaseTombstone(leaseModel)` so
2583
- // that when the late write eventually settles
2584
- // (fulfills or rejects), the shared refcount drops
2585
- // and, once every outstanding persist has released,
2586
- // any subsequent re-registration correctly mints a
2587
- // fresh id. Without this scoping, a past hard-timeout
2588
- // event would permanently re-enable id inheritance
2589
- // across unrelated later lifecycles reopening
2590
- // stale-chain replay across what should be logically
2591
- // dead bindings. The refcounted single-entry layout
2592
- // handles OVERLAPPING hard-timeouts on the same live
2593
- // instance id in bounded space: every breaker targets
2594
- // the SAME retired id (the register-inherit path
2595
- // keeps using it while the tombstone is alive) so one
2596
- // shared refcount safely collapses every in-flight
2597
- // retire, and memory stays O(1) per model even under
2598
- // a truly wedged store that never settles.
2599
- registry.retainBinding(leaseModel);
2600
- let persistRetainReleased = false;
2601
- persistRetainBox.release = () => {
2602
- if (persistRetainReleased)
2603
- return;
2604
- persistRetainReleased = true;
2605
- registry.releaseBinding(leaseModel);
2606
- };
2607
- const streamingPersistMode = 'streaming';
2608
- const streamingHardTimeoutMs = getPostCommitPersistHardTimeoutMs();
2609
- let retiredTombstone;
2610
- // Compute the scalar `absoluteExpiresAtMs` ONCE up
2611
- // front — the MINIMUM of the newly produced record's
2612
- // own row expiry and the earliest expiry across any
2613
- // resolved ancestor chain. This value is threaded
2614
- // into both the pending-write tracker at
2615
- // `initiatePersist()` time (so the pre-breaker
2616
- // `awaitPending` path can short-circuit to 404 once
2617
- // the bound is crossed) AND the hard-timeout marker
2618
- // at breaker-fire time (absolute cap). The
2619
- // hard-timeout closure captures ONLY this scalar
2620
- // NOT the full resolved chain so the closure's
2621
- // retained heap stays O(1) under sustained pending
2622
- // continuations against a degraded backend.
2623
- //
2624
- // `record.expiresAt` is epoch-seconds (see
2625
- // `buildResponseRecord` it adds
2626
- // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now() /
2627
- // 1000)`); convert to ms at this boundary. If both
2628
- // the record and the chain lack a finite expiry
2629
- // (legacy rows), fall back to
2630
- // `Number.POSITIVE_INFINITY` at the marker call site
2631
- // so TTL-only bounding still holds.
2632
- const recordExpiresAtMs = record.expiresAt != null && Number.isFinite(record.expiresAt) ? record.expiresAt * 1000 : undefined;
2633
- const absoluteExpiresAtMs = recordExpiresAtMs !== undefined && chainEarliestExpiresAtMs !== undefined
2634
- ? Math.min(recordExpiresAtMs, chainEarliestExpiresAtMs)
2635
- : (recordExpiresAtMs ?? chainEarliestExpiresAtMs);
2636
- const streamingHardTimeoutHandle = streamingHardTimeoutMs > 0
2637
- ? setTimeout(() => {
2529
+ await sessionReg.disposeSession(session);
2530
+ }
2531
+ catch (error) {
2532
+ console.error('[responses] failed to release an unretained chat-session cache owner:', error);
2533
+ }
2534
+ };
2535
+ // Visibility / wire-format tracker shared between the handler
2536
+ // body and the outer catch. Declared outside the `try` so the
2537
+ // catch can branch on `responseMode` (JSON vs SSE) and know
2538
+ // whether a terminal artefact already landed — both signals
2539
+ // are authoritative, unlike `res.headersSent`.
2540
+ const visibility = createVisibility();
2541
+ try {
2542
+ const tier2Hit = previousResponseId == null && lookup.hit;
2543
+ const isStreaming = mappedBody.stream === true;
2544
+ // Streaming headers are conservative because native cached-token
2545
+ // evidence is not available until after SSE headers have flushed.
2546
+ let sessionCacheStatus = previousResponseId == null
2547
+ ? tier2Hit && !isStreaming
2548
+ ? 'prefix_hit'
2549
+ : 'fresh'
2550
+ : lookup.hit
2551
+ ? 'hit'
2552
+ : 'cold_replay';
2553
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
2554
+ // `runSession*` plumbs an honest commit signal out of the helper:
2555
+ // `ChatSession` only advances `turns` on a successful non-error
2556
+ // final chunk (streaming) or a resolved native promise
2557
+ // (non-streaming). The streaming safety-net path (generator
2558
+ // exhausts without a `done` event, see `handleStreamingNative`
2559
+ // fallback) and the `finishReason === 'error'` final chunk both
2560
+ // leave `turns` unchanged. The helper captures its baseline
2561
+ // AFTER any internal `session.reset()` on the multi-message
2562
+ // reset-and-cold-restart branch, so the signal is honest there
2563
+ // too a pre-helper snapshot would be stale.
2564
+ let committed;
2565
+ // Pass `mappedBody` (not the raw `body`) so the response
2566
+ // object and the persisted record carry the EFFECTIVE
2567
+ // instructions, including any value inherited from the
2568
+ // trailing stored record via instruction inheritance.
2569
+ // Using `body` here
2570
+ // would re-drop the inherited value on the wire the
2571
+ // client's response would report `instructions: null` even
2572
+ // though the turn was run against the inherited system
2573
+ // context, and the next cold replay would have nothing to
2574
+ // re-inherit from.
2575
+ // Wrap the handler call in its own try/catch so that a
2576
+ // post-commit persistence failure does not prevent adopt.
2577
+ // Post-commit store failures are caught inside the handlers
2578
+ // themselves (handleNonStreaming / handleStreamingNative) and
2579
+ // demoted to log-only. A handlerError at this level therefore
2580
+ // comes from non-persistence failures (response construction,
2581
+ // SSE write, res.writeHead/end crash).
2582
+ //
2583
+ // `res.headersSent` is NOT a reliable proxy for "the client
2584
+ // received the response": Node's `writeHead` flips
2585
+ // `headersSent = true` synchronously before any body bytes
2586
+ // leave the buffer, and the sync return of `res.end()` /
2587
+ // `writeSSEEvent` only proves the bytes were queued — an
2588
+ // async socket failure after the queue could still leave
2589
+ // the client with no terminal. Picking JSON-vs-SSE fallback
2590
+ // from `res.headersSent` is also unsafe because a
2591
+ // `writeHead(200, 'application/json')` `res.end()` crash
2592
+ // would otherwise emit SSE frames into a JSON-declared
2593
+ // response.
2594
+ //
2595
+ // The `TransportVisibility` record instead tracks both the
2596
+ // wire format the handler committed to (`responseMode`)
2597
+ // AND whether the client observed a terminal artefact
2598
+ // (`responseBodyWritten` / `terminalEmitted`). Both flags
2599
+ // are flipped only from the kernel-ack callback of the
2600
+ // underlying `res.end` / `res.write` — synchronous return
2601
+ // is NOT treated as proof of visibility. The outer catch
2602
+ // branches on `responseMode` to choose the clean-up shape
2603
+ // (JSON error, SSE `error` frame, or socket destroy).
2604
+ let handlerError = null;
2605
+ if (mappedBody.stream) {
2606
+ const outcome = await runSessionStreaming(session, messages, newInputMessages, config, streamSignal, !lookup.hit && !pagedActive);
2607
+ const streamingWasCommitted = () => outcome.wasCommitted();
2608
+ try {
2609
+ const handlerOutcome = await handleStreamingNative(res, outcome.stream, mappedBody, responseId, previousResponseId, streamingWasCommitted, httpReq, visibility, serverTiming);
2610
+ streamFailureMode = handlerOutcome.failureMode;
2611
+ if (handlerOutcome.terminalToPersist != null && store && body.store !== false) {
2612
+ // Initiate the write SYNCHRONOUSLY inside the mutex so
2613
+ // the pending-write tracker observes it before the
2614
+ // mutex releases. The promise is awaited off-lock in
2615
+ // the outer finally block.
2616
+ const record = buildResponseRecord(handlerOutcome.terminalToPersist, newInputMessages, previousResponseId, currentInstanceId, effectiveRetentionSec);
2617
+ // Pair a `retainBinding` against the persist promise
2618
+ // so the binding's `modelInstanceId` survives a
2619
+ // concurrent same-model unregister + re-register that
2620
+ // races the post-commit write. `releaseBinding` runs
2621
+ // in the persist's `.finally(...)` regardless of
2622
+ // outcome, so the retention counter stays balanced
2623
+ // whether the write fulfils or rejects.
2624
+ //
2625
+ // Leaving the retain pinned forever on a wedged write
2626
+ // would make the binding unreclaimable until process
2627
+ // restart, so an INDEPENDENT hard-timeout timer is
2628
+ // armed alongside the persist (see
2629
+ // `getPostCommitPersistHardTimeoutMs` for the default).
2630
+ // If the persist settles naturally the timer is
2631
+ // cancelled via `clearTimeout` inside the same
2632
+ // `.finally(...)` slow-but-eventual writes are
2633
+ // unaffected. If the persist is still wedged past the
2634
+ // hard bound, the timer fires and force-releases the
2635
+ // retain via the idempotent `persistRetainBox`. The
2636
+ // hard timer is armed off the handler's await path, so
2637
+ // the response is never delayed by it.
2638
+ //
2639
+ // Before the hard timeout force-releases the retain
2640
+ // (which unblocks binding teardown), it calls
2641
+ // `registry.retireInstanceIdForForceRelease(leaseModel)`
2642
+ // to tombstone the binding's current instance id on
2643
+ // the model object. A subsequent `register()` of the
2644
+ // SAME model object inherits that retired id rather
2645
+ // than minting fresh — so the late-landing persist's
2646
+ // record (stamped with the retired id) still matches
2647
+ // the live binding and stays chainable through
2648
+ // `previous_response_id`. Only a true hot-swap
2649
+ // (re-register with a DIFFERENT model object) mints a
2650
+ // fresh id, and the 400 instance-mismatch that results
2651
+ // is the correct semantic outcome because the new
2652
+ // model is semantically different from the one that
2653
+ // produced the stored record. Retirement MUST happen
2654
+ // BEFORE release so `instanceIds.get(model)` still
2655
+ // returns the live id the record carries.
2656
+ //
2657
+ // The tombstone's lifetime is scoped to the pending
2658
+ // persists that installed it — the `.finally(...)`
2659
+ // calls `registry.releaseTombstone(leaseModel)` so
2660
+ // that when the late write eventually settles
2661
+ // (fulfills or rejects), the shared refcount drops
2662
+ // and, once every outstanding persist has released,
2663
+ // any subsequent re-registration correctly mints a
2664
+ // fresh id. Without this scoping, a past hard-timeout
2665
+ // event would permanently re-enable id inheritance
2666
+ // across unrelated later lifecycles — reopening
2667
+ // stale-chain replay across what should be logically
2668
+ // dead bindings. The refcounted single-entry layout
2669
+ // handles OVERLAPPING hard-timeouts on the same live
2670
+ // instance id in bounded space: every breaker targets
2671
+ // the SAME retired id (the register-inherit path
2672
+ // keeps using it while the tombstone is alive) so one
2673
+ // shared refcount safely collapses every in-flight
2674
+ // retire, and memory stays O(1) per model even under
2675
+ // a truly wedged store that never settles.
2676
+ registry.retainBinding(leaseModel);
2677
+ let persistRetainReleased = false;
2678
+ persistRetainBox.release = () => {
2638
2679
  if (persistRetainReleased)
2639
2680
  return;
2640
- console.error(`[responses] post-commit persist HARD timeout (${streamingHardTimeoutMs}ms, ` +
2641
- `${streamingPersistMode}): underlying store.store(...) has not settled; assuming ` +
2642
- `wedged backend, force-releasing the binding retain so the binding can be torn ` +
2643
- `down. Retiring the current instance id via tombstone so a same-object ` +
2644
- `re-registration inherits it and a late-landing persist remains chainable; a ` +
2645
- `hot-swap to a DIFFERENT model object will mint a fresh id and the stale chain ` +
2646
- `will correctly fail with 400 instance-mismatch.`);
2647
- // Move the pending-write tracker entry into
2648
- // the hard-timed-out marker state for this
2649
- // response id. The pending entry is dropped
2650
- // so a wedged store.store(...) does not pin
2651
- // one promise closure + tracker entry per
2652
- // hard-timed-out request, AND the id is added
2653
- // to the `hardTimedOut` marker so a concurrent
2654
- // `previous_response_id` continuation can
2655
- // tell the difference between a permanent
2656
- // 404 and a slow-but-eventual persist that
2657
- // crossed the hard timeout. The continuation
2658
- // path consults `isHardTimedOut(id)` before
2659
- // falling through to `sendNotFound(...)` and
2660
- // returns retryable 503 `storage_timeout`
2661
- // instead, so clients keep retrying rather
2662
- // than discarding the chain. The marker has
2663
- // two cleanup paths: (1) fast the underlying
2664
- // store promise's `.finally(...)` inside
2665
- // `track()` fires when the wedged store
2666
- // unwedges; (2) slow an independent TTL
2667
- // (`MLX_HARD_TIMEOUT_MARKER_TTL_MS`, default
2668
- // 300s) bounds memory at O(requestRate × TTL)
2669
- // even against a truly wedged store that
2670
- // NEVER settles. Marker lifetime =
2671
- // min(settlement, TTL expiry).
2672
- //
2673
- // Pass the record's absolute row expiry as a
2674
- // hard cap on the marker. The record's
2675
- // `expiresAt` field is epoch-seconds (see
2676
- // `buildResponseRecord` it adds
2677
- // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now()
2678
- // / 1000)`), so convert to ms for the marker
2679
- // map. Once the absolute bound passes,
2680
- // `ResponseStore.getChain()` hides the row and
2681
- // the retryable-503 classification is factually
2682
- // wrong the marker must flip to 404 regardless
2683
- // of ongoing client retries.
2684
- //
2685
- // Capture ONLY the precomputed scalar
2686
- // `absoluteExpiresAtMs` in this closure NOT
2687
- // the full resolved chain. The scalar is
2688
- // `min(record.expiresAt * 1000,
2689
- // chainEarliestExpiresAtMs)`, computed once
2690
- // when the hard-timeout handle was armed
2691
- // above. `ResponseStore.getChain()` walks
2692
- // ancestors and aborts on the first expired
2693
- // link (see
2694
- // `crates/mlx-db/src/response_store/reader.rs:44-59`),
2695
- // so clamping the marker at whichever link
2696
- // would disappear from `getChain()` first is
2697
- // the authoritative bound. Capturing only
2698
- // the scalar means background pending
2699
- // continuations under a degraded store do
2700
- // not retain ancestor transcripts —
2701
- // heap growth stays O(1) per hard-timed-out
2702
- // persist regardless of chain length.
2703
- getPendingWritesFor(store).markHardTimedOut(record.id, getHardTimedOutMarkerTtlMs(), absoluteExpiresAtMs ?? Number.POSITIVE_INFINITY);
2704
- // Retire the id FIRST (binding is still alive
2705
- // here retirement reads the live id) then
2706
- // drop the retain, which may trigger the
2707
- // deferred teardown. Capture the retired id so
2708
- // the persist's `.finally(...)` can release
2709
- // the tombstone once the late write eventually
2710
- // settles. The registry stores one refcounted
2711
- // tombstone per model regardless of how many
2712
- // hard-timeouts overlap each retire
2713
- // increments the shared counter and each
2714
- // release decrements it so the returned
2715
- // `{ instanceId }` is captured as a presence
2716
- // flag and `releaseTombstone(leaseModel)` is
2717
- // called in the persist's `.finally(...)`.
2718
- retiredTombstone = registry.retireInstanceIdForForceRelease(leaseModel);
2681
+ persistRetainReleased = true;
2682
+ registry.releaseBinding(leaseModel);
2683
+ };
2684
+ const streamingPersistMode = 'streaming';
2685
+ const streamingHardTimeoutMs = getPostCommitPersistHardTimeoutMs();
2686
+ let retiredTombstone;
2687
+ // Compute the scalar `absoluteExpiresAtMs` ONCE up
2688
+ // front the MINIMUM of the newly produced record's
2689
+ // own row expiry and the earliest expiry across any
2690
+ // resolved ancestor chain. This value is threaded
2691
+ // into both the pending-write tracker at
2692
+ // `initiatePersist()` time (so the pre-breaker
2693
+ // `awaitPending` path can short-circuit to 404 once
2694
+ // the bound is crossed) AND the hard-timeout marker
2695
+ // at breaker-fire time (absolute cap). The
2696
+ // hard-timeout closure captures ONLY this scalar —
2697
+ // NOT the full resolved chain — so the closure's
2698
+ // retained heap stays O(1) under sustained pending
2699
+ // continuations against a degraded backend.
2700
+ //
2701
+ // `record.expiresAt` is epoch-seconds (see
2702
+ // `buildResponseRecord` it adds
2703
+ // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now() /
2704
+ // 1000)`); convert to ms at this boundary. If both
2705
+ // the record and the chain lack a finite expiry
2706
+ // (legacy rows), fall back to
2707
+ // `Number.POSITIVE_INFINITY` at the marker call site
2708
+ // so TTL-only bounding still holds.
2709
+ const recordExpiresAtMs = record.expiresAt != null && Number.isFinite(record.expiresAt) ? record.expiresAt * 1000 : undefined;
2710
+ const absoluteExpiresAtMs = recordExpiresAtMs !== undefined && chainEarliestExpiresAtMs !== undefined
2711
+ ? Math.min(recordExpiresAtMs, chainEarliestExpiresAtMs)
2712
+ : (recordExpiresAtMs ?? chainEarliestExpiresAtMs);
2713
+ const streamingHardTimeoutHandle = streamingHardTimeoutMs > 0
2714
+ ? setTimeout(() => {
2715
+ if (persistRetainReleased)
2716
+ return;
2717
+ console.error(`[responses] post-commit persist HARD timeout (${streamingHardTimeoutMs}ms, ` +
2718
+ `${streamingPersistMode}): underlying store.store(...) has not settled; assuming ` +
2719
+ `wedged backend, force-releasing the binding retain so the binding can be torn ` +
2720
+ `down. Retiring the current instance id via tombstone so a same-object ` +
2721
+ `re-registration inherits it and a late-landing persist remains chainable; a ` +
2722
+ `hot-swap to a DIFFERENT model object will mint a fresh id and the stale chain ` +
2723
+ `will correctly fail with 400 instance-mismatch.`);
2724
+ // Move the pending-write tracker entry into
2725
+ // the hard-timed-out marker state for this
2726
+ // response id. The pending entry is dropped
2727
+ // so a wedged store.store(...) does not pin
2728
+ // one promise closure + tracker entry per
2729
+ // hard-timed-out request, AND the id is added
2730
+ // to the `hardTimedOut` marker so a concurrent
2731
+ // `previous_response_id` continuation can
2732
+ // tell the difference between a permanent
2733
+ // 404 and a slow-but-eventual persist that
2734
+ // crossed the hard timeout. The continuation
2735
+ // path consults `isHardTimedOut(id)` before
2736
+ // falling through to `sendNotFound(...)` and
2737
+ // returns retryable 503 `storage_timeout`
2738
+ // instead, so clients keep retrying rather
2739
+ // than discarding the chain. The marker has
2740
+ // two cleanup paths: (1) fast — the underlying
2741
+ // store promise's `.finally(...)` inside
2742
+ // `track()` fires when the wedged store
2743
+ // unwedges; (2) slow an independent TTL
2744
+ // (`MLX_HARD_TIMEOUT_MARKER_TTL_MS`, default
2745
+ // 300s) bounds memory at O(requestRate × TTL)
2746
+ // even against a truly wedged store that
2747
+ // NEVER settles. Marker lifetime =
2748
+ // min(settlement, TTL expiry).
2749
+ //
2750
+ // Pass the record's absolute row expiry as a
2751
+ // hard cap on the marker. The record's
2752
+ // `expiresAt` field is epoch-seconds (see
2753
+ // `buildResponseRecord`it adds
2754
+ // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now()
2755
+ // / 1000)`), so convert to ms for the marker
2756
+ // map. Once the absolute bound passes,
2757
+ // `ResponseStore.getChain()` hides the row and
2758
+ // the retryable-503 classification is factually
2759
+ // wrong — the marker must flip to 404 regardless
2760
+ // of ongoing client retries.
2761
+ //
2762
+ // Capture ONLY the precomputed scalar
2763
+ // `absoluteExpiresAtMs` in this closure — NOT
2764
+ // the full resolved chain. The scalar is
2765
+ // `min(record.expiresAt * 1000,
2766
+ // chainEarliestExpiresAtMs)`, computed once
2767
+ // when the hard-timeout handle was armed
2768
+ // above. `ResponseStore.getChain()` walks
2769
+ // ancestors and aborts on the first expired
2770
+ // link (see
2771
+ // `crates/mlx-db/src/response_store/reader.rs:44-59`),
2772
+ // so clamping the marker at whichever link
2773
+ // would disappear from `getChain()` first is
2774
+ // the authoritative bound. Capturing only
2775
+ // the scalar means background pending
2776
+ // continuations under a degraded store do
2777
+ // not retain ancestor transcripts —
2778
+ // heap growth stays O(1) per hard-timed-out
2779
+ // persist regardless of chain length.
2780
+ getPendingWritesFor(store).markHardTimedOut(record.id, getHardTimedOutMarkerTtlMs(), absoluteExpiresAtMs ?? Number.POSITIVE_INFINITY);
2781
+ // Retire the id FIRST (binding is still alive
2782
+ // here — retirement reads the live id) then
2783
+ // drop the retain, which may trigger the
2784
+ // deferred teardown. Capture the retired id so
2785
+ // the persist's `.finally(...)` can release
2786
+ // the tombstone once the late write eventually
2787
+ // settles. The registry stores one refcounted
2788
+ // tombstone per model regardless of how many
2789
+ // hard-timeouts overlap — each retire
2790
+ // increments the shared counter and each
2791
+ // release decrements it — so the returned
2792
+ // `{ instanceId }` is captured as a presence
2793
+ // flag and `releaseTombstone(leaseModel)` is
2794
+ // called in the persist's `.finally(...)`.
2795
+ retiredTombstone = registry.retireInstanceIdForForceRelease(leaseModel);
2796
+ persistRetainBox.release?.();
2797
+ }, streamingHardTimeoutMs)
2798
+ : null;
2799
+ pendingPersistOuter = initiatePersist(store, record, absoluteExpiresAtMs).finally(() => {
2800
+ if (streamingHardTimeoutHandle !== null) {
2801
+ clearTimeout(streamingHardTimeoutHandle);
2802
+ }
2803
+ // If the hard-timeout breaker fired and installed a
2804
+ // tombstone on `leaseModel`, decrement the shared
2805
+ // refcount now that this persist has settled. The
2806
+ // single-entry refcount layout means overlapping
2807
+ // breakers share one slot — releasing one balances
2808
+ // one retire, and the entry survives until the
2809
+ // last outstanding persist releases.
2810
+ if (retiredTombstone !== undefined) {
2811
+ registry.releaseTombstone(leaseModel);
2812
+ }
2719
2813
  persistRetainBox.release?.();
2720
- }, streamingHardTimeoutMs)
2721
- : null;
2722
- pendingPersistOuter = initiatePersist(store, record, absoluteExpiresAtMs).finally(() => {
2723
- if (streamingHardTimeoutHandle !== null) {
2724
- clearTimeout(streamingHardTimeoutHandle);
2725
- }
2726
- // If the hard-timeout breaker fired and installed a
2727
- // tombstone on `leaseModel`, decrement the shared
2728
- // refcount now that this persist has settled. The
2729
- // single-entry refcount layout means overlapping
2730
- // breakers share one slot — releasing one balances
2731
- // one retire, and the entry survives until the
2732
- // last outstanding persist releases.
2733
- if (retiredTombstone !== undefined) {
2734
- registry.releaseTombstone(leaseModel);
2735
- }
2736
- persistRetainBox.release?.();
2737
- });
2738
- persistMode = streamingPersistMode;
2814
+ });
2815
+ persistMode = streamingPersistMode;
2816
+ }
2739
2817
  }
2818
+ catch (err) {
2819
+ handlerError = err instanceof Error ? err : new Error(String(err));
2820
+ }
2821
+ committed = streamingWasCommitted();
2740
2822
  }
2741
- catch (err) {
2742
- handlerError = err instanceof Error ? err : new Error(String(err));
2743
- }
2744
- committed = streamingWasCommitted();
2745
- }
2746
- else {
2747
- // The non-streaming native path has NO AbortSignal surface
2748
- // (plain `chatSession*` returns a Promise, no cancel), so a
2749
- // client that disconnects mid-generation still burns the
2750
- // full decode budget under this mutex. TODO: native
2751
- // cancellation for `chatSession*` until then the best we
2752
- // can do is the disconnect-aware skip inside
2753
- // `handleNonStreaming` (short-circuits `endJson` and
2754
- // signals the outer persist gate) plus this documented
2755
- // limitation.
2756
- const outcome = await runSessionNonStreaming(session, messages, newInputMessages, config, !lookup.hit);
2757
- // Prefix-cache observability headers for the non-streaming
2758
- // path. `res.end` has not fired yet (the handler's
2759
- // `endJson` call below is what flushes), so `setHeader`
2760
- // still lands on the wire. We re-classify the
2761
- // `X-Session-Cache` header here so a tier-2 lookup that
2762
- // did NOT actually produce native prefix reuse
2763
- // (`cachedTokens === 0`) gets demoted from the optimistic
2764
- // `prefix_hit` back to `fresh` — matching the plan's
2765
- // contract that `prefix_hit` only fires when the registry
2766
- // served a match via `promptCacheKey` AND the ChatResult
2767
- // reports `cachedTokens > 0`. The companion
2768
- // `X-Cached-Tokens: N` header reports the exact count for
2769
- // operators and downstream telemetry whenever reuse
2770
- // happened.
2771
- if (tier2Hit && outcome.result.cachedTokens === 0) {
2772
- sessionCacheStatus = 'fresh';
2773
- res.setHeader('X-Session-Cache', sessionCacheStatus);
2774
- }
2775
- if (outcome.result.cachedTokens > 0) {
2776
- res.setHeader('X-Cached-Tokens', String(outcome.result.cachedTokens));
2777
- }
2778
- try {
2779
- const handlerOutcome = await handleNonStreaming(res, outcome.result, mappedBody, responseId, previousResponseId, visibility, serverTiming);
2780
- if (store && body.store !== false) {
2781
- // Same in-lock-initiate / off-lock-await split as the
2782
- // streaming branch. The non-streaming handler only
2783
- // returns when the JSON body's `res.end()` callback
2784
- // has fired, so reaching this point means the client
2785
- // observed the turn the pending-write tracker
2786
- // protects a back-to-back continuation from a
2787
- // transient 404.
2788
- const record = buildResponseRecord(handlerOutcome.response, newInputMessages, previousResponseId, currentInstanceId, effectiveRetentionSec);
2789
- // See the streaming branch for the retain/release
2790
- // rationale a same-model unregister + re-register
2791
- // during the slow persist must not mint a fresh
2792
- // `modelInstanceId` that invalidates the row this
2793
- // write is about to land. The idempotent-release
2794
- // scaffolding is a structural hook for a future split
2795
- // teardown; the post-commit SOFT timeout arm does not
2796
- // force-fire it.
2797
- //
2798
- // A wedged persist would otherwise leak the binding
2799
- // retain for the lifetime of the process, so the
2800
- // hard-timeout timer is armed here in the same shape
2801
- // as the streaming branch, cancelled from the
2802
- // persist's own `.finally(...)` when the write settles
2803
- // naturally, and fires a force-release through the
2804
- // idempotent `persistRetainBox` otherwise. Default
2805
- // 60s, override via
2806
- // `MLX_POST_COMMIT_PERSIST_HARD_TIMEOUT_MS`, `'0'`
2807
- // disables. Empty string is treated as unset (falls
2808
- // back to the 60000ms default) so a config-templating
2809
- // typo cannot silently disable the breaker.
2810
- //
2811
- // The force-release path also calls
2812
- // `registry.retireInstanceIdForForceRelease(leaseModel)`
2813
- // BEFORE releasing the retain so a same-object
2814
- // re-registration AFTER teardown inherits the retired
2815
- // instance id from the tombstone a late-landing
2816
- // persist against the retired id stays chainable. A
2817
- // hot-swap to a DIFFERENT model object mints a fresh
2818
- // id and the 400 instance-mismatch is correct.
2819
- //
2820
- // The tombstone's lifetime is scoped to the pending
2821
- // persists that installed it — the `.finally(...)`
2822
- // calls `registry.releaseTombstone(leaseModel)` so
2823
- // that when the late write eventually settles, the
2824
- // shared refcount drops and, once every outstanding
2825
- // persist has released, any subsequent
2826
- // re-registration correctly mints a fresh id. Without
2827
- // this scoping, a past hard-timeout event would
2828
- // permanently re-enable id inheritance across
2829
- // unrelated later lifecycles reopening stale-chain
2830
- // replay across what should be logically dead
2831
- // bindings. The refcounted single-entry layout
2832
- // handles OVERLAPPING hard-timeouts on the same live
2833
- // instance id in bounded space: every breaker targets
2834
- // the SAME retired id (the register-inherit path
2835
- // keeps using it while the tombstone is alive) so one
2836
- // shared refcount safely collapses every in-flight
2837
- // retire, and memory stays O(1) per model even under
2838
- // a truly wedged store that never settles.
2839
- registry.retainBinding(leaseModel);
2840
- let persistRetainReleased = false;
2841
- persistRetainBox.release = () => {
2842
- if (persistRetainReleased)
2843
- return;
2844
- persistRetainReleased = true;
2845
- registry.releaseBinding(leaseModel);
2846
- };
2847
- const nonStreamingPersistMode = 'non-streaming';
2848
- const nonStreamingHardTimeoutMs = getPostCommitPersistHardTimeoutMs();
2849
- let retiredTombstone;
2850
- // See the matching streaming-path comment above —
2851
- // precompute the scalar `absoluteExpiresAtMs`
2852
- // (`min(record.expiresAt * 1000,
2853
- // chainEarliestExpiresAtMs)`) ONCE, thread it into
2854
- // the tracker at `initiatePersist()` time, and capture
2855
- // ONLY this scalar in the hard-timeout closure.
2856
- const recordExpiresAtMs = record.expiresAt != null && Number.isFinite(record.expiresAt) ? record.expiresAt * 1000 : undefined;
2857
- const absoluteExpiresAtMs = recordExpiresAtMs !== undefined && chainEarliestExpiresAtMs !== undefined
2858
- ? Math.min(recordExpiresAtMs, chainEarliestExpiresAtMs)
2859
- : (recordExpiresAtMs ?? chainEarliestExpiresAtMs);
2860
- const nonStreamingHardTimeoutHandle = nonStreamingHardTimeoutMs > 0
2861
- ? setTimeout(() => {
2823
+ else {
2824
+ // Non-streaming cancellation (H2): `streamSignal` threads
2825
+ // through `ChatSession.send/sendToolResult/startFromHistory`
2826
+ // into the normal public session method; the wrapper maps it to
2827
+ // the internal native operation, so a client that disconnects mid-generation
2828
+ // flips the controller, the native turn unwinds at the
2829
+ // next safepoint, and the dispatch REJECTS with
2830
+ // "chat session cancelled" (routed through the ordinary
2831
+ // uncommitted-error epilogue below no adopt, no
2832
+ // persist). A disconnect that lands before dispatch takes
2833
+ // the pre-dispatch early return above instead. The
2834
+ // disconnect-aware skip inside `handleNonStreaming` /
2835
+ // `endJson` remains the last line of defense for a
2836
+ // disconnect racing the final flush.
2837
+ const outcome = await runSessionNonStreaming(session, messages, newInputMessages, config, !lookup.hit && !pagedActive, streamSignal);
2838
+ // Prefix-cache observability headers for the non-streaming
2839
+ // path. `res.end` has not fired yet (the handler's
2840
+ // `endJson` call below is what flushes), so `setHeader`
2841
+ // still lands on the wire. We re-classify the
2842
+ // `X-Session-Cache` header here so a tier-2 lookup that
2843
+ // did NOT actually produce native prefix reuse
2844
+ // (`cachedTokens === 0`) gets demoted from the optimistic
2845
+ // `prefix_hit` back to `fresh` matching the plan's
2846
+ // contract that `prefix_hit` only fires when the registry
2847
+ // served a match via `promptCacheKey` AND the ChatResult
2848
+ // reports `cachedTokens > 0`. The companion
2849
+ // `X-Cached-Tokens: N` header reports the exact count for
2850
+ // operators and downstream telemetry whenever reuse
2851
+ // happened.
2852
+ if (tier2Hit && outcome.result.cachedTokens === 0) {
2853
+ sessionCacheStatus = 'fresh';
2854
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
2855
+ }
2856
+ if (outcome.result.cachedTokens > 0) {
2857
+ res.setHeader('X-Cached-Tokens', String(outcome.result.cachedTokens));
2858
+ }
2859
+ try {
2860
+ const handlerOutcome = await handleNonStreaming(res, outcome.result, mappedBody, responseId, previousResponseId, visibility, serverTiming);
2861
+ if (store && body.store !== false) {
2862
+ // Same in-lock-initiate / off-lock-await split as the
2863
+ // streaming branch. The non-streaming handler only
2864
+ // returns when the JSON body's `res.end()` callback
2865
+ // has fired, so reaching this point means the client
2866
+ // observed the turn the pending-write tracker
2867
+ // protects a back-to-back continuation from a
2868
+ // transient 404.
2869
+ const record = buildResponseRecord(handlerOutcome.response, newInputMessages, previousResponseId, currentInstanceId, effectiveRetentionSec);
2870
+ // See the streaming branch for the retain/release
2871
+ // rationale a same-model unregister + re-register
2872
+ // during the slow persist must not mint a fresh
2873
+ // `modelInstanceId` that invalidates the row this
2874
+ // write is about to land. The idempotent-release
2875
+ // scaffolding is a structural hook for a future split
2876
+ // teardown; the post-commit SOFT timeout arm does not
2877
+ // force-fire it.
2878
+ //
2879
+ // A wedged persist would otherwise leak the binding
2880
+ // retain for the lifetime of the process, so the
2881
+ // hard-timeout timer is armed here in the same shape
2882
+ // as the streaming branch, cancelled from the
2883
+ // persist's own `.finally(...)` when the write settles
2884
+ // naturally, and fires a force-release through the
2885
+ // idempotent `persistRetainBox` otherwise. Default
2886
+ // 60s, override via
2887
+ // `MLX_POST_COMMIT_PERSIST_HARD_TIMEOUT_MS`, `'0'`
2888
+ // disables. Empty string is treated as unset (falls
2889
+ // back to the 60000ms default) so a config-templating
2890
+ // typo cannot silently disable the breaker.
2891
+ //
2892
+ // The force-release path also calls
2893
+ // `registry.retireInstanceIdForForceRelease(leaseModel)`
2894
+ // BEFORE releasing the retain so a same-object
2895
+ // re-registration AFTER teardown inherits the retired
2896
+ // instance id from the tombstone — a late-landing
2897
+ // persist against the retired id stays chainable. A
2898
+ // hot-swap to a DIFFERENT model object mints a fresh
2899
+ // id and the 400 instance-mismatch is correct.
2900
+ //
2901
+ // The tombstone's lifetime is scoped to the pending
2902
+ // persists that installed it the `.finally(...)`
2903
+ // calls `registry.releaseTombstone(leaseModel)` so
2904
+ // that when the late write eventually settles, the
2905
+ // shared refcount drops and, once every outstanding
2906
+ // persist has released, any subsequent
2907
+ // re-registration correctly mints a fresh id. Without
2908
+ // this scoping, a past hard-timeout event would
2909
+ // permanently re-enable id inheritance across
2910
+ // unrelated later lifecycles reopening stale-chain
2911
+ // replay across what should be logically dead
2912
+ // bindings. The refcounted single-entry layout
2913
+ // handles OVERLAPPING hard-timeouts on the same live
2914
+ // instance id in bounded space: every breaker targets
2915
+ // the SAME retired id (the register-inherit path
2916
+ // keeps using it while the tombstone is alive) so one
2917
+ // shared refcount safely collapses every in-flight
2918
+ // retire, and memory stays O(1) per model even under
2919
+ // a truly wedged store that never settles.
2920
+ registry.retainBinding(leaseModel);
2921
+ let persistRetainReleased = false;
2922
+ persistRetainBox.release = () => {
2862
2923
  if (persistRetainReleased)
2863
2924
  return;
2864
- console.error(`[responses] post-commit persist HARD timeout (${nonStreamingHardTimeoutMs}ms, ` +
2865
- `${nonStreamingPersistMode}): underlying store.store(...) has not settled; ` +
2866
- `assuming wedged backend, force-releasing the binding retain so the binding can ` +
2867
- `be torn down. Retiring the current instance id via tombstone so a same-object ` +
2868
- `re-registration inherits it and a late-landing persist remains chainable; a ` +
2869
- `hot-swap to a DIFFERENT model object will mint a fresh id and the stale chain ` +
2870
- `will correctly fail with 400 instance-mismatch.`);
2871
- // Move the pending-write tracker entry into
2872
- // the hard-timed-out marker state for this
2873
- // response id. The pending entry is dropped
2874
- // so a wedged store.store(...) does not pin
2875
- // one promise closure + tracker entry per
2876
- // hard-timed-out request, AND the id is added
2877
- // to the `hardTimedOut` marker so a concurrent
2878
- // `previous_response_id` continuation can
2879
- // tell the difference between a permanent
2880
- // 404 and a slow-but-eventual persist that
2881
- // crossed the hard timeout. The continuation
2882
- // path consults `isHardTimedOut(id)` before
2883
- // falling through to `sendNotFound(...)` and
2884
- // returns retryable 503 `storage_timeout`
2885
- // instead, so clients keep retrying rather
2886
- // than discarding the chain. The marker has
2887
- // two cleanup paths: (1) fast the
2888
- // underlying store promise's `.finally(...)`
2889
- // inside `track()` fires when the wedged
2890
- // store unwedges; (2) slow — an independent
2891
- // TTL (`MLX_HARD_TIMEOUT_MARKER_TTL_MS`,
2892
- // default 300s) bounds memory at
2893
- // O(requestRate × TTL) even against a truly
2894
- // wedged store that NEVER settles. Marker
2895
- // lifetime = min(settlement, TTL expiry).
2896
- //
2897
- // Pass the record's absolute row expiry as a
2898
- // hard cap on the marker. The record's
2899
- // `expiresAt` field is epoch-seconds (see
2900
- // `buildResponseRecord` it adds
2901
- // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now()
2902
- // / 1000)`), so convert to ms for the marker
2903
- // map. Once the absolute bound passes,
2904
- // `ResponseStore.getChain()` hides the row and
2905
- // the retryable-503 classification is factually
2906
- // wrong the marker must flip to 404 regardless
2907
- // of ongoing client retries.
2908
- //
2909
- // Capture ONLY the precomputed scalar
2910
- // `absoluteExpiresAtMs` in this closuresee
2911
- // the matching streaming-path comment for the
2912
- // full rationale. The scalar was computed
2913
- // above when the hard-timeout handle was
2914
- // armed.
2915
- getPendingWritesFor(store).markHardTimedOut(record.id, getHardTimedOutMarkerTtlMs(), absoluteExpiresAtMs ?? Number.POSITIVE_INFINITY);
2916
- // Retire the id FIRST (binding is still alive
2917
- // here retirement reads the live id) then
2918
- // drop the retain, which may trigger the
2919
- // deferred teardown. Capture the retired id so
2920
- // the persist's `.finally(...)` can release
2921
- // the tombstone once the late write eventually
2922
- // settles. The registry stores one refcounted
2923
- // tombstone per model regardless of how many
2924
- // hard-timeouts overlap each retire
2925
- // increments the shared counter and each
2926
- // release decrements it so the returned
2927
- // `{ instanceId }` is captured as a presence
2928
- // flag and `releaseTombstone(leaseModel)` is
2929
- // called in the persist's `.finally(...)`.
2930
- retiredTombstone = registry.retireInstanceIdForForceRelease(leaseModel);
2925
+ persistRetainReleased = true;
2926
+ registry.releaseBinding(leaseModel);
2927
+ };
2928
+ const nonStreamingPersistMode = 'non-streaming';
2929
+ const nonStreamingHardTimeoutMs = getPostCommitPersistHardTimeoutMs();
2930
+ let retiredTombstone;
2931
+ // See the matching streaming-path comment above —
2932
+ // precompute the scalar `absoluteExpiresAtMs`
2933
+ // (`min(record.expiresAt * 1000,
2934
+ // chainEarliestExpiresAtMs)`) ONCE, thread it into
2935
+ // the tracker at `initiatePersist()` time, and capture
2936
+ // ONLY this scalar in the hard-timeout closure.
2937
+ const recordExpiresAtMs = record.expiresAt != null && Number.isFinite(record.expiresAt) ? record.expiresAt * 1000 : undefined;
2938
+ const absoluteExpiresAtMs = recordExpiresAtMs !== undefined && chainEarliestExpiresAtMs !== undefined
2939
+ ? Math.min(recordExpiresAtMs, chainEarliestExpiresAtMs)
2940
+ : (recordExpiresAtMs ?? chainEarliestExpiresAtMs);
2941
+ const nonStreamingHardTimeoutHandle = nonStreamingHardTimeoutMs > 0
2942
+ ? setTimeout(() => {
2943
+ if (persistRetainReleased)
2944
+ return;
2945
+ console.error(`[responses] post-commit persist HARD timeout (${nonStreamingHardTimeoutMs}ms, ` +
2946
+ `${nonStreamingPersistMode}): underlying store.store(...) has not settled; ` +
2947
+ `assuming wedged backend, force-releasing the binding retain so the binding can ` +
2948
+ `be torn down. Retiring the current instance id via tombstone so a same-object ` +
2949
+ `re-registration inherits it and a late-landing persist remains chainable; a ` +
2950
+ `hot-swap to a DIFFERENT model object will mint a fresh id and the stale chain ` +
2951
+ `will correctly fail with 400 instance-mismatch.`);
2952
+ // Move the pending-write tracker entry into
2953
+ // the hard-timed-out marker state for this
2954
+ // response id. The pending entry is dropped
2955
+ // so a wedged store.store(...) does not pin
2956
+ // one promise closure + tracker entry per
2957
+ // hard-timed-out request, AND the id is added
2958
+ // to the `hardTimedOut` marker so a concurrent
2959
+ // `previous_response_id` continuation can
2960
+ // tell the difference between a permanent
2961
+ // 404 and a slow-but-eventual persist that
2962
+ // crossed the hard timeout. The continuation
2963
+ // path consults `isHardTimedOut(id)` before
2964
+ // falling through to `sendNotFound(...)` and
2965
+ // returns retryable 503 `storage_timeout`
2966
+ // instead, so clients keep retrying rather
2967
+ // than discarding the chain. The marker has
2968
+ // two cleanup paths: (1) fast — the
2969
+ // underlying store promise's `.finally(...)`
2970
+ // inside `track()` fires when the wedged
2971
+ // store unwedges; (2) slowan independent
2972
+ // TTL (`MLX_HARD_TIMEOUT_MARKER_TTL_MS`,
2973
+ // default 300s) bounds memory at
2974
+ // O(requestRate × TTL) even against a truly
2975
+ // wedged store that NEVER settles. Marker
2976
+ // lifetime = min(settlement, TTL expiry).
2977
+ //
2978
+ // Pass the record's absolute row expiry as a
2979
+ // hard cap on the marker. The record's
2980
+ // `expiresAt` field is epoch-seconds (see
2981
+ // `buildResponseRecord` it adds
2982
+ // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now()
2983
+ // / 1000)`), so convert to ms for the marker
2984
+ // map. Once the absolute bound passes,
2985
+ // `ResponseStore.getChain()` hides the row and
2986
+ // the retryable-503 classification is factually
2987
+ // wrong the marker must flip to 404 regardless
2988
+ // of ongoing client retries.
2989
+ //
2990
+ // Capture ONLY the precomputed scalar
2991
+ // `absoluteExpiresAtMs` in this closure — see
2992
+ // the matching streaming-path comment for the
2993
+ // full rationale. The scalar was computed
2994
+ // above when the hard-timeout handle was
2995
+ // armed.
2996
+ getPendingWritesFor(store).markHardTimedOut(record.id, getHardTimedOutMarkerTtlMs(), absoluteExpiresAtMs ?? Number.POSITIVE_INFINITY);
2997
+ // Retire the id FIRST (binding is still alive
2998
+ // here — retirement reads the live id) then
2999
+ // drop the retain, which may trigger the
3000
+ // deferred teardown. Capture the retired id so
3001
+ // the persist's `.finally(...)` can release
3002
+ // the tombstone once the late write eventually
3003
+ // settles. The registry stores one refcounted
3004
+ // tombstone per model regardless of how many
3005
+ // hard-timeouts overlap — each retire
3006
+ // increments the shared counter and each
3007
+ // release decrements it — so the returned
3008
+ // `{ instanceId }` is captured as a presence
3009
+ // flag and `releaseTombstone(leaseModel)` is
3010
+ // called in the persist's `.finally(...)`.
3011
+ retiredTombstone = registry.retireInstanceIdForForceRelease(leaseModel);
3012
+ persistRetainBox.release?.();
3013
+ }, nonStreamingHardTimeoutMs)
3014
+ : null;
3015
+ pendingPersistOuter = initiatePersist(store, record, absoluteExpiresAtMs).finally(() => {
3016
+ if (nonStreamingHardTimeoutHandle !== null) {
3017
+ clearTimeout(nonStreamingHardTimeoutHandle);
3018
+ }
3019
+ // If the hard-timeout breaker fired and installed a
3020
+ // tombstone on `leaseModel`, decrement the shared
3021
+ // refcount now that this persist has settled. The
3022
+ // single-entry refcount layout means overlapping
3023
+ // breakers share one slot — releasing one balances
3024
+ // one retire, and the entry survives until the
3025
+ // last outstanding persist releases.
3026
+ if (retiredTombstone !== undefined) {
3027
+ registry.releaseTombstone(leaseModel);
3028
+ }
2931
3029
  persistRetainBox.release?.();
2932
- }, nonStreamingHardTimeoutMs)
2933
- : null;
2934
- pendingPersistOuter = initiatePersist(store, record, absoluteExpiresAtMs).finally(() => {
2935
- if (nonStreamingHardTimeoutHandle !== null) {
2936
- clearTimeout(nonStreamingHardTimeoutHandle);
2937
- }
2938
- // If the hard-timeout breaker fired and installed a
2939
- // tombstone on `leaseModel`, decrement the shared
2940
- // refcount now that this persist has settled. The
2941
- // single-entry refcount layout means overlapping
2942
- // breakers share one slot — releasing one balances
2943
- // one retire, and the entry survives until the
2944
- // last outstanding persist releases.
2945
- if (retiredTombstone !== undefined) {
2946
- registry.releaseTombstone(leaseModel);
2947
- }
2948
- persistRetainBox.release?.();
2949
- });
2950
- persistMode = nonStreamingPersistMode;
3030
+ });
3031
+ persistMode = nonStreamingPersistMode;
3032
+ }
2951
3033
  }
3034
+ catch (err) {
3035
+ handlerError = err instanceof Error ? err : new Error(String(err));
3036
+ }
3037
+ committed = outcome.committed;
2952
3038
  }
2953
- catch (err) {
2954
- handlerError = err instanceof Error ? err : new Error(String(err));
3039
+ // "Safe to suppress" collapses to: did the client observe a
3040
+ // terminal artefact for this responseId? On the non-
3041
+ // streaming path that is the JSON body landing cleanly on
3042
+ // the wire; on the streaming path it is a terminal SSE
3043
+ // event (`response.completed` or `response.failed`) landing
3044
+ // cleanly on the wire. In either case the client can see
3045
+ // the responseId and knows the turn is over, so adopting
3046
+ // the committed session under that id is safe and
3047
+ // swallowing the (already-surfaced-via-failed-event)
3048
+ // handler error is the only option that does not produce a
3049
+ // malformed double-response.
3050
+ const safeToSuppress = visibility.responseBodyWritten || visibility.terminalEmitted;
3051
+ if (previousResponseId) {
3052
+ sessionReg.drop(previousResponseId);
2955
3053
  }
2956
- committed = outcome.committed;
2957
- }
2958
- // "Safe to suppress" collapses to: did the client observe a
2959
- // terminal artefact for this responseId? On the non-
2960
- // streaming path that is the JSON body landing cleanly on
2961
- // the wire; on the streaming path it is a terminal SSE
2962
- // event (`response.completed` or `response.failed`) landing
2963
- // cleanly on the wire. In either case the client can see
2964
- // the responseId and knows the turn is over, so adopting
2965
- // the committed session under that id is safe and
2966
- // swallowing the (already-surfaced-via-failed-event)
2967
- // handler error is the only option that does not produce a
2968
- // malformed double-response.
2969
- const safeToSuppress = visibility.responseBodyWritten || visibility.terminalEmitted;
2970
- if (previousResponseId) {
2971
- sessionReg.drop(previousResponseId);
2972
- }
2973
- // Only adopt if the turn committed AND either the handler
2974
- // succeeded or a terminal artefact is already on the wire.
2975
- // A committed turn whose handler threw before the client
2976
- // saw anything it can chain off of must NOT be adopted —
2977
- // the responseId is unreachable from the client, so caching
2978
- // the session under it creates a permanently dangling warm
2979
- // session.
2980
- //
2981
- // Refuse to adopt whenever the streaming handler took ANY
2982
- // failure epilogue, not just `client_abort`. The streaming
2983
- // handler writes `failureMode` for every path that does
2984
- // not produce a clean `response.completed`:
2985
- //
2986
- // * `'client_abort'` client dropped the socket after
2987
- // the decode loop committed but before the success
2988
- // terminal was flushed; `response.failed` goes on the
2989
- // wire under a responseId the client has abandoned.
2990
- //
2991
- // * `'error'` — post-final teardown threw in
2992
- // the stream adapter's `finally` after the decode
2993
- // loop had already committed; `terminalToPersist` is
2994
- // null and the client saw `response.failed`, so the
2995
- // responseId is not a chainable artefact from the
2996
- // client's perspective.
2997
- //
2998
- // * `'finish_reason_error'` / `'stream_exhausted'` —
2999
- // terminal derived from a non-clean end of stream.
3000
- // Same reasoning: `response.failed` on the wire, no
3001
- // chainable success terminal.
3002
- //
3003
- // In every non-null `failureMode` case the session
3004
- // committed at the native level but the observable wire
3005
- // state is a failure, so adopting the session under the
3006
- // responseId would evict the last good hot session for
3007
- // this model under the single-warm invariant even
3008
- // though the adopted slot is unreachable.
3009
- //
3010
- // `failureMode === null` is the sole signal that the
3011
- // stream path completed cleanly and the adopted session
3012
- // is genuinely reachable via the responseId.
3013
- if (committed && (handlerError == null || safeToSuppress) && streamFailureMode === null) {
3014
- // Adopt under the SAME `effectivePromptCacheKey` that was
3015
- // used for `getOrCreate` above — not the raw
3016
- // `promptCacheKey` from the request body. When a request
3017
- // carries `previous_response_id` (tier-1 path), tier-2 is
3018
- // deliberately disabled on the lookup side by forcing
3019
- // `effectivePromptCacheKey = null`; the adopt side must
3020
- // follow the same rule or a mixed-mode request
3021
- // (`previous_response_id=rA + prompt_cache_key=K`) would
3022
- // store the adopted session under `K` even though the
3023
- // lookup was resolved via `rA`. A subsequent keyless/
3024
- // prev-idless request with `prompt_cache_key=K` would then
3025
- // tier-2 hit and lease rA's chain session — a cross-chain
3026
- // corruption the precedence rule was explicitly designed
3027
- // to prevent. Keep adopt's key aligned with lookup's key.
3028
- sessionReg.adopt(responseId, session, requestedInstructions, effectivePromptCacheKey);
3029
- }
3030
- // Rethrow handler errors when the client hasn't seen a
3031
- // terminal yet, regardless of commit state. The outer
3032
- // catch will send a proper 500 (non-streaming) or a last-
3033
- // ditch SSE `error` event (streaming, after `beginSSE` but
3034
- // before any terminal). Without this the request would
3035
- // hang from the client's perspective.
3036
- if (handlerError && !safeToSuppress) {
3037
- throw handlerError;
3038
- }
3039
- // If a terminal is on the wire but the handler still
3040
- // threw: log only. Rethrowing would produce a malformed
3041
- // double-response; the client already has a terminal event
3042
- // it can parse.
3043
- if (handlerError) {
3044
- console.error('[responses] handler error after terminal response already delivered:', handlerError);
3045
- }
3046
- }
3047
- catch (err) {
3048
- const message = err instanceof Error ? err.message : 'Unknown error during inference';
3049
- // Branch on `responseMode` (the wire format the handler
3050
- // committed to), NOT `res.headersSent`
3051
- // (which flips synchronously in `writeHead` and lies about
3052
- // which format the client is consuming). Each branch
3053
- // produces output that matches the Content-Type the client
3054
- // already received — or no output at all if the terminal
3055
- // already landed.
3056
- if (visibility.responseMode === null) {
3057
- // Capacity failures are deterministic request errors, raised
3058
- // before native cache mutation. Keep them out of the generic
3059
- // 500 path so clients can compact/truncate and retry.
3060
- if (isContextCapacityError(err)) {
3061
- sendBadRequest(res, message);
3054
+ // Only adopt if the turn committed AND either the handler
3055
+ // succeeded or a terminal artefact is already on the wire.
3056
+ // A committed turn whose handler threw before the client
3057
+ // saw anything it can chain off of must NOT be adopted —
3058
+ // the responseId is unreachable from the client, so caching
3059
+ // the session under it creates a permanently dangling warm
3060
+ // session.
3061
+ //
3062
+ // Refuse to adopt whenever the streaming handler took ANY
3063
+ // failure epilogue, not just `client_abort`. The streaming
3064
+ // handler writes `failureMode` for every path that does
3065
+ // not produce a clean `response.completed`:
3066
+ //
3067
+ // * `'client_abort'` — client dropped the socket after
3068
+ // the decode loop committed but before the success
3069
+ // terminal was flushed; `response.failed` goes on the
3070
+ // wire under a responseId the client has abandoned.
3071
+ //
3072
+ // * `'error'` — post-final teardown threw in
3073
+ // the stream adapter's `finally` after the decode
3074
+ // loop had already committed; `terminalToPersist` is
3075
+ // null and the client saw `response.failed`, so the
3076
+ // responseId is not a chainable artefact from the
3077
+ // client's perspective.
3078
+ //
3079
+ // * `'finish_reason_error'` / `'stream_exhausted'`
3080
+ // terminal derived from a non-clean end of stream.
3081
+ // Same reasoning: `response.failed` on the wire, no
3082
+ // chainable success terminal.
3083
+ //
3084
+ // In every non-null `failureMode` case the session
3085
+ // committed at the native level but the observable wire
3086
+ // state is a failure, so adopting the session under the
3087
+ // responseId would evict the last good hot session for
3088
+ // this model under the single-warm invariant even
3089
+ // though the adopted slot is unreachable.
3090
+ //
3091
+ // `failureMode === null` is the sole signal that the
3092
+ // stream path completed cleanly and the adopted session
3093
+ // is genuinely reachable via the responseId.
3094
+ if (committed && (handlerError == null || safeToSuppress) && streamFailureMode === null) {
3095
+ // Adopt under the SAME `effectivePromptCacheKey` that was
3096
+ // used for `getOrCreate` above not the raw
3097
+ // `promptCacheKey` from the request body. When a request
3098
+ // carries `previous_response_id` (tier-1 path), tier-2 is
3099
+ // deliberately disabled on the lookup side by forcing
3100
+ // `effectivePromptCacheKey = null`; the adopt side must
3101
+ // follow the same rule or a mixed-mode request
3102
+ // (`previous_response_id=rA + prompt_cache_key=K`) would
3103
+ // store the adopted session under `K` even though the
3104
+ // lookup was resolved via `rA`. A subsequent keyless/
3105
+ // prev-idless request with `prompt_cache_key=K` would then
3106
+ // tier-2 hit and lease rA's chain session — a cross-chain
3107
+ // corruption the precedence rule was explicitly designed
3108
+ // to prevent. Keep adopt's key aligned with lookup's key.
3109
+ sessionReg.adopt(responseId, session, requestedInstructions, effectivePromptCacheKey, config.cacheSalt ?? null);
3110
+ sessionRetained = true;
3062
3111
  }
3063
- else {
3064
- sendInternalError(res, message);
3112
+ // Rethrow handler errors when the client hasn't seen a
3113
+ // terminal yet, regardless of commit state. The outer
3114
+ // catch will send a proper 500 (non-streaming) or a last-
3115
+ // ditch SSE `error` event (streaming, after `beginSSE` but
3116
+ // before any terminal). Without this the request would
3117
+ // hang from the client's perspective.
3118
+ if (handlerError && !safeToSuppress) {
3119
+ throw handlerError;
3065
3120
  }
3066
- }
3067
- else if (visibility.responseMode === 'json') {
3068
- // We already wrote `Content-Type: application/json` and
3069
- // possibly some body bytes; emitting an SSE frame here
3070
- // would corrupt the response. Best we can do is destroy
3071
- // the socket so the client sees a truncated JSON
3072
- // response instead of a malformed document with an
3073
- // unexpected MIME type. If the body was fully written
3074
- // (`responseBodyWritten === true`) the outcome gate
3075
- // above already returned without rethrowing, so reaching
3076
- // this branch means the JSON never fully landed.
3077
- try {
3078
- res.destroy(err instanceof Error ? err : new Error(message));
3079
- }
3080
- catch {
3081
- // Socket may already be gone; nothing more we can do.
3121
+ // If a terminal is on the wire but the handler still
3122
+ // threw: log only. Rethrowing would produce a malformed
3123
+ // double-response; the client already has a terminal event
3124
+ // it can parse.
3125
+ if (handlerError) {
3126
+ console.error('[responses] handler error after terminal response already delivered:', handlerError);
3082
3127
  }
3083
3128
  }
3084
- else {
3085
- // `responseMode === 'sse'`: headers advertise SSE and
3086
- // some (or all) of the stream already went out. If a
3087
- // terminal event already landed, emitting another frame
3088
- // is a no-op from the client's perspective but we still
3089
- // close the stream cleanly. If no terminal landed (early
3090
- // `writeSSEEvent` crash before `response.created`), emit
3091
- // a best-effort streaming `error` frame so the client
3092
- // sees SOMETHING it can parse.
3093
- if (!visibility.terminalEmitted) {
3094
- writeFallbackErrorSSE(res, 'error', { error_type: 'server_error', message });
3129
+ catch (err) {
3130
+ const message = err instanceof Error ? err.message : 'Unknown error during inference';
3131
+ // Branch on `responseMode` (the wire format the handler
3132
+ // committed to), NOT `res.headersSent`
3133
+ // (which flips synchronously in `writeHead` and lies about
3134
+ // which format the client is consuming). Each branch
3135
+ // produces output that matches the Content-Type the client
3136
+ // already received or no output at all if the terminal
3137
+ // already landed.
3138
+ if (visibility.responseMode === null) {
3139
+ // Capacity failures are deterministic request errors, raised
3140
+ // before native cache mutation. Keep them out of the generic
3141
+ // 500 path so clients can compact/truncate and retry.
3142
+ if (isContextCapacityError(err)) {
3143
+ sendBadRequest(res, message);
3144
+ }
3145
+ else {
3146
+ sendInternalError(res, message);
3147
+ }
3095
3148
  }
3096
- try {
3097
- endSSE(res);
3149
+ else if (visibility.responseMode === 'json') {
3150
+ // We already wrote `Content-Type: application/json` and
3151
+ // possibly some body bytes; emitting an SSE frame here
3152
+ // would corrupt the response. Best we can do is destroy
3153
+ // the socket so the client sees a truncated JSON
3154
+ // response instead of a malformed document with an
3155
+ // unexpected MIME type. If the body was fully written
3156
+ // (`responseBodyWritten === true`) the outcome gate
3157
+ // above already returned without rethrowing, so reaching
3158
+ // this branch means the JSON never fully landed.
3159
+ try {
3160
+ res.destroy(err instanceof Error ? err : new Error(message));
3161
+ }
3162
+ catch {
3163
+ // Socket may already be gone; nothing more we can do.
3164
+ }
3098
3165
  }
3099
- catch {
3100
- // Already closed / destroyed.
3166
+ else {
3167
+ // `responseMode === 'sse'`: headers advertise SSE and
3168
+ // some (or all) of the stream already went out. If a
3169
+ // terminal event already landed, emitting another frame
3170
+ // is a no-op from the client's perspective but we still
3171
+ // close the stream cleanly. If no terminal landed (early
3172
+ // `writeSSEEvent` crash before `response.created`), emit
3173
+ // a best-effort streaming `error` frame so the client
3174
+ // sees SOMETHING it can parse.
3175
+ if (!visibility.terminalEmitted) {
3176
+ writeFallbackErrorSSE(res, 'error', { error_type: 'server_error', message });
3177
+ }
3178
+ try {
3179
+ endSSE(res);
3180
+ }
3181
+ catch {
3182
+ // Already closed / destroyed.
3183
+ }
3101
3184
  }
3102
3185
  }
3103
- }
3104
- });
3186
+ finally {
3187
+ await disposeUnretainedSession();
3188
+ await sessionReg.flushPendingDisposals();
3189
+ }
3190
+ });
3191
+ };
3105
3192
  await runInference();
3106
3193
  }
3107
3194
  catch (err) {
@@ -3119,7 +3206,7 @@ resolveModel) {
3119
3206
  // preserved untouched.
3120
3207
  if (err instanceof QueueFullError) {
3121
3208
  if (!res.headersSent) {
3122
- sendRateLimit(res, `Model queue full: ${err.queuedCount} waiting (limit ${err.limit}). Retry after 1s.`);
3209
+ sendRateLimit(res, `${err.message}. Retry after 1s.`);
3123
3210
  }
3124
3211
  }
3125
3212
  else {
@@ -3262,6 +3349,13 @@ resolveModel) {
3262
3349
  }
3263
3350
  }
3264
3351
  finally {
3352
+ // Balance the pre-dispatch admission on EVERY exit that never
3353
+ // handed the permit to `withExclusive`: getChain storage errors,
3354
+ // the binding-changed 400s, disconnects, and any validation
3355
+ // early-return inside the outer `try`. Idempotent and a no-op
3356
+ // after handoff, so the unconditional call is always safe.
3357
+ preDispatchAdmission?.release();
3358
+ modelLoadAdmission?.release();
3265
3359
  // Idempotent fallback: if the post-dispatch cleanup above
3266
3360
  // never ran (early-return validation failure, or an exception
3267
3361
  // raised inside the outer `try` block between lease