@mlx-node/server 0.0.13 → 0.0.15

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 (45) hide show
  1. package/dist/host/discover.d.ts +3 -6
  2. package/dist/host/discover.d.ts.map +1 -1
  3. package/dist/host/discover.js +9 -42
  4. package/dist/host/index.d.ts +2 -2
  5. package/dist/host/index.d.ts.map +1 -1
  6. package/dist/host/index.js +8 -1
  7. package/package.json +9 -4
  8. package/src/auth.ts +111 -0
  9. package/src/chat-session-warm-reuse.ts +96 -0
  10. package/src/endpoints/messages-count-tokens.ts +164 -0
  11. package/src/endpoints/messages.ts +1802 -0
  12. package/src/endpoints/models.ts +20 -0
  13. package/src/endpoints/responses.ts +3928 -0
  14. package/src/errors.ts +120 -0
  15. package/src/handler.ts +195 -0
  16. package/src/health.ts +213 -0
  17. package/src/host/discover.ts +25 -0
  18. package/src/host/env-policy.ts +81 -0
  19. package/src/host/index.ts +496 -0
  20. package/src/host/logger.ts +419 -0
  21. package/src/host/net.ts +100 -0
  22. package/src/host/paths.ts +77 -0
  23. package/src/host/swap.ts +200 -0
  24. package/src/host/temp-root.ts +110 -0
  25. package/src/idle-sweeper.ts +555 -0
  26. package/src/index.ts +114 -0
  27. package/src/load-model.ts +92 -0
  28. package/src/mappers/anthropic-request.ts +485 -0
  29. package/src/mappers/anthropic-response.ts +306 -0
  30. package/src/mappers/request.ts +456 -0
  31. package/src/mappers/response.ts +163 -0
  32. package/src/model-work-coordinator.ts +416 -0
  33. package/src/pending-writes.ts +481 -0
  34. package/src/registry.ts +691 -0
  35. package/src/router.ts +220 -0
  36. package/src/server.ts +579 -0
  37. package/src/session-registry.ts +1371 -0
  38. package/src/stop-sequence-buffer.ts +161 -0
  39. package/src/streaming.ts +205 -0
  40. package/src/text-recovery.ts +41 -0
  41. package/src/timing.ts +236 -0
  42. package/src/tool-call-buffer.ts +78 -0
  43. package/src/transport-visibility.ts +185 -0
  44. package/src/types-anthropic.ts +409 -0
  45. package/src/types.ts +470 -0
@@ -0,0 +1,3928 @@
1
+ /**
2
+ * POST /v1/responses — OpenAI Responses API, streaming (SSE) and non-streaming (JSON).
3
+ *
4
+ * Dispatches to loaded models via `ModelRegistry`. Inference goes through a per-model
5
+ * `ChatSession` looked up by `previous_response_id` in the model's `SessionRegistry`: a
6
+ * hit reuses the live KV cache (`send` / `sendStream` / `sendToolResult`); a miss
7
+ * reconstructs the full conversation from `ResponseStore` and cold-replays via
8
+ * `primeHistory` + `startFromHistory[Stream]`.
9
+ */
10
+
11
+ import { randomUUID } from 'node:crypto';
12
+ import type { IncomingMessage, ServerResponse } from 'node:http';
13
+
14
+ import type { ChatConfig, ChatMessage, ChatResult, ResponseStore, StoredResponseRecord } from '@mlx-node/core';
15
+ import { isContextCapacityError } from '@mlx-node/lm';
16
+ import type { ChatSession, ChatStreamEvent, SessionCapableModel } from '@mlx-node/lm';
17
+
18
+ import { resetPreservingNativeCacheForWarmReuse } from '../chat-session-warm-reuse.js';
19
+ import { sendBadRequest, sendInternalError, sendNotFound, sendRateLimit, sendStorageTimeout } from '../errors.js';
20
+ import type { IdleSweeper } from '../idle-sweeper.js';
21
+ import { mapRequest, reconstructMessagesFromChain, stringifyStoredInputMessages } from '../mappers/request.js';
22
+ import {
23
+ buildPartialResponse,
24
+ buildResponseObject,
25
+ computeOutputText,
26
+ genId,
27
+ mapFinishReasonToStatus,
28
+ } from '../mappers/response.js';
29
+ import {
30
+ type ModelLoadAdmission,
31
+ ModelLoadQueueFullError,
32
+ type ModelWorkCoordinator,
33
+ } from '../model-work-coordinator.js';
34
+ import { getPendingWritesFor } from '../pending-writes.js';
35
+ import type { ModelRegistry } from '../registry.js';
36
+ import {
37
+ maybeWarnPromptCacheKeyIneligible,
38
+ QueueFullError,
39
+ type PreDispatchAdmission,
40
+ type SessionRegistry,
41
+ } from '../session-registry.js';
42
+ import {
43
+ awaitDrainOrClose,
44
+ beginSSE,
45
+ endSSE,
46
+ type SSEClientAbortTracker,
47
+ trackSSEClientAbort,
48
+ writeSSEEvent as writeRawSSEEvent,
49
+ } from '../streaming.js';
50
+ import { longestSuffixPrefixOverlap } from '../text-recovery.js';
51
+ import { mergeTimingUsageExtensions, resolveServerTuningForUsage, type ServerTimingForUsage } from '../timing.js';
52
+ import { ToolCallTagBuffer } from '../tool-call-buffer.js';
53
+ import {
54
+ createVisibility,
55
+ endJson,
56
+ flushTerminalSSE,
57
+ markSSEMode,
58
+ type TransportVisibility,
59
+ writeFallbackErrorSSE,
60
+ } from '../transport-visibility.js';
61
+ import type {
62
+ FunctionCallOutputItem,
63
+ MessageOutputItem,
64
+ OutputItem,
65
+ ReasoningOutputItem,
66
+ ResponseObject,
67
+ ResponsesAPIRequest,
68
+ } from '../types.js';
69
+
70
+ /**
71
+ * Fallback retention for stored response rows when no explicit
72
+ * `responseRetentionSec` is threaded in. Production wires retention via
73
+ * `ServerConfig.responseRetentionSec` (default 7 days, see `server.ts`);
74
+ * this 30-minute fallback is only used by legacy direct-invocation callers.
75
+ */
76
+ const RESPONSE_TTL_SECONDS = 1800;
77
+
78
+ /**
79
+ * Upper bound for a client-supplied output-token budget. The native
80
+ * `ChatConfig.max_new_tokens` is `Option<i32>`, and NAPI's
81
+ * `napi_get_value_int32` silently truncates a JS integer above `i32::MAX`
82
+ * to a NEGATIVE value — which the core clamp then turns into 0 (a silent
83
+ * empty completion). Reject anything above this bound at the edge so an
84
+ * over-large budget 400s instead of producing nothing. Shared with
85
+ * `/v1/messages` (`messages.ts`).
86
+ */
87
+ export const MAX_OUTPUT_TOKENS = 2147483647; // i32::MAX — native ChatConfig.max_new_tokens is i32
88
+
89
+ function withAdmissionControlledInference<T>(
90
+ sessionReg: SessionRegistry,
91
+ modelWorkCoordinator: ModelWorkCoordinator | undefined,
92
+ // Pre-dispatch permit handed off ATOMICALLY as this call's admission
93
+ // (the selected admission lane consumes it instead of charging
94
+ // `queuedCount` a second time). See `beginPreDispatchAdmission`. Placed BEFORE `fn`
95
+ // so call sites keep the trailing-closure layout.
96
+ permit: PreDispatchAdmission | undefined,
97
+ fn: () => Promise<T>,
98
+ ): Promise<T> {
99
+ const run = () => (modelWorkCoordinator ? modelWorkCoordinator.withInference(fn) : fn());
100
+ return sessionReg.concurrentAdmissionLimit > 1
101
+ ? sessionReg.withAdmission(run, permit)
102
+ : sessionReg.withExclusive(run, permit);
103
+ }
104
+
105
+ /**
106
+ * Value of the `X-Session-Cache` response header emitted on every
107
+ * `/v1/responses` and `/v1/messages` response. Advertises whether the
108
+ * request warm-hit the per-model `SessionRegistry` via `previous_response_id`
109
+ * (`hit`), missed and cold-replayed from the stored chain
110
+ * (`cold_replay`), reused a warm session via the stateless-agent
111
+ * `prompt_cache_key` tier-2 lookup (`prefix_hit`), or started a fresh
112
+ * session with neither keying signal (`fresh`). The literal string
113
+ * values are load-bearing — clients and operator tooling pin on them.
114
+ */
115
+ export type SessionCacheStatus = 'hit' | 'cold_replay' | 'fresh' | 'prefix_hit';
116
+
117
+ /**
118
+ * Upper bound (ms) on how long the recovery path waits for an in-flight
119
+ * `store.store(...)` to land. On timeout we re-probe `getChain` once to
120
+ * catch a late-landing write, then surface HTTP 503 (retryable) rather
121
+ * than 404 (permanent). Default 2000ms — short enough to fail fast on a
122
+ * wedged backend, long enough that healthy SQLite writes complete well
123
+ * within it. Override via `MLX_CHAIN_WRITE_WAIT_TIMEOUT_MS`.
124
+ */
125
+ function getChainWriteWaitTimeoutMs(): number {
126
+ const raw = process.env.MLX_CHAIN_WRITE_WAIT_TIMEOUT_MS;
127
+ if (raw == null || raw === '') return 2000;
128
+ const parsed = Number(raw);
129
+ if (!Number.isFinite(parsed) || parsed <= 0) return 2000;
130
+ return parsed;
131
+ }
132
+
133
+ /**
134
+ * Soft timeout (ms) on how long the outer handler awaits the off-lock
135
+ * `store.store(...)` before detaching and letting the write run in the
136
+ * background. The pending-writes tracker still holds a reference so
137
+ * chained continuations can observe it. Default 5000ms (larger than the
138
+ * chain-write wait because this bound is not client-facing — the client
139
+ * already has its terminal response). Override via
140
+ * `MLX_POST_COMMIT_PERSIST_TIMEOUT_MS`.
141
+ */
142
+ function getPostCommitPersistTimeoutMs(): number {
143
+ const raw = process.env.MLX_POST_COMMIT_PERSIST_TIMEOUT_MS;
144
+ if (raw == null || raw === '') return 5000;
145
+ const parsed = Number(raw);
146
+ if (!Number.isFinite(parsed) || parsed <= 0) return 5000;
147
+ return parsed;
148
+ }
149
+
150
+ /**
151
+ * Hard timeout (ms) for the off-lock post-commit persist — the
152
+ * second-stage breaker that force-releases the `retainBinding` paired
153
+ * with `initiatePersist` when the write is truly wedged (never settles).
154
+ *
155
+ * The soft persist timeout above only detaches the handler; the retain
156
+ * stays pinned so a slow-but-eventual write still lands against the
157
+ * live `modelInstanceId`. This hard breaker bounds the leak for a
158
+ * genuinely wedged promise at this value instead of process lifetime.
159
+ * On fire, it also retires the instance id via a refcounted tombstone
160
+ * so a same-object re-registration inherits the id and the late write
161
+ * remains chainable — a true hot-swap to a different object still
162
+ * mints a fresh id and correctly fails stale chains with 400.
163
+ *
164
+ * Default 60000ms — well past the soft timeout so slow-but-eventual
165
+ * writes are unaffected. Override via `MLX_POST_COMMIT_PERSIST_HARD_TIMEOUT_MS`:
166
+ * empty/whitespace-only falls back to default (so a config-templating
167
+ * typo cannot silently disable the breaker); `'0'` explicitly disables;
168
+ * non-numeric garbage falls back to default. Exported for unit tests.
169
+ */
170
+ export function getPostCommitPersistHardTimeoutMs(): number {
171
+ const raw = process.env.MLX_POST_COMMIT_PERSIST_HARD_TIMEOUT_MS;
172
+ const normalized = raw?.trim();
173
+ if (normalized == null || normalized === '') return 60_000;
174
+ const parsed = Number(normalized);
175
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 60_000;
176
+ }
177
+
178
+ /**
179
+ * TTL (ms) for hard-timed-out markers in the per-store pending-writes
180
+ * tracker. See `pending-writes.ts` for the full lifetime model.
181
+ *
182
+ * An independent TTL with lazy expiry on read bounds marker memory at
183
+ * O(requestRate × TTL) even when the underlying wedged writes never
184
+ * settle (and their `.finally(...)` cleanup therefore never fires).
185
+ * Default 300000ms (5 min) — past this, the best-effort persist
186
+ * contract has long since failed and permanent 404 is the correct
187
+ * eventual outcome. Override via `MLX_HARD_TIMEOUT_MARKER_TTL_MS`
188
+ * (same parse semantics as the hard-timeout env var above). Exported
189
+ * for unit tests.
190
+ */
191
+ export function getHardTimedOutMarkerTtlMs(): number {
192
+ const raw = process.env.MLX_HARD_TIMEOUT_MARKER_TTL_MS;
193
+ const normalized = raw?.trim();
194
+ if (normalized == null || normalized === '') return 300_000;
195
+ const parsed = Number(normalized);
196
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 300_000;
197
+ }
198
+
199
+ /**
200
+ * Per-process boot id stamped into every stored response row's
201
+ * `configJson` alongside `modelInstanceId`. The pair enables
202
+ * restart-safe chain continuation while preserving the in-process
203
+ * hot-swap guard:
204
+ *
205
+ * - stored `serverBootId` == live boot id AND `modelInstanceId`
206
+ * matches live → strict hit (in-process hot-swap protection).
207
+ * - stored `serverBootId` != live boot id (or missing, i.e. rows
208
+ * written before this field existed) → cross-restart. The
209
+ * stored `modelInstanceId` belongs to a dead process and is
210
+ * meaningless, so the instance-id check is skipped and the
211
+ * continuation falls back to name-based resume through whatever
212
+ * model is currently bound to the requested name.
213
+ * - stored `configJson` malformed → reject.
214
+ * - stored row has neither `serverBootId` NOR `modelInstanceId`
215
+ * (truly legacy, pre-instance-id) → reject.
216
+ *
217
+ * `getServerBootId()` resolves lazily on every call so tests can
218
+ * install a deterministic boot id via `__setServerBootIdForTesting`
219
+ * before exercising either the persistence or validation path.
220
+ */
221
+ let serverBootId: string = randomUUID();
222
+
223
+ export function getServerBootId(): string {
224
+ return serverBootId;
225
+ }
226
+
227
+ export function __setServerBootIdForTesting(id: string): void {
228
+ serverBootId = id;
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Non-streaming path
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * Outcome of the non-streaming handler. The outer handler persists
237
+ * `response` AFTER releasing the per-model mutex — keeping persistence
238
+ * off the critical path so a slow store does not pin the next waiter.
239
+ */
240
+ interface NonStreamingHandlerOutcome {
241
+ response: ResponseObject;
242
+ }
243
+
244
+ async function handleNonStreaming(
245
+ res: ServerResponse,
246
+ result: ChatResult,
247
+ req: ResponsesAPIRequest,
248
+ responseId: string,
249
+ previousResponseId: string | undefined,
250
+ visibility: TransportVisibility,
251
+ serverTiming?: ServerTimingForUsage,
252
+ ): Promise<NonStreamingHandlerOutcome> {
253
+ const response = buildResponseObject(result, req, responseId, previousResponseId);
254
+ mergeTimingUsageExtensions(
255
+ response.usage,
256
+ result.performance,
257
+ result.promptTokens,
258
+ result.numTokens,
259
+ result.cachedTokens,
260
+ serverTiming,
261
+ );
262
+
263
+ // The request AbortSignal reaches the normal session method, whose wrapper
264
+ // maps it to native cancellation at the next model safepoint.
265
+ // `endJson`'s `isSocketGone(res)` remains the final transport race check:
266
+ // on a dead peer it rejects AFTER committing `responseMode = 'json'`
267
+ // so the outer catch routes to the JSON error / socket-destroy
268
+ // shape; `responseBodyWritten` flips only from `res.end`'s write
269
+ // callback (proving the kernel accepted the chunk) so the adopt
270
+ // gate refuses to cache the session under an unreachable responseId.
271
+ await endJson(res, JSON.stringify(response), visibility);
272
+ return { response };
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Streaming path
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * Build a failure terminal `ResponseObject`: `status: 'failed'`,
281
+ * `incomplete_details: { reason }`, and every nested message /
282
+ * function_call item with `status` `in_progress` or `completed`
283
+ * normalized to `incomplete` so a client inspecting `response.output`
284
+ * on a failed envelope cannot see success-shaped items inside it.
285
+ * `ReasoningOutputItem` has no `status` field and is left alone.
286
+ */
287
+ function buildFailedTerminal(
288
+ partial: ResponseObject,
289
+ outputItems: OutputItem[],
290
+ reason: string,
291
+ usage: ResponseObject['usage'],
292
+ errorMessage: string | null,
293
+ ): ResponseObject {
294
+ const normalized: OutputItem[] = outputItems.map((item) => {
295
+ if (item.type === 'message') {
296
+ const prev = item.status;
297
+ if (prev === 'in_progress' || prev === 'completed') {
298
+ return { ...item, status: 'incomplete' };
299
+ }
300
+ return item;
301
+ }
302
+ if (item.type === 'function_call') {
303
+ if (item.status === 'completed' || item.status === 'incomplete') {
304
+ return { ...item, status: 'incomplete' as const };
305
+ }
306
+ return item;
307
+ }
308
+ return item;
309
+ });
310
+ // Only the `reason: 'error'` path carries a diagnostic message —
311
+ // client_abort / stream_exhausted / finish_reason_error are caller
312
+ // or finite-state conditions, not server faults worth surfacing.
313
+ const error = errorMessage ? { type: 'server_error', message: errorMessage, code: null, param: null } : null;
314
+ return {
315
+ ...partial,
316
+ status: 'failed',
317
+ output: normalized,
318
+ output_text: computeOutputText(normalized),
319
+ error,
320
+ incomplete_details: { reason },
321
+ usage,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Outcome of the streaming handler.
327
+ *
328
+ * `terminalToPersist` is non-null only on the committed-success path
329
+ * (the outer handler writes it to the `ResponseStore` after releasing
330
+ * the per-model mutex). Every failure path leaves it null — the turn
331
+ * never committed, so there is nothing authoritative to persist or
332
+ * cold-replay.
333
+ *
334
+ * `failureMode` carries the reason out to the adopt gate, which must
335
+ * refuse to cache under an unreachable responseId — in particular, a
336
+ * `res.close` that fires AFTER the final chunk can commit the session
337
+ * while the client will never chain off that id, so the gate keys on
338
+ * `failureMode === null`, not on `committed` alone.
339
+ */
340
+ interface StreamingHandlerOutcome {
341
+ terminalToPersist: ResponseObject | null;
342
+ failureMode: 'client_abort' | 'error' | 'finish_reason_error' | 'stream_exhausted' | null;
343
+ /**
344
+ * Number of prompt tokens served from the reused KV-cache prefix on
345
+ * this turn, lifted from the final `ChatStreamEvent` when the native
346
+ * chunk carried a `cachedTokens` field. `undefined` means the
347
+ * streaming path did NOT report a count (the native
348
+ * `ChatStreamChunk` does not expose `cachedTokens` today) — downstream
349
+ * consumers MUST treat `undefined` distinctly from `0` (e.g. skip
350
+ * emitting `X-Cached-Tokens` rather than reporting a fabricated
351
+ * zero). Remains `undefined` on any non-success path.
352
+ */
353
+ cachedTokens: number | undefined;
354
+ }
355
+
356
+ async function handleStreamingNative(
357
+ res: ServerResponse,
358
+ chatStream: AsyncGenerator<ChatStreamEvent>,
359
+ req: ResponsesAPIRequest,
360
+ responseId: string,
361
+ previousResponseId: string | undefined,
362
+ wasCommitted: () => boolean,
363
+ httpReq: IncomingMessage | undefined,
364
+ visibility: TransportVisibility,
365
+ serverTiming?: ServerTimingForUsage,
366
+ ): Promise<StreamingHandlerOutcome> {
367
+ const abort = trackSSEClientAbort(res, httpReq);
368
+ try {
369
+ return await handleStreamingNativeWithAbort(
370
+ res,
371
+ chatStream,
372
+ req,
373
+ responseId,
374
+ previousResponseId,
375
+ wasCommitted,
376
+ abort,
377
+ visibility,
378
+ serverTiming,
379
+ );
380
+ } finally {
381
+ abort.dispose();
382
+ }
383
+ }
384
+
385
+ async function handleStreamingNativeWithAbort(
386
+ res: ServerResponse,
387
+ chatStream: AsyncGenerator<ChatStreamEvent>,
388
+ req: ResponsesAPIRequest,
389
+ responseId: string,
390
+ previousResponseId: string | undefined,
391
+ wasCommitted: () => boolean,
392
+ abort: SSEClientAbortTracker,
393
+ visibility: TransportVisibility,
394
+ serverTiming?: ServerTimingForUsage,
395
+ ): Promise<StreamingHandlerOutcome> {
396
+ // `runSessionStreaming` completed the exact token/capacity preflight before
397
+ // handing us this iterator. Commit SSE immediately instead of entering the
398
+ // generator here: its first `next()` also starts image processing/prefill and
399
+ // may not resolve until the first generated token.
400
+ beginSSE(res);
401
+ // Commit to SSE wire format synchronously so the outer catch
402
+ // branches on `responseMode` (not `headersSent`) and routes an
403
+ // early `writeSSEEvent` failure to the streaming error epilogue
404
+ // instead of corrupting the JSON path.
405
+ markSSEMode(visibility);
406
+
407
+ const partial = buildPartialResponse(req, responseId, previousResponseId);
408
+
409
+ // Arm the close-safe drain listener synchronously on the FIRST false write.
410
+ // A native event can expand into several SSE frames, so the promise stays
411
+ // sticky until the loop awaits it; no later true return may erase the gate.
412
+ let pendingDrain: Promise<void> | null = null;
413
+ const writeSSEEvent = (response: ServerResponse, eventType: string, data: object): void => {
414
+ const ok = writeRawSSEEvent(response, eventType, data);
415
+ if (!ok && pendingDrain === null) {
416
+ pendingDrain = awaitDrainOrClose(response, { onTimeout: () => abort.markAborted() });
417
+ }
418
+ };
419
+ const drainPending = async (): Promise<void> => {
420
+ const drain = pendingDrain;
421
+ if (drain === null) return;
422
+ await drain;
423
+ if (pendingDrain === drain) pendingDrain = null;
424
+ };
425
+
426
+ const outputItems: OutputItem[] = [];
427
+ let outputIndex = 0;
428
+
429
+ // State tracking for streaming
430
+ let reasoningItemId: string | null = null;
431
+ let reasoningText = '';
432
+ let messageItemId: string | null = null;
433
+ let messageText = '';
434
+ let hasEmittedMessage = false;
435
+ let hasEmittedReasoning = false;
436
+ // Tracks whether the reasoning output item's `response.output_item.done`
437
+ // has already been emitted. We close it eagerly on the reasoning→text
438
+ // transition (before opening the message item) so OpenAI Responses
439
+ // clients can populate their `thinkingSignature` via the `done`
440
+ // event's `currentBlock?.type === 'thinking'` guard. The terminal and
441
+ // failure paths check this flag to avoid double-emitting.
442
+ let hasClosedReasoning = false;
443
+ let suppressedMessageIndex = -1;
444
+ const tagBuffer = new ToolCallTagBuffer();
445
+
446
+ // Terminal response is captured in the done branch but emitted AFTER
447
+ // the loop drains — `wasCommitted()` only reads authoritative
448
+ // `session.turns` once the producer's finally has run.
449
+ let completedResponse: ResponseObject | null = null;
450
+ let sawDone = false;
451
+ // Lifted from the final stream event so the outer handler can set
452
+ // `X-Cached-Tokens` and promote the `X-Session-Cache` header to
453
+ // `prefix_hit` when tier-2 reuse actually happened. See
454
+ // `StreamingHandlerOutcome.cachedTokens` for the full rationale.
455
+ // Starts `undefined` — the field is only populated if the native
456
+ // terminal chunk carries `cachedTokens`. Today it never does; a
457
+ // future native plumbing change can lift it through.
458
+ let cachedTokens: number | undefined;
459
+
460
+ // Fault state. `thrownError` sticks on a generator throw. The outer
461
+ // `SSEClientAbortTracker` remains armed through every residual write, drain,
462
+ // classification, and terminal flush — not merely through this loop.
463
+ let thrownError: Error | null = null;
464
+
465
+ // The outer wrapper installed abort listeners before the first body write.
466
+ // If that write queues an asynchronous transport error, `abort.aborted` must
467
+ // flip before its drain promise settles and the loop evaluates the gate.
468
+ writeSSEEvent(res, 'response.created', { response: partial });
469
+ writeSSEEvent(res, 'response.in_progress', { response: partial });
470
+
471
+ try {
472
+ for await (const event of chatStream) {
473
+ await drainPending();
474
+ // Honor disconnect or a bounded drain timeout at loop-top. Breaking
475
+ // drops the generator reference; its AbortSignal cancels native work
476
+ // and its finally releases the per-model lock.
477
+ if (abort.aborted) break;
478
+ if (event.done) {
479
+ sawDone = true;
480
+ // Final event -- close open items and emit completed
481
+
482
+ // Flush any remaining pending text (no tool call tag was found)
483
+ const remainingText = tagBuffer.flush();
484
+ if (!tagBuffer.suppressed && remainingText) {
485
+ if (!hasEmittedMessage) {
486
+ hasEmittedMessage = true;
487
+ messageItemId = genId('msg_');
488
+ const messageItem: MessageOutputItem = {
489
+ id: messageItemId,
490
+ type: 'message',
491
+ role: 'assistant',
492
+ status: 'in_progress',
493
+ content: [],
494
+ };
495
+ const miIndex = outputItems.length;
496
+ outputItems.push(messageItem);
497
+ outputIndex = miIndex;
498
+ writeSSEEvent(res, 'response.output_item.added', { output_index: miIndex, item: messageItem });
499
+ const textPart = { type: 'output_text' as const, text: '', annotations: [] as never[] };
500
+ writeSSEEvent(res, 'response.content_part.added', {
501
+ item_id: messageItemId,
502
+ output_index: miIndex,
503
+ content_index: 0,
504
+ part: textPart,
505
+ });
506
+ }
507
+ messageText += remainingText;
508
+ writeSSEEvent(res, 'response.output_text.delta', {
509
+ item_id: messageItemId,
510
+ output_index: outputItems.findIndex((i) => i.id === messageItemId),
511
+ content_index: 0,
512
+ delta: remainingText,
513
+ });
514
+ }
515
+
516
+ // Close reasoning item if still open (already closed eagerly on
517
+ // reasoning→text transition for most turns — this branch covers
518
+ // the reasoning-only shape where no text deltas ever arrived).
519
+ if (hasEmittedReasoning && !hasClosedReasoning && reasoningItemId) {
520
+ hasClosedReasoning = true;
521
+ const finalReasoningText = event.thinking ?? reasoningText;
522
+ writeSSEEvent(res, 'response.reasoning_summary_text.done', {
523
+ item_id: reasoningItemId,
524
+ output_index: outputItems.length - (hasEmittedMessage ? 1 : 0) - 1,
525
+ summary_index: 0,
526
+ text: finalReasoningText,
527
+ });
528
+ const reasoningItem: ReasoningOutputItem = {
529
+ id: reasoningItemId,
530
+ type: 'reasoning',
531
+ summary: [{ type: 'summary_text', text: finalReasoningText }],
532
+ };
533
+ const riIndex = outputItems.findIndex((i) => i.id === reasoningItemId);
534
+ if (riIndex >= 0) {
535
+ outputItems[riIndex] = reasoningItem;
536
+ }
537
+ writeSSEEvent(res, 'response.output_item.done', {
538
+ output_index: riIndex >= 0 ? riIndex : 0,
539
+ item: reasoningItem,
540
+ });
541
+ }
542
+
543
+ // Close message item if open.
544
+ // Use the final event's parsed text (markup-stripped) as the authoritative content.
545
+ // If the parsed text is empty and there are tool calls, skip the message item entirely
546
+ // (matching the non-streaming buildOutputItems behavior).
547
+ const finalText = event.text;
548
+ const hasToolCalls = event.toolCalls.some((t) => t.status === 'ok');
549
+ const skipMessageItem = !finalText && hasToolCalls;
550
+
551
+ // Recovery: if tool-call suppression was triggered but the final event has no
552
+ // parsed tool calls (false alarm — e.g., literal "<tool_call>" in model output),
553
+ // create a message item using the final parsed text.
554
+ if (tagBuffer.suppressed && !hasToolCalls && finalText && !hasEmittedMessage) {
555
+ hasEmittedMessage = true;
556
+ messageItemId = genId('msg_');
557
+ const messageItem: MessageOutputItem = {
558
+ id: messageItemId,
559
+ type: 'message',
560
+ role: 'assistant',
561
+ status: 'in_progress',
562
+ content: [],
563
+ };
564
+ const miIndex = outputItems.length;
565
+ outputItems.push(messageItem);
566
+ outputIndex = miIndex;
567
+ writeSSEEvent(res, 'response.output_item.added', { output_index: miIndex, item: messageItem });
568
+ const textPart = { type: 'output_text' as const, text: '', annotations: [] as never[] };
569
+ writeSSEEvent(res, 'response.content_part.added', {
570
+ item_id: messageItemId,
571
+ output_index: miIndex,
572
+ content_index: 0,
573
+ part: textPart,
574
+ });
575
+ messageText = finalText;
576
+ writeSSEEvent(res, 'response.output_text.delta', {
577
+ item_id: messageItemId,
578
+ output_index: miIndex,
579
+ content_index: 0,
580
+ delta: finalText,
581
+ });
582
+ } else if (
583
+ tagBuffer.suppressed &&
584
+ !hasToolCalls &&
585
+ finalText &&
586
+ hasEmittedMessage &&
587
+ !messageText.includes(finalText)
588
+ ) {
589
+ // Recovery: streaming text was cut off by a false-alarm `<tool_call>` tag.
590
+ //
591
+ // The previous `finalText.slice(messageText.length)` is wrong: when the
592
+ // streamed text contains post-</think> whitespace (or any prefix the
593
+ // native side trimmed via `split_at_think_end` / `parse_tool_calls`),
594
+ // `messageText.length` indexes into the streamed buffer while
595
+ // `finalText` starts at the post-trim cleaned position — the two
596
+ // prefixes diverge (e.g. messageText=`"\n\n"`, finalText=`"<tool_call>..."`)
597
+ // and a length-based slice chops `<t` off `<tool_call>`, emitting
598
+ // `"ool_call>\n<function=..."` as visible text.
599
+ //
600
+ // Find the longest streamed-suffix == finalText-prefix overlap and emit
601
+ // whatever finalText has BEYOND that overlap.
602
+ //
603
+ // The `!messageText.includes(finalText)` guard distinguishes:
604
+ // (a) duplicate-trim case: streamed "Let me check. " + closed
605
+ // non-ok tool_call → finalText="Let me check." (trimmed). The
606
+ // trimmed text IS a substring of the streamed text → skip
607
+ // (otherwise we'd duplicate "Let me check.").
608
+ // (b) unclosed-tool case: streamed `\n\n` + unclosed
609
+ // `<tool_call>...` → finalText=`<tool_call>...`. The malformed
610
+ // tag is NOT a substring of the streamed whitespace → emit
611
+ // (this is the original `<t`-strip bug we're fixing).
612
+ // Length-based guards (`finalText.length > messageText.length`)
613
+ // misclassify case (b) when the streamed whitespace is long.
614
+ const overlap = longestSuffixPrefixOverlap(messageText, finalText);
615
+ const unsent = finalText.slice(overlap);
616
+ if (unsent) {
617
+ messageText += unsent;
618
+ writeSSEEvent(res, 'response.output_text.delta', {
619
+ item_id: messageItemId,
620
+ output_index: outputItems.findIndex((i) => i.id === messageItemId),
621
+ content_index: 0,
622
+ delta: unsent,
623
+ });
624
+ }
625
+ }
626
+
627
+ // Emit any unsent suffix when final text extends past what was
628
+ // streamed. Same divergence concern as above (post-</think> trim
629
+ // can leave `messageText` longer than the matching prefix of
630
+ // `finalText`), so we use the same overlap-based slice instead of
631
+ // a length-based one. When the overlap covers all of `finalText`
632
+ // (i.e. nothing more to emit) `unsent` is empty and we skip.
633
+ //
634
+ // The `!messageText.includes(finalText)` guard skips the
635
+ // duplicate-trim case where finalText is a substring of the
636
+ // streamed text (e.g. native `.trim()` shrinkage). See the
637
+ // companion comment above for the case-distinction rationale.
638
+ if (hasEmittedMessage && finalText && !tagBuffer.suppressed && !messageText.includes(finalText)) {
639
+ const overlap = longestSuffixPrefixOverlap(messageText, finalText);
640
+ const unsent = finalText.slice(overlap);
641
+ if (unsent) {
642
+ messageText += unsent;
643
+ writeSSEEvent(res, 'response.output_text.delta', {
644
+ item_id: messageItemId,
645
+ output_index: outputItems.findIndex((i) => i.id === messageItemId),
646
+ content_index: 0,
647
+ delta: unsent,
648
+ });
649
+ }
650
+ }
651
+
652
+ // Recovery: text was never emitted during streaming but final has text
653
+ // (possible if all text arrived in the final event only)
654
+ if (!hasEmittedMessage && finalText && !skipMessageItem) {
655
+ hasEmittedMessage = true;
656
+ messageItemId = genId('msg_');
657
+ const messageItem: MessageOutputItem = {
658
+ id: messageItemId,
659
+ type: 'message',
660
+ role: 'assistant',
661
+ status: 'in_progress',
662
+ content: [],
663
+ };
664
+ const miIndex = outputItems.length;
665
+ outputItems.push(messageItem);
666
+ outputIndex = miIndex;
667
+ writeSSEEvent(res, 'response.output_item.added', { output_index: miIndex, item: messageItem });
668
+ const textPart = { type: 'output_text' as const, text: '', annotations: [] as never[] };
669
+ writeSSEEvent(res, 'response.content_part.added', {
670
+ item_id: messageItemId,
671
+ output_index: miIndex,
672
+ content_index: 0,
673
+ part: textPart,
674
+ });
675
+ messageText = finalText;
676
+ writeSSEEvent(res, 'response.output_text.delta', {
677
+ item_id: messageItemId,
678
+ output_index: miIndex,
679
+ content_index: 0,
680
+ delta: finalText,
681
+ });
682
+ }
683
+
684
+ if (hasEmittedMessage && messageItemId && !skipMessageItem) {
685
+ const miIndex = outputItems.findIndex((i) => i.id === messageItemId);
686
+ const contentIndex = 0;
687
+
688
+ writeSSEEvent(res, 'response.output_text.done', {
689
+ item_id: messageItemId,
690
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
691
+ content_index: contentIndex,
692
+ text: finalText,
693
+ });
694
+
695
+ const textPart = { type: 'output_text' as const, text: finalText, annotations: [] as never[] };
696
+ writeSSEEvent(res, 'response.content_part.done', {
697
+ item_id: messageItemId,
698
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
699
+ content_index: contentIndex,
700
+ part: textPart,
701
+ });
702
+
703
+ const messageItem: MessageOutputItem = {
704
+ id: messageItemId,
705
+ type: 'message',
706
+ role: 'assistant',
707
+ status: mapFinishReasonToStatus(event.finishReason),
708
+ content: [textPart],
709
+ };
710
+ if (miIndex >= 0) {
711
+ outputItems[miIndex] = messageItem;
712
+ }
713
+ writeSSEEvent(res, 'response.output_item.done', {
714
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
715
+ item: messageItem,
716
+ });
717
+ } else if (hasEmittedMessage && messageItemId && skipMessageItem) {
718
+ // A message item was started (output_item.added / content_part.added events already
719
+ // sent to the client) but we now know it should be suppressed because the final
720
+ // text is empty and there are tool calls. Send proper done events to close out
721
+ // the item gracefully so clients do not see a dangling in-progress item, then
722
+ // remove it from outputItems so it does not appear in the completed response.
723
+ const miIndex = outputItems.findIndex((i) => i.id === messageItemId);
724
+ const miOutputIndex = miIndex >= 0 ? miIndex : outputIndex;
725
+
726
+ writeSSEEvent(res, 'response.output_text.done', {
727
+ item_id: messageItemId,
728
+ output_index: miOutputIndex,
729
+ content_index: 0,
730
+ text: '',
731
+ });
732
+
733
+ const emptyTextPart = { type: 'output_text' as const, text: '', annotations: [] as never[] };
734
+ writeSSEEvent(res, 'response.content_part.done', {
735
+ item_id: messageItemId,
736
+ output_index: miOutputIndex,
737
+ content_index: 0,
738
+ part: emptyTextPart,
739
+ });
740
+
741
+ const closedMessageItem: MessageOutputItem = {
742
+ id: messageItemId,
743
+ type: 'message',
744
+ role: 'assistant',
745
+ status: 'completed',
746
+ content: [],
747
+ };
748
+ writeSSEEvent(res, 'response.output_item.done', {
749
+ output_index: miOutputIndex,
750
+ item: closedMessageItem,
751
+ });
752
+
753
+ // Track suppressed index for exclusion from final response
754
+ // but keep in array so subsequent output_index values remain unique.
755
+ if (miIndex >= 0) {
756
+ suppressedMessageIndex = miIndex;
757
+ }
758
+ }
759
+
760
+ // Collect function_call items but defer SSE emission until
761
+ // after the commit gate — otherwise clients can see completed
762
+ // tool calls from a turn the session later refuses to commit.
763
+ for (const tc of event.toolCalls.filter((t) => t.status === 'ok')) {
764
+ const callId = tc.id ?? genId('call_');
765
+ const fcItem: FunctionCallOutputItem = {
766
+ id: genId('fc_'),
767
+ type: 'function_call',
768
+ call_id: callId,
769
+ name: tc.name,
770
+ arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments),
771
+ status: 'completed',
772
+ };
773
+ outputItems.push(fcItem);
774
+ }
775
+
776
+ // Build the terminal but do NOT emit `response.completed` yet:
777
+ // commit signal only becomes authoritative after the producer's
778
+ // finally runs. Break so for-await cleanup triggers that finally,
779
+ // then the post-loop block handles emission + persistence.
780
+ const promptTokens = event.promptTokens ?? 0;
781
+ const reasoningTokens = event.reasoningTokens ?? 0;
782
+ cachedTokens = event.cachedTokens;
783
+ const usage: ResponseObject['usage'] = {
784
+ input_tokens: promptTokens,
785
+ output_tokens: event.numTokens,
786
+ output_tokens_details: { reasoning_tokens: reasoningTokens },
787
+ total_tokens: promptTokens + event.numTokens,
788
+ };
789
+ // Round 5 Fix #3: SSE headers flush before the native prefix
790
+ // verifier has reported cached-token counts, so streaming
791
+ // `X-Session-Cache` is documented as non-authoritative. The
792
+ // authoritative signal for streaming clients is this in-band
793
+ // `usage.input_tokens_details.cached_tokens` field on the
794
+ // terminal `response.completed` event — identical shape to
795
+ // the upstream OpenAI Responses API. Populated only when the
796
+ // native dispatch reports a non-zero reuse count so consumers
797
+ // cheaply distinguish "feature not active" from "active with
798
+ // zero reuse".
799
+ if (cachedTokens != null && cachedTokens > 0) {
800
+ usage.input_tokens_details = { cached_tokens: cachedTokens };
801
+ }
802
+ mergeTimingUsageExtensions(usage, event.performance, promptTokens, event.numTokens, cachedTokens, serverTiming);
803
+
804
+ const finalOutput = outputItems.filter((_, idx) => idx !== suppressedMessageIndex);
805
+ completedResponse = {
806
+ ...partial,
807
+ status: mapFinishReasonToStatus(event.finishReason),
808
+ output: finalOutput,
809
+ output_text: computeOutputText(finalOutput),
810
+ incomplete_details: event.finishReason === 'length' ? { reason: 'max_output_tokens' } : null,
811
+ usage,
812
+ };
813
+ break;
814
+ }
815
+
816
+ // Delta event
817
+ if (event.isReasoning) {
818
+ // Filter out </think> tag from reasoning deltas
819
+ const deltaText = event.text.replace(/<\/think>/g, '');
820
+ if (!deltaText) continue; // Skip empty deltas (e.g., just the </think> token)
821
+
822
+ if (!hasEmittedReasoning) {
823
+ // First reasoning chunk -- add reasoning item
824
+ hasEmittedReasoning = true;
825
+ reasoningItemId = genId('rs_');
826
+ const reasoningItem: ReasoningOutputItem = {
827
+ id: reasoningItemId,
828
+ type: 'reasoning',
829
+ summary: [],
830
+ };
831
+ const riIndex = outputItems.length;
832
+ outputItems.push(reasoningItem);
833
+
834
+ writeSSEEvent(res, 'response.output_item.added', { output_index: riIndex, item: reasoningItem });
835
+ }
836
+ reasoningText += deltaText;
837
+ writeSSEEvent(res, 'response.reasoning_summary_text.delta', {
838
+ item_id: reasoningItemId,
839
+ output_index: outputItems.findIndex((i) => i.id === reasoningItemId),
840
+ summary_index: 0,
841
+ delta: deltaText,
842
+ });
843
+ } else {
844
+ // Transition from reasoning to assistant text: close the reasoning
845
+ // output item BEFORE emitting any `response.output_item.added` for
846
+ // the message. OpenAI Responses clients (e.g. pi-mono) maintain a
847
+ // single `currentBlock` state — opening the message item while the
848
+ // reasoning item is still "in progress" overwrites that state, so
849
+ // the later `output_item.done` for reasoning fails its
850
+ // `currentBlock?.type === 'thinking'` guard and never sets
851
+ // `thinkingSignature`. Without the signature the next turn cannot
852
+ // echo the reasoning item back, and any thinking-model agent loses
853
+ // its chain of thought on each turn. Must run before the first
854
+ // message write on this branch.
855
+ if (hasEmittedReasoning && !hasClosedReasoning && reasoningItemId) {
856
+ hasClosedReasoning = true;
857
+ const riIndex = outputItems.findIndex((i) => i.id === reasoningItemId);
858
+ writeSSEEvent(res, 'response.reasoning_summary_text.done', {
859
+ item_id: reasoningItemId,
860
+ output_index: riIndex,
861
+ summary_index: 0,
862
+ text: reasoningText,
863
+ });
864
+ const reasoningItem: ReasoningOutputItem = {
865
+ id: reasoningItemId,
866
+ type: 'reasoning',
867
+ summary: [{ type: 'summary_text', text: reasoningText }],
868
+ };
869
+ if (riIndex >= 0) {
870
+ outputItems[riIndex] = reasoningItem;
871
+ }
872
+ writeSSEEvent(res, 'response.output_item.done', {
873
+ output_index: riIndex >= 0 ? riIndex : 0,
874
+ item: reasoningItem,
875
+ });
876
+ }
877
+ // Text delta with tool_call tag buffering
878
+ const { safeText, tagFound, cleanPrefix } = tagBuffer.push(event.text);
879
+ if (tagFound) {
880
+ // Emit any clean text before the tag.
881
+ // Trim whitespace-only prefixes: whitespace immediately before <tool_call>
882
+ // is always markup-related (e.g. "\n<tool_call>"), not user-visible content.
883
+ // Emitting it would create a dangling message item that needs special-casing
884
+ // at finalization when skipMessageItem is true.
885
+ if (cleanPrefix.trim()) {
886
+ if (!hasEmittedMessage) {
887
+ hasEmittedMessage = true;
888
+ messageItemId = genId('msg_');
889
+ const messageItem: MessageOutputItem = {
890
+ id: messageItemId,
891
+ type: 'message',
892
+ role: 'assistant',
893
+ status: 'in_progress',
894
+ content: [],
895
+ };
896
+ const miIndex = outputItems.length;
897
+ outputItems.push(messageItem);
898
+ outputIndex = miIndex;
899
+ writeSSEEvent(res, 'response.output_item.added', { output_index: miIndex, item: messageItem });
900
+ const textPart = { type: 'output_text' as const, text: '', annotations: [] as never[] };
901
+ writeSSEEvent(res, 'response.content_part.added', {
902
+ item_id: messageItemId,
903
+ output_index: miIndex,
904
+ content_index: 0,
905
+ part: textPart,
906
+ });
907
+ }
908
+ messageText += cleanPrefix;
909
+ writeSSEEvent(res, 'response.output_text.delta', {
910
+ item_id: messageItemId,
911
+ output_index: outputItems.findIndex((i) => i.id === messageItemId),
912
+ content_index: 0,
913
+ delta: cleanPrefix,
914
+ });
915
+ }
916
+ } else if (safeText) {
917
+ if (!hasEmittedMessage) {
918
+ hasEmittedMessage = true;
919
+ messageItemId = genId('msg_');
920
+ const messageItem: MessageOutputItem = {
921
+ id: messageItemId,
922
+ type: 'message',
923
+ role: 'assistant',
924
+ status: 'in_progress',
925
+ content: [],
926
+ };
927
+ const miIndex = outputItems.length;
928
+ outputItems.push(messageItem);
929
+ outputIndex = miIndex;
930
+ writeSSEEvent(res, 'response.output_item.added', { output_index: miIndex, item: messageItem });
931
+ const textPart = { type: 'output_text' as const, text: '', annotations: [] as never[] };
932
+ writeSSEEvent(res, 'response.content_part.added', {
933
+ item_id: messageItemId,
934
+ output_index: miIndex,
935
+ content_index: 0,
936
+ part: textPart,
937
+ });
938
+ }
939
+ messageText += safeText;
940
+ writeSSEEvent(res, 'response.output_text.delta', {
941
+ item_id: messageItemId,
942
+ output_index: outputItems.findIndex((i) => i.id === messageItemId),
943
+ content_index: 0,
944
+ delta: safeText,
945
+ });
946
+ }
947
+ }
948
+ }
949
+ } catch (err: unknown) {
950
+ // Capture mid-decode throws so the post-loop block routes to the
951
+ // failure epilogue and emits `response.failed` — otherwise the
952
+ // error would escape into the outer JSON error path with SSE
953
+ // headers already on the wire.
954
+ thrownError = err instanceof Error ? err : new Error(String(err));
955
+ // Surface the message to stderr even on the failure path — without
956
+ // this the native side (e.g. `Tokenizer encoded <turn|> to N
957
+ // tokens; expected 1`) is invisible to operators since the SSE
958
+ // `response.failed` payload only carries `incomplete_details.reason`
959
+ // and never the underlying exception text.
960
+ console.error(`[responses] native dispatch failed for ${req.model} (response ${responseId}):`, thrownError.message);
961
+ } finally {
962
+ // Cover done/break/continue/generator-throw paths. Abort listeners belong
963
+ // to the outer wrapper and intentionally remain installed after this.
964
+ await drainPending();
965
+ }
966
+
967
+ // Post-loop terminal emission. The producer's finally has run so
968
+ // `wasCommitted()` reads an authoritative baseline. On success emit
969
+ // `response.completed`; otherwise route through the failure epilogue
970
+ // with one of `finish_reason_error` / `error` / `client_abort` /
971
+ // `stream_exhausted`. `response.failed` is emitted even on
972
+ // `client_abort` so a tee/proxy that stays connected sees a terminal.
973
+ const committed = wasCommitted();
974
+ const successful = sawDone && committed && thrownError == null && !abort.aborted;
975
+
976
+ if (successful) {
977
+ const terminal = completedResponse!;
978
+
979
+ // Emit deferred function_call events now that the commit gate
980
+ // passed — held until here so clients never see completed tool
981
+ // calls from an uncommitted turn.
982
+ for (const item of terminal.output) {
983
+ if (item.type === 'function_call') {
984
+ const fcIndex = outputItems.indexOf(item);
985
+ writeSSEEvent(res, 'response.output_item.added', { output_index: fcIndex, item });
986
+ const argsStr = item.arguments;
987
+ writeSSEEvent(res, 'response.function_call_arguments.delta', {
988
+ item_id: item.id,
989
+ output_index: fcIndex,
990
+ delta: argsStr,
991
+ });
992
+ writeSSEEvent(res, 'response.function_call_arguments.done', {
993
+ item_id: item.id,
994
+ output_index: fcIndex,
995
+ arguments: argsStr,
996
+ });
997
+ writeSSEEvent(res, 'response.output_item.done', { output_index: fcIndex, item });
998
+ }
999
+ }
1000
+
1001
+ await drainPending();
1002
+
1003
+ // A close/error can be the event that settled the residual drain. Recheck
1004
+ // after the await; the pre-drain success snapshot is no longer sufficient.
1005
+ if (!abort.aborted) {
1006
+ // The terminal SSE flushes inside the per-model mutex (client
1007
+ // expects it ordered against prior deltas); the `ResponseStore`
1008
+ // write is deferred to the outer handler so a slow SQLite write
1009
+ // does not pin the next waiter. `flushTerminalSSE` flips
1010
+ // `terminalEmitted` only once the kernel acks the frame — a
1011
+ // callback-reported error rejects so the outer catch refuses to
1012
+ // adopt under an unseen responseId.
1013
+ await flushTerminalSSE(res, 'response.completed', { response: terminal }, visibility);
1014
+ endSSE(res);
1015
+ return { terminalToPersist: terminal, failureMode: null, cachedTokens };
1016
+ }
1017
+ }
1018
+
1019
+ // Failure epilogue. Close any dangling message items BEFORE the
1020
+ // terminal so clients tracking `output_index` see matching closes.
1021
+ // Function_call items are never emitted on failure (their SSE is
1022
+ // deferred to the success path); reasoning items have no `status`.
1023
+ const reason: 'error' | 'client_abort' | 'finish_reason_error' | 'stream_exhausted' = thrownError
1024
+ ? 'error'
1025
+ : abort.aborted
1026
+ ? 'client_abort'
1027
+ : sawDone
1028
+ ? 'finish_reason_error'
1029
+ : 'stream_exhausted';
1030
+
1031
+ // Prefer captured usage on a finish_reason_error path so clients
1032
+ // still see what was spent; synthesize zero-usage only when no done
1033
+ // event was ever observed.
1034
+ const usage: ResponseObject['usage'] = completedResponse?.usage ?? {
1035
+ input_tokens: 0,
1036
+ output_tokens: 0,
1037
+ output_tokens_details: { reasoning_tokens: 0 },
1038
+ total_tokens: 0,
1039
+ };
1040
+
1041
+ const finalOutput = outputItems.filter((_, idx) => idx !== suppressedMessageIndex);
1042
+
1043
+ // Flush still-open message items before the terminal. Only on the
1044
+ // non-sawDone path — the done branch emits its own closes before
1045
+ // breaking out.
1046
+ if (!sawDone && hasEmittedMessage && messageItemId != null) {
1047
+ const miIndex = outputItems.findIndex((i) => i.id === messageItemId);
1048
+ writeSSEEvent(res, 'response.output_text.done', {
1049
+ item_id: messageItemId,
1050
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
1051
+ content_index: 0,
1052
+ text: messageText,
1053
+ });
1054
+ const textPart = { type: 'output_text' as const, text: messageText, annotations: [] as never[] };
1055
+ writeSSEEvent(res, 'response.content_part.done', {
1056
+ item_id: messageItemId,
1057
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
1058
+ content_index: 0,
1059
+ part: textPart,
1060
+ });
1061
+ const closedMessageItem: MessageOutputItem = {
1062
+ id: messageItemId,
1063
+ type: 'message',
1064
+ role: 'assistant',
1065
+ status: 'incomplete',
1066
+ content: messageText ? [textPart] : [],
1067
+ };
1068
+ if (miIndex >= 0) {
1069
+ outputItems[miIndex] = closedMessageItem;
1070
+ finalOutput[miIndex] = closedMessageItem;
1071
+ }
1072
+ writeSSEEvent(res, 'response.output_item.done', {
1073
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
1074
+ item: closedMessageItem,
1075
+ });
1076
+ }
1077
+ if (!sawDone && hasEmittedReasoning && !hasClosedReasoning && reasoningItemId != null) {
1078
+ // No `status` field on reasoning items — just emit closes so
1079
+ // client-side output_index bookkeeping stays consistent.
1080
+ hasClosedReasoning = true;
1081
+ writeSSEEvent(res, 'response.reasoning_summary_text.done', {
1082
+ item_id: reasoningItemId,
1083
+ output_index: outputItems.findIndex((i) => i.id === reasoningItemId),
1084
+ summary_index: 0,
1085
+ text: reasoningText,
1086
+ });
1087
+ const riIndex = outputItems.findIndex((i) => i.id === reasoningItemId);
1088
+ if (riIndex >= 0) {
1089
+ const reasoningItem: ReasoningOutputItem = {
1090
+ id: reasoningItemId,
1091
+ type: 'reasoning',
1092
+ summary: [{ type: 'summary_text', text: reasoningText }],
1093
+ };
1094
+ outputItems[riIndex] = reasoningItem;
1095
+ finalOutput[riIndex] = reasoningItem;
1096
+ writeSSEEvent(res, 'response.output_item.done', { output_index: riIndex, item: reasoningItem });
1097
+ }
1098
+ }
1099
+
1100
+ await drainPending();
1101
+
1102
+ const failedTerminal = buildFailedTerminal(
1103
+ partial,
1104
+ finalOutput,
1105
+ reason,
1106
+ usage,
1107
+ reason === 'error' && thrownError ? thrownError.message : null,
1108
+ );
1109
+ await flushTerminalSSE(res, 'response.failed', { response: failedTerminal }, visibility);
1110
+ endSSE(res);
1111
+ // No terminalToPersist on an uncommitted turn: a later continuation
1112
+ // that cold-replayed this record would silently resurrect failed
1113
+ // output as authoritative history. `cachedTokens` is meaningless on
1114
+ // the failure path but returned verbatim to keep the type shape
1115
+ // uniform — consumers already treat a non-null `failureMode` as the
1116
+ // authoritative "do not use these numbers" signal.
1117
+ return { terminalToPersist: null, failureMode: reason, cachedTokens };
1118
+ }
1119
+
1120
+ // ---------------------------------------------------------------------------
1121
+ // Session routing
1122
+ // ---------------------------------------------------------------------------
1123
+
1124
+ /**
1125
+ * Return the ordered sibling call ids for the trailing assistant
1126
+ * fan-out (if any calls remain unresolved), else `null`. MUST be
1127
+ * invoked on the STORED prior chain, never on the augmented `messages`
1128
+ * list — otherwise an echoed `function_call` could overwrite the
1129
+ * trailing assistant with a forged single-call turn.
1130
+ */
1131
+ function extractOutstandingToolCallIds(messages: ChatMessage[]): string[] | null {
1132
+ let lastAssistantWithCallsIdx = -1;
1133
+ for (let i = messages.length - 1; i >= 0; i--) {
1134
+ const msg = messages[i];
1135
+ if (msg?.role === 'assistant') {
1136
+ const tcs = msg.toolCalls ?? [];
1137
+ if (tcs.length > 0) {
1138
+ lastAssistantWithCallsIdx = i;
1139
+ }
1140
+ break;
1141
+ }
1142
+ }
1143
+ if (lastAssistantWithCallsIdx === -1) {
1144
+ return null;
1145
+ }
1146
+ const trailingAssistant = messages[lastAssistantWithCallsIdx]!;
1147
+ const orderedIds: string[] = [];
1148
+ for (const tc of trailingAssistant.toolCalls ?? []) {
1149
+ if (typeof tc.id === 'string' && tc.id.length > 0) {
1150
+ orderedIds.push(tc.id);
1151
+ }
1152
+ }
1153
+ if (orderedIds.length === 0) {
1154
+ return null;
1155
+ }
1156
+ const outstanding = new Set(orderedIds);
1157
+ for (let j = lastAssistantWithCallsIdx + 1; j < messages.length; j++) {
1158
+ const m = messages[j];
1159
+ if (m?.role === 'tool' && typeof m.toolCallId === 'string' && m.toolCallId.length > 0) {
1160
+ outstanding.delete(m.toolCallId);
1161
+ }
1162
+ }
1163
+ if (outstanding.size === 0) {
1164
+ return null;
1165
+ }
1166
+ return orderedIds.filter((id) => outstanding.has(id));
1167
+ }
1168
+
1169
+ /**
1170
+ * Set of `call_id`s owned by the trailing assistant turn, used to
1171
+ * authenticate echoed `function_call` items in a `previous_response_id`
1172
+ * continuation. Ownership check only — `name` / `arguments` are not
1173
+ * compared against the stored payload (clients commonly reserialize
1174
+ * their own arguments with different whitespace). Returns `null` when
1175
+ * the trailing message is not an assistant fan-out.
1176
+ */
1177
+ function buildTrailingAssistantToolCallIds(messages: ChatMessage[]): Set<string> | null {
1178
+ for (let i = messages.length - 1; i >= 0; i--) {
1179
+ const msg = messages[i];
1180
+ if (msg?.role === 'assistant') {
1181
+ const ids = new Set<string>();
1182
+ for (const tc of msg.toolCalls ?? []) {
1183
+ if (typeof tc.id === 'string' && tc.id.length > 0) {
1184
+ ids.add(tc.id);
1185
+ }
1186
+ }
1187
+ return ids.size > 0 ? ids : null;
1188
+ }
1189
+ }
1190
+ return null;
1191
+ }
1192
+
1193
+ /**
1194
+ * Reorder tool messages in `messages[startOffset, blockEnd)` to match
1195
+ * `expectedOrder`. Replay correctness for a multi-call fan-out depends
1196
+ * on POSITION — several native backends drop the id on the wire and
1197
+ * pair results to calls by sibling index, so a reordered submission
1198
+ * would silently bind results to the wrong calls even after the
1199
+ * id-set gate passes.
1200
+ *
1201
+ * `blockEnd` MUST be sized to a single contiguous tool block; the
1202
+ * full-history walker computes one per fan-out. No-op when any
1203
+ * precondition fails.
1204
+ */
1205
+ function canonicalizeToolMessageOrder(
1206
+ messages: ChatMessage[],
1207
+ startOffset: number,
1208
+ blockEnd: number,
1209
+ expectedOrder: readonly string[],
1210
+ ): void {
1211
+ const toolPositions: number[] = [];
1212
+ const byId = new Map<string, ChatMessage>();
1213
+ for (let i = startOffset; i < blockEnd; i++) {
1214
+ const m = messages[i]!;
1215
+ if (m.role === 'tool' && typeof m.toolCallId === 'string' && m.toolCallId.length > 0) {
1216
+ toolPositions.push(i);
1217
+ byId.set(m.toolCallId, m);
1218
+ }
1219
+ }
1220
+ if (toolPositions.length !== expectedOrder.length) return;
1221
+ for (const id of expectedOrder) {
1222
+ if (!byId.has(id)) return;
1223
+ }
1224
+ let alreadyOrdered = true;
1225
+ for (let k = 0; k < toolPositions.length; k++) {
1226
+ if (messages[toolPositions[k]!]!.toolCallId !== expectedOrder[k]) {
1227
+ alreadyOrdered = false;
1228
+ break;
1229
+ }
1230
+ }
1231
+ if (alreadyOrdered) return;
1232
+ for (let k = 0; k < toolPositions.length; k++) {
1233
+ messages[toolPositions[k]!] = byId.get(expectedOrder[k]!)!;
1234
+ }
1235
+ }
1236
+
1237
+ /**
1238
+ * Walk the full `messages` history, validate each assistant fan-out's
1239
+ * tool-result block, and canonicalize each block to sibling order in
1240
+ * place. Invoked on stateless cold-start histories and on the
1241
+ * Anthropic `/v1/messages` endpoint (both feed caller-supplied tool
1242
+ * order straight into `primeHistory()` without the continuation gate).
1243
+ *
1244
+ * Validation rejects: orphan tool messages, unknown `toolCallId`s,
1245
+ * missing/duplicate resolutions, and a trailing unresolved fan-out in
1246
+ * a stateless history. Returns `null` on success or a human-readable
1247
+ * error string (sent as 400 `invalid_request_error`).
1248
+ *
1249
+ * @param apiSurface controls error-string vocabulary (`openai` default
1250
+ * uses `function_call_output` / `call_id`; `anthropic` uses
1251
+ * `tool_result` / `tool_use_id`). Validation logic is identical.
1252
+ */
1253
+ export function validateAndCanonicalizeHistoryToolOrder(
1254
+ messages: ChatMessage[],
1255
+ apiSurface: 'openai' | 'anthropic' = 'openai',
1256
+ ): string | null {
1257
+ const vocab =
1258
+ apiSurface === 'anthropic'
1259
+ ? {
1260
+ toolResult: 'tool_result',
1261
+ toolCallId: 'tool_use_id',
1262
+ fanOut: 'assistant turn with tool_use blocks',
1263
+ }
1264
+ : {
1265
+ toolResult: 'function_call_output',
1266
+ toolCallId: 'call_id',
1267
+ fanOut: 'assistant fan-out',
1268
+ };
1269
+
1270
+ let i = 0;
1271
+ while (i < messages.length) {
1272
+ const m = messages[i]!;
1273
+ if (m.role === 'tool') {
1274
+ return (
1275
+ `tool message at index ${i} (${vocab.toolCallId} "${m.toolCallId ?? ''}") is not preceded by an ` +
1276
+ `${vocab.fanOut}. Every ${vocab.toolResult} must immediately follow the assistant turn whose ` +
1277
+ `tool calls include its ${vocab.toolCallId}.`
1278
+ );
1279
+ }
1280
+ if (m.role !== 'assistant' || !m.toolCalls || m.toolCalls.length === 0) {
1281
+ i++;
1282
+ continue;
1283
+ }
1284
+
1285
+ // Assistant fan-out. Collect declared sibling ids.
1286
+ const declaredIds: string[] = [];
1287
+ const declaredSet = new Set<string>();
1288
+ for (const tc of m.toolCalls) {
1289
+ const id = typeof tc.id === 'string' ? tc.id : null;
1290
+ if (id === null || id.length === 0) {
1291
+ return (
1292
+ `${vocab.fanOut} at index ${i} declares a tool call with no id, which cannot be paired ` +
1293
+ `with its ${vocab.toolResult} positionally.`
1294
+ );
1295
+ }
1296
+ if (declaredSet.has(id)) {
1297
+ return (
1298
+ `${vocab.fanOut} at index ${i} declares duplicate ${vocab.toolCallId} "${id}". Each sibling ` +
1299
+ `call must have a unique ${vocab.toolCallId}.`
1300
+ );
1301
+ }
1302
+ declaredIds.push(id);
1303
+ declaredSet.add(id);
1304
+ }
1305
+
1306
+ // Read the contiguous tool block following the fan-out.
1307
+ const blockStart = i + 1;
1308
+ let blockEnd = blockStart;
1309
+ const seenInBlock = new Set<string>();
1310
+ while (blockEnd < messages.length && messages[blockEnd]!.role === 'tool') {
1311
+ const tool = messages[blockEnd]!;
1312
+ const id = typeof tool.toolCallId === 'string' ? tool.toolCallId : null;
1313
+ if (id === null || id.length === 0) {
1314
+ return (
1315
+ `tool message at index ${blockEnd} is missing ${vocab.toolCallId}. Every ${vocab.toolResult} ` +
1316
+ `in an ${vocab.fanOut}'s resolution block must carry the ${vocab.toolCallId} it resolves.`
1317
+ );
1318
+ }
1319
+ if (!declaredSet.has(id)) {
1320
+ return (
1321
+ `tool message at index ${blockEnd} references ${vocab.toolCallId} "${id}", which is not ` +
1322
+ `declared by the preceding ${vocab.fanOut} at index ${i}. Submitting a ${vocab.toolResult} ` +
1323
+ `for an undeclared ${vocab.toolCallId} would silently bind output to the wrong sibling.`
1324
+ );
1325
+ }
1326
+ if (seenInBlock.has(id)) {
1327
+ return (
1328
+ `duplicate tool message for ${vocab.toolCallId} "${id}" inside the ${vocab.fanOut}'s ` +
1329
+ `resolution block (index ${blockEnd}). Each outstanding sibling must be resolved exactly once.`
1330
+ );
1331
+ }
1332
+ seenInBlock.add(id);
1333
+ blockEnd++;
1334
+ }
1335
+
1336
+ const blockLength = blockEnd - blockStart;
1337
+ if (blockLength === 0) {
1338
+ // Trailing unresolved fan-out is rejected — a stateless history
1339
+ // has nothing for the model to continue from. Mid-history the
1340
+ // next non-tool turn orphans the fan-out.
1341
+ if (blockEnd === messages.length) {
1342
+ return (
1343
+ `${vocab.fanOut} at index ${i} is the trailing turn of the history but has no ` +
1344
+ `${vocab.toolResult} resolutions. A stateless cold-start history cannot end on an ` +
1345
+ `unresolved tool-call fan-out because there is nothing for the model to continue from.`
1346
+ );
1347
+ }
1348
+ return (
1349
+ `${vocab.fanOut} at index ${i} declares ${declaredIds.length} tool call${declaredIds.length === 1 ? '' : 's'} ` +
1350
+ `but the next message at index ${blockEnd} is a ${messages[blockEnd]!.role} turn. Every fan-out ` +
1351
+ `must be fully resolved by ${vocab.toolResult} messages before the next assistant/user/system turn.`
1352
+ );
1353
+ }
1354
+ if (blockLength < declaredIds.length) {
1355
+ const missing = declaredIds.filter((id) => !seenInBlock.has(id));
1356
+ return (
1357
+ `${vocab.fanOut} at index ${i} has unresolved sibling tool calls: ${missing.join(', ')}. ` +
1358
+ `Every declared tool call must be answered by a ${vocab.toolResult} before the next turn.`
1359
+ );
1360
+ }
1361
+ // blockLength > declaredIds.length is impossible (every id is in
1362
+ // declaredSet and seenInBlock dedupes).
1363
+
1364
+ canonicalizeToolMessageOrder(messages, blockStart, blockEnd, declaredIds);
1365
+ i = blockEnd;
1366
+ }
1367
+
1368
+ return null;
1369
+ }
1370
+
1371
+ /**
1372
+ * Non-streaming dispatch outcome. `committed` is measured against a
1373
+ * baseline captured AFTER any internal `session.reset()` so it is
1374
+ * honest across the multi-message reset-and-restart branch — a
1375
+ * pre-helper snapshot would be stale. Uncommitted dispatches must
1376
+ * never be adopted: their KV state is out of sync with persistence.
1377
+ */
1378
+ interface NonStreamingOutcome {
1379
+ result: ChatResult;
1380
+ committed: boolean;
1381
+ }
1382
+
1383
+ /** Streaming dispatch outcome. `wasCommitted()` is valid only AFTER
1384
+ * the SSE writer has drained the stream. */
1385
+ interface StreamingOutcome {
1386
+ stream: AsyncGenerator<ChatStreamEvent>;
1387
+ wasCommitted(): boolean;
1388
+ }
1389
+
1390
+ /**
1391
+ * Route a non-streaming request through `ChatSession`. Cold path
1392
+ * (fresh session) runs `primeHistory` + `startFromHistory`; hot path
1393
+ * uses `send` / `sendToolResult` for a single new message, or falls
1394
+ * back to reset + cold re-prime on multi-message input. The caller
1395
+ * is responsible for rejecting partial tool-result submissions
1396
+ * against a fan-out (`handleCreateResponse` fan-out gate).
1397
+ *
1398
+ * `resetNativeCache` is the cache-isolation decision made by the caller.
1399
+ * A non-paged registry miss wipes the shared native model's
1400
+ * leftover `cached_token_history` + KV caches before re-priming, or
1401
+ * a previous UNRELATED request's cache could silently get reused as
1402
+ * a prefix (cross-request cache-affinity side channel). Registry hits
1403
+ * and block-paged models preserve native state: the former owns the
1404
+ * leased cache, while the latter validates reusable blocks by content
1405
+ * hash and isolates live requests by cache owner. In both cases
1406
+ * `verify_cache_prefix_direct` can recover the reused prefix on the
1407
+ * next `chat_session_start_sync`. The HIT branch calls the
1408
+ * server-private `resetPreservingNativeCacheForWarmReuse(session)`
1409
+ * helper from `../chat-session-warm-reuse.js` instead of the public
1410
+ * `reset()` to thread this distinction down to the JS-side state
1411
+ * clear. The helper lives inside `@mlx-node/server` and is never
1412
+ * re-exported from either `@mlx-node/lm` or `@mlx-node/server`'s
1413
+ * public surface, so downstream consumers cannot discover or invoke
1414
+ * it.
1415
+ */
1416
+ async function runSessionNonStreaming(
1417
+ session: ChatSession<SessionCapableModel>,
1418
+ messages: ChatMessage[],
1419
+ newInputMessages: ChatMessage[],
1420
+ config: ChatConfig,
1421
+ resetNativeCache: boolean,
1422
+ signal?: AbortSignal,
1423
+ ): Promise<NonStreamingOutcome> {
1424
+ if (session.turns === 0) {
1425
+ // Fresh JS session does NOT imply a fresh native cache — the
1426
+ // underlying `SessionCapableModel` is shared across every
1427
+ // `ChatSession` lifetime via `ModelRegistry`, and its native
1428
+ // `cached_token_history` + KV caches persist across requests.
1429
+ // After the native refactor moved the unconditional cache wipe
1430
+ // out of `chat_session_start_sync` into the miss branch of
1431
+ // `verify_cache_prefix_direct`, a MISS path that runs
1432
+ // `primeHistory() + startFromHistory()` on a fresh session would
1433
+ // inherit the PREVIOUS request's native cache and silently reuse
1434
+ // whatever prefix happened to overlap — a cross-request
1435
+ // cache-affinity side channel. The caller permits native reuse only
1436
+ // for a registry hit or a content-addressed block-paged model. In
1437
+ // both cases we still clear JS-side state so `primeHistory()` accepts
1438
+ // the replay while the native prefix verifier decides what is reusable.
1439
+ if (resetNativeCache) {
1440
+ await session.reset();
1441
+ } else {
1442
+ await resetPreservingNativeCacheForWarmReuse(session);
1443
+ }
1444
+ session.primeHistory(messages);
1445
+ const initialTurns = session.turns;
1446
+ const result = await session.startFromHistory(config, { signal });
1447
+ return { result, committed: session.turns > initialTurns };
1448
+ }
1449
+
1450
+ // Hot path — session's KV cache is already warmed for this chain.
1451
+ // Single-message continuations whose role is `user` or `tool` take
1452
+ // the session paths (`send` / `sendToolResult`), which render the full
1453
+ // transcript and reuse KV on an exact token-prefix match. Any other single
1454
+ // role (`assistant`, `system`) is still accepted by `mapRequest` —
1455
+ // `reconstructMessagesFromChain` + `primeHistory` tolerate a tail of
1456
+ // either — but the high-level chat-session API has no entry point for
1457
+ // them, so fall through to reset + cold re-prime against the fully
1458
+ // rebuilt history. Returning 500 here would regress the pre-session-
1459
+ // API full-history path, making valid continuation payloads fail
1460
+ // nondeterministically based on cache state.
1461
+ if (newInputMessages.length === 1) {
1462
+ const last = newInputMessages[0]!;
1463
+ if (last.role === 'user') {
1464
+ const initialTurns = session.turns;
1465
+ const images = last.images ?? undefined;
1466
+ const result = await session.send(last.content, images ? { images, config, signal } : { config, signal });
1467
+ return { result, committed: session.turns > initialTurns };
1468
+ }
1469
+ if (last.role === 'tool') {
1470
+ if (!last.toolCallId) {
1471
+ throw new Error('tool message missing toolCallId');
1472
+ }
1473
+ const initialTurns = session.turns;
1474
+ // Forward the structured `isError` field through to the native
1475
+ // renderer so the wire-format `[tool error]` marker stays in sync
1476
+ // with the Anthropic `tool_result.is_error === true` source field
1477
+ // (the structured channel is the authoritative signal — see
1478
+ // `ChatMessage.isError` rustdoc).
1479
+ const result = await session.sendToolResult(last.toolCallId, last.content, {
1480
+ config,
1481
+ isError: last.isError,
1482
+ signal,
1483
+ });
1484
+ return { result, committed: session.turns > initialTurns };
1485
+ }
1486
+ // Non-user / non-tool single-message continuation (assistant /
1487
+ // system) falls through to the multi-message reset + cold re-prime
1488
+ // branch below.
1489
+ }
1490
+
1491
+ // Multi-message (or single non-user/non-tool) hot path: reset + cold
1492
+ // re-prime. `initialTurns` MUST be captured AFTER `session.reset()`
1493
+ // zeroes `turns`, otherwise the committed check reads stale.
1494
+ // Amortized: the caller re-keys this session under the new
1495
+ // responseId on success. On a tier-1 / tier-2 HIT that landed here
1496
+ // (full-history replay is NOT a single-message delta, so even a
1497
+ // warm session falls through to this branch), we MUST keep the
1498
+ // native KV cache so the native `verify_cache_prefix_direct` can
1499
+ // recover the reused prefix — wiping it would neutralize the
1500
+ // entire warm-lease feature on multi-message hits. Block-paged models
1501
+ // also preserve their content-verified per-owner cache; only a non-paged
1502
+ // miss wipes to prevent cross-request cache-affinity leakage.
1503
+ if (resetNativeCache) {
1504
+ await session.reset();
1505
+ } else {
1506
+ await resetPreservingNativeCacheForWarmReuse(session);
1507
+ }
1508
+ session.primeHistory(messages);
1509
+ const initialTurns = session.turns;
1510
+ const result = await session.startFromHistory(config, { signal });
1511
+ return { result, committed: session.turns > initialTurns };
1512
+ }
1513
+
1514
+ /** Streaming counterpart to {@link runSessionNonStreaming}. */
1515
+ async function runSessionStreaming(
1516
+ session: ChatSession<SessionCapableModel>,
1517
+ messages: ChatMessage[],
1518
+ newInputMessages: ChatMessage[],
1519
+ config: ChatConfig,
1520
+ signal: AbortSignal | undefined,
1521
+ resetNativeCache: boolean,
1522
+ ): Promise<StreamingOutcome> {
1523
+ // Preserve the startFromHistoryStream precondition outside the lazy
1524
+ // generator. Without this guard an accepted `input: []` request would commit
1525
+ // SSE and only then throw when iteration begins; the former eager-first-item
1526
+ // path surfaced the same deterministic error before selecting wire format.
1527
+ if (messages.length === 0) {
1528
+ throw new Error('ChatSession: startFromHistoryStream() requires a primed history');
1529
+ }
1530
+ if (session.turns === 0) {
1531
+ // Fresh/replay requests carry their complete canonical history in
1532
+ // `messages`. Validate it before reset so context overflow cannot mutate
1533
+ // native state and still receives a JSON 400 before SSE begins.
1534
+ const constrainedConfig = await session.preflightContextCapacity(messages, config);
1535
+ // See `runSessionNonStreaming` for the full rationale. A fresh
1536
+ // JS session inherits the shared native model's KV cache from
1537
+ // prior requests; without an explicit `reset()` here the native
1538
+ // prefix verifier can silently reuse a previous request's cache
1539
+ // on any prompt-prefix overlap (a cross-request cache-affinity
1540
+ // side channel). The caller requests a full native wipe only for a
1541
+ // non-paged registry miss; warm hits and content-addressed paged models
1542
+ // keep verified native state while still clearing JS state for replay.
1543
+ if (resetNativeCache) {
1544
+ await session.reset();
1545
+ } else {
1546
+ await resetPreservingNativeCacheForWarmReuse(session);
1547
+ }
1548
+ session.primeHistory(messages);
1549
+ const initialTurns = session.turns;
1550
+ return {
1551
+ stream: session.startFromHistoryStream(constrainedConfig, signal),
1552
+ wasCommitted: () => session.turns > initialTurns,
1553
+ };
1554
+ }
1555
+
1556
+ // See {@link runSessionNonStreaming} for the routing contract. A
1557
+ // single assistant/system continuation falls through to the
1558
+ // multi-message reset + cold re-prime branch below rather than
1559
+ // crashing with 500.
1560
+ if (newInputMessages.length === 1) {
1561
+ const last = newInputMessages[0]!;
1562
+ if (last.role === 'user') {
1563
+ // Tier-2 prompt-cache hits may carry only this new message in the HTTP
1564
+ // request while the leased ChatSession owns the prior conversation.
1565
+ // Preflight against that authoritative private history, not `messages`.
1566
+ const constrainedConfig = await session.preflightPendingContextCapacity(last, config);
1567
+ const initialTurns = session.turns;
1568
+ const images = last.images ?? undefined;
1569
+ return {
1570
+ stream: session.sendStream(
1571
+ last.content,
1572
+ images ? { images, config: constrainedConfig, signal } : { config: constrainedConfig, signal },
1573
+ ),
1574
+ wasCommitted: () => session.turns > initialTurns,
1575
+ };
1576
+ }
1577
+ if (last.role === 'tool') {
1578
+ if (!last.toolCallId) {
1579
+ throw new Error('tool message missing toolCallId');
1580
+ }
1581
+ const constrainedConfig = await session.preflightPendingContextCapacity(last, config);
1582
+ const initialTurns = session.turns;
1583
+ return {
1584
+ // Forward the structured `isError` field through to the native
1585
+ // renderer so the streaming wire-format `[tool error]` marker
1586
+ // stays in sync with the Anthropic `tool_result.is_error === true`
1587
+ // source field — same contract as the non-streaming path above.
1588
+ stream: session.sendToolResultStream(last.toolCallId, last.content, {
1589
+ config: constrainedConfig,
1590
+ signal,
1591
+ isError: last.isError,
1592
+ }),
1593
+ wasCommitted: () => session.turns > initialTurns,
1594
+ };
1595
+ }
1596
+ // Non-user / non-tool single-message continuation falls through to
1597
+ // the reset + cold re-prime branch below.
1598
+ }
1599
+
1600
+ // Multi-message (or single non-user/non-tool) hot path: same reset +
1601
+ // cold re-prime as the non-streaming variant. `initialTurns` must be
1602
+ // captured AFTER reset. On tier-1 / tier-2 HIT (warm lease that
1603
+ // cannot use the delta API because the input spans multiple
1604
+ // messages), keep the native KV cache so the prefix verifier can
1605
+ // reuse it on the replayed `chat_session_start_sync`. Block-paged models
1606
+ // likewise preserve content-verified native state; non-paged misses wipe
1607
+ // to block cross-request cache-affinity leakage.
1608
+ const constrainedConfig = await session.preflightContextCapacity(messages, config);
1609
+ if (resetNativeCache) {
1610
+ await session.reset();
1611
+ } else {
1612
+ await resetPreservingNativeCacheForWarmReuse(session);
1613
+ }
1614
+ session.primeHistory(messages);
1615
+ const initialTurns = session.turns;
1616
+ return {
1617
+ stream: session.startFromHistoryStream(constrainedConfig, signal),
1618
+ wasCommitted: () => session.turns > initialTurns,
1619
+ };
1620
+ }
1621
+
1622
+ // ---------------------------------------------------------------------------
1623
+ // Storage helper
1624
+ // ---------------------------------------------------------------------------
1625
+
1626
+ /**
1627
+ * Build the `StoredResponseRecord` for a committed response. Pure
1628
+ * function, split out from `initiatePersist` so the caller can build
1629
+ * the record synchronously inside `withExclusive`, register the
1630
+ * in-flight write in the tracker before the mutex releases, and await
1631
+ * off-lock purely for error logging. See `pending-writes.ts` for the
1632
+ * tracker contract.
1633
+ *
1634
+ * Only NEW input messages are stored — chain reconstruction re-derives
1635
+ * full history via `previous_response_id` links. `modelInstanceId` is
1636
+ * stashed in `configJson` (leaving the Rust-side schema untouched)
1637
+ * alongside `serverBootId` so `readStoredModelIdentity` can distinguish
1638
+ * a live in-process hot-swap (strict instance-id guard) from a cross-
1639
+ * restart resume (skip the instance-id guard, fall back to name-based
1640
+ * resume against whatever model is currently bound).
1641
+ */
1642
+ function buildResponseRecord(
1643
+ response: ResponseObject,
1644
+ newInputMessages: ChatMessage[],
1645
+ previousResponseId: string | undefined,
1646
+ modelInstanceId: number | undefined,
1647
+ retentionSec?: number,
1648
+ ): StoredResponseRecord {
1649
+ // Retention is decoupled from the warm `SessionRegistry` TTL (30 min
1650
+ // KV cache) — the row must outlive the session so a later cold
1651
+ // replay can rebuild from SQLite. Default 7 days via `createServer`.
1652
+ const effectiveRetention =
1653
+ retentionSec != null && Number.isFinite(retentionSec) && retentionSec > 0 ? retentionSec : RESPONSE_TTL_SECONDS;
1654
+ return {
1655
+ id: response.id,
1656
+ createdAt: response.created_at,
1657
+ model: response.model,
1658
+ status: response.status,
1659
+ instructions: response.instructions ?? undefined,
1660
+ inputJson: stringifyStoredInputMessages(newInputMessages),
1661
+ outputJson: JSON.stringify(response.output),
1662
+ outputText: response.output_text,
1663
+ usageJson: JSON.stringify(response.usage),
1664
+ previousResponseId: previousResponseId ?? undefined,
1665
+ configJson: JSON.stringify({
1666
+ temperature: response.temperature,
1667
+ top_p: response.top_p,
1668
+ max_output_tokens: response.max_output_tokens,
1669
+ tools: response.tools,
1670
+ reasoning: response.reasoning,
1671
+ modelInstanceId,
1672
+ serverBootId: getServerBootId(),
1673
+ }),
1674
+ expiresAt: Math.floor(Date.now() / 1000) + effectiveRetention,
1675
+ };
1676
+ }
1677
+
1678
+ /**
1679
+ * Kick off an off-lock `store.store(record)` write and register it in
1680
+ * the per-store pending-write tracker. MUST be called synchronously
1681
+ * inside `withExclusive` so the tracker registration happens before
1682
+ * the mutex releases — a back-to-back continuation that slips in
1683
+ * observes the in-flight write via `awaitPending(previous_response_id)`
1684
+ * and retries `getChain` rather than 404-ing on a fresh responseId.
1685
+ *
1686
+ * `absoluteExpiresAtMs` = min(record expiry, chain earliest expiry) —
1687
+ * once crossed, the `awaitPending` path can short-circuit to 404
1688
+ * rather than keep emitting retryable 503 for an unrecoverable chain.
1689
+ *
1690
+ * Caller awaits the returned promise off-lock purely for error logging.
1691
+ */
1692
+ function initiatePersist(
1693
+ store: ResponseStore,
1694
+ record: StoredResponseRecord,
1695
+ absoluteExpiresAtMs?: number,
1696
+ ): Promise<void> {
1697
+ const writePromise = store.store(record);
1698
+ getPendingWritesFor(store).track(record.id, writePromise, absoluteExpiresAtMs);
1699
+ return writePromise;
1700
+ }
1701
+
1702
+ /**
1703
+ * Identity signal from a stored record's `configJson` blob:
1704
+ * - `present` (well-formed `modelInstanceId`; `bootId` may be
1705
+ * `undefined` for rows written before `serverBootId` was added)
1706
+ * - `absent` (truly legacy row with neither `modelInstanceId` nor
1707
+ * `serverBootId` — rejected outright, cannot verify anything)
1708
+ * - `malformed` (blob failed to JSON-parse — rejected with 400)
1709
+ *
1710
+ * The boot id is threaded through so the validation block can
1711
+ * distinguish same-process hot-swap (strict instance-id check) from
1712
+ * cross-restart resume (skip instance-id check, name-based only).
1713
+ */
1714
+ type StoredModelIdentity =
1715
+ | { kind: 'present'; instanceId: number; bootId: string | undefined }
1716
+ | { kind: 'absent' }
1717
+ | { kind: 'malformed' };
1718
+
1719
+ function readStoredModelIdentity(record: StoredResponseRecord): StoredModelIdentity {
1720
+ if (record.configJson == null) return { kind: 'absent' };
1721
+ let parsed: { modelInstanceId?: unknown; serverBootId?: unknown };
1722
+ try {
1723
+ parsed = JSON.parse(record.configJson) as { modelInstanceId?: unknown; serverBootId?: unknown };
1724
+ } catch {
1725
+ return { kind: 'malformed' };
1726
+ }
1727
+ const bootId =
1728
+ typeof parsed.serverBootId === 'string' && parsed.serverBootId.length > 0 ? parsed.serverBootId : undefined;
1729
+ if (typeof parsed.modelInstanceId === 'number' && Number.isFinite(parsed.modelInstanceId)) {
1730
+ return { kind: 'present', instanceId: parsed.modelInstanceId, bootId };
1731
+ }
1732
+ return { kind: 'absent' };
1733
+ }
1734
+
1735
+ // ---------------------------------------------------------------------------
1736
+ // Public handler
1737
+ // ---------------------------------------------------------------------------
1738
+
1739
+ export async function handleCreateResponse(
1740
+ res: ServerResponse,
1741
+ body: ResponsesAPIRequest,
1742
+ registry: ModelRegistry,
1743
+ store: ResponseStore | null,
1744
+ httpReq?: IncomingMessage,
1745
+ responseRetentionSec?: number,
1746
+ idleSweeper?: IdleSweeper | null,
1747
+ modelWorkCoordinator?: ModelWorkCoordinator,
1748
+ /** Lazy-load hook. See the call site below and `ServerConfig.resolveModel`. */
1749
+ resolveModel?: (name: string) => Promise<void>,
1750
+ ): Promise<void> {
1751
+ if (modelWorkCoordinator) {
1752
+ registry.setModelLoadAdmissionCoordinator(modelWorkCoordinator);
1753
+ }
1754
+ const handlerStartedAt = Date.now();
1755
+
1756
+ // Validate required fields
1757
+ if (body == null || typeof body !== 'object') {
1758
+ sendBadRequest(res, 'Request body must be a JSON object', 'body');
1759
+ return;
1760
+ }
1761
+ if (!body.model) {
1762
+ sendBadRequest(res, 'Missing required field: model', 'model');
1763
+ return;
1764
+ }
1765
+ if (body.input == null) {
1766
+ sendBadRequest(res, 'Missing required field: input', 'input');
1767
+ return;
1768
+ }
1769
+ if (typeof body.input !== 'string' && !Array.isArray(body.input)) {
1770
+ sendBadRequest(res, 'Field "input" must be a string or an array', 'input');
1771
+ return;
1772
+ }
1773
+ // A present `max_output_tokens` must be an integer in `[1, i32::MAX]`.
1774
+ // `null` / missing means "no explicit limit" and is fine. Mirrors the
1775
+ // Anthropic `/v1/messages` guard on `max_tokens`. Rejecting here (rather
1776
+ // than forwarding through the mapper) keeps a nonpositive budget from
1777
+ // reaching native chat, where a negative `i32` would size a cache /
1778
+ // allocation as a huge `usize`; core still clamps nonpositive to 0 as
1779
+ // a backstop. The upper bound matters because NAPI truncates a JS
1780
+ // integer above `i32::MAX` to a NEGATIVE `i32` (then clamped to 0 → a
1781
+ // silent empty completion) — reject it as a 400 instead.
1782
+ if (
1783
+ body.max_output_tokens != null &&
1784
+ (!Number.isInteger(body.max_output_tokens) ||
1785
+ body.max_output_tokens <= 0 ||
1786
+ body.max_output_tokens > MAX_OUTPUT_TOKENS)
1787
+ ) {
1788
+ sendBadRequest(
1789
+ res,
1790
+ `Field "max_output_tokens" must be an integer between 1 and ${MAX_OUTPUT_TOKENS}`,
1791
+ 'max_output_tokens',
1792
+ );
1793
+ return;
1794
+ }
1795
+
1796
+ // Per-request retention override: `metadata.retention_seconds` lets a
1797
+ // client pin a single row to a longer (VIP / onboarding) or shorter
1798
+ // (one-shot PII) lifetime than the server-wide default. Bounds
1799
+ // `[60, 90 * 86400]` cap runaway retention and bound operator disk
1800
+ // use; `null` / `undefined` / missing → fall through to
1801
+ // `responseRetentionSec`. The error message is exact — clients parse
1802
+ // on it.
1803
+ let requestedRetentionSec: number | undefined;
1804
+ if (body.metadata != null && typeof body.metadata === 'object') {
1805
+ const raw = (body.metadata as { retention_seconds?: unknown }).retention_seconds;
1806
+ if (raw != null) {
1807
+ const RETENTION_MIN = 60;
1808
+ const RETENTION_MAX = 90 * 86400; // 7_776_000
1809
+ if (
1810
+ typeof raw !== 'number' ||
1811
+ !Number.isFinite(raw) ||
1812
+ !Number.isInteger(raw) ||
1813
+ raw < RETENTION_MIN ||
1814
+ raw > RETENTION_MAX
1815
+ ) {
1816
+ sendBadRequest(
1817
+ res,
1818
+ 'metadata.retention_seconds must be an integer in [60, 7776000]',
1819
+ 'metadata.retention_seconds',
1820
+ );
1821
+ return;
1822
+ }
1823
+ requestedRetentionSec = raw;
1824
+ }
1825
+ }
1826
+ const effectiveRetentionSec = requestedRetentionSec ?? responseRetentionSec;
1827
+
1828
+ // Pre-dispatch admission gate (H3, host mode). Resident requests bypass
1829
+ // the model-load writer below and proceed toward the continuous-batching
1830
+ // lane, but can still park in pre-lock store work before reaching it. Admit
1831
+ // or 429 up front against the same per-model budget so that work stays
1832
+ // bounded too. A non-resident model has no `SessionRegistry` yet, so it
1833
+ // takes the coordinator's bounded pre-resolution permit below instead of
1834
+ // entering its writer queue uncounted.
1835
+ //
1836
+ // The applicable permit is RETAINED through every pre-lock await — the
1837
+ // writer bracket for a cold model, or the `store.getChain` continuation
1838
+ // lookups for a resident model — and handed to the selected resident lane
1839
+ // at placement, which consumes it
1840
+ // atomically as this request's admission (one budget, one token,
1841
+ // never double-counted). It is released only on the bail-out exits:
1842
+ // explicitly on the early returns before the outer `try`, and by the
1843
+ // outer `finally` for everything inside it (idempotent + no-op after
1844
+ // handoff, so the unconditional release is always safe).
1845
+ let preDispatchAdmission: PreDispatchAdmission | undefined;
1846
+ let modelLoadAdmission: ModelLoadAdmission | undefined;
1847
+ const preDispatchRegistry = registry.getSessionRegistry(body.model);
1848
+ if (preDispatchRegistry) {
1849
+ try {
1850
+ preDispatchAdmission = preDispatchRegistry.beginPreDispatchAdmission();
1851
+ } catch (err) {
1852
+ if (err instanceof QueueFullError) {
1853
+ sendRateLimit(res, `${err.message}. Retry after 1s.`);
1854
+ return;
1855
+ }
1856
+ throw err;
1857
+ }
1858
+ } else if (resolveModel && modelWorkCoordinator) {
1859
+ try {
1860
+ modelLoadAdmission = modelWorkCoordinator.beginRequestLoadAdmission(body.model);
1861
+ } catch (err) {
1862
+ if (err instanceof ModelLoadQueueFullError) {
1863
+ sendRateLimit(
1864
+ res,
1865
+ `Model queue full: admission footprint ${err.admissionFootprint} (limit ${err.limit}). Retry after 1s.`,
1866
+ );
1867
+ return;
1868
+ }
1869
+ throw err;
1870
+ }
1871
+ }
1872
+
1873
+ // Lazy load, exactly as the Anthropic endpoints do, only when the requested
1874
+ // name has no resident registry. Nothing is resident at boot —
1875
+ // `createInferenceHost` only discovers — so without this the very
1876
+ // first `/v1/responses` 404s against a `/v1/models` list that advertises the
1877
+ // model, and a client id that exists only as an alias 404s forever.
1878
+ //
1879
+ // Placement is pinned on both sides. AFTER the pure validation above, so a
1880
+ // 400 cannot burn a 30 s load or evict the resident model. BEFORE
1881
+ // `registry.get` below, and therefore before the dispatch lease, which needs
1882
+ // a registered name.
1883
+ if (resolveModel && !preDispatchRegistry) {
1884
+ // Errors serialize through the OpenAI envelope here. Letting them reach
1885
+ // the outer `createHandler` catch would be right for this endpoint by
1886
+ // accident and wrong for the Anthropic one — `messages.ts` has the mirror
1887
+ // of this note for the opposite reason.
1888
+ try {
1889
+ // Suspension OUTSIDE the writer lock, per `load-model.ts`: a load that
1890
+ // parks in `acquireWrite()` must already be covered, or the drain timer
1891
+ // armed by the previous request fires mid-materialization. `messages.ts`
1892
+ // nests these the other way round, which is safe only because
1893
+ // `withSuspendedDrains` is a counter rather than a mutex.
1894
+ const load = (): Promise<void> =>
1895
+ modelWorkCoordinator
1896
+ ? modelWorkCoordinator.withModelLoad(() => resolveModel(body.model), 'responses')
1897
+ : resolveModel(body.model);
1898
+ await (idleSweeper ? idleSweeper.withSuspendedDrains(load) : load());
1899
+ } catch (err) {
1900
+ preDispatchAdmission?.release();
1901
+ modelLoadAdmission?.release();
1902
+ sendInternalError(res, err instanceof Error ? err.message : 'Failed to resolve model');
1903
+ return;
1904
+ }
1905
+ }
1906
+ // NOTE: the permit is NOT released here. Pre-lock work continues below
1907
+ // (`store.getChain` on continuations can block indefinitely on a slow
1908
+ // store) and the request must stay counted until `withExclusive`
1909
+ // consumes the permit at placement. Only bail-out exits release.
1910
+
1911
+ // Look up model
1912
+ const model = registry.get(body.model);
1913
+ if (!model) {
1914
+ preDispatchAdmission?.release();
1915
+ modelLoadAdmission?.release();
1916
+ sendNotFound(
1917
+ res,
1918
+ `Model "${body.model}" not found. Available models: ${registry
1919
+ .list()
1920
+ .map((m) => m.id)
1921
+ .join(', ')}`,
1922
+ );
1923
+ return;
1924
+ }
1925
+
1926
+ // Dispatch lease keeps the binding (and its FIFO `execLock` chain)
1927
+ // alive across every await in this handler — required because a
1928
+ // concurrent `unregister()` + `register(sameModel)` would otherwise
1929
+ // allocate a fresh `SessionRegistry` and race two independent mutex
1930
+ // chains against one native model. Released in `finally` below.
1931
+ const lease = registry.acquireDispatchLease(body.model);
1932
+ if (!lease) {
1933
+ preDispatchAdmission?.release();
1934
+ modelLoadAdmission?.release();
1935
+ sendInternalError(res, 'session registry missing for registered model');
1936
+ return;
1937
+ }
1938
+ const leaseModel = lease.model;
1939
+ if (modelLoadAdmission) {
1940
+ try {
1941
+ preDispatchAdmission = modelLoadAdmission.transferToResident(lease.registry);
1942
+ } catch (err) {
1943
+ registry.releaseDispatchLease(leaseModel);
1944
+ modelLoadAdmission.release();
1945
+ if (err instanceof QueueFullError) {
1946
+ sendRateLimit(res, `${err.message}. Retry after 1s.`);
1947
+ return;
1948
+ }
1949
+ throw err;
1950
+ }
1951
+ }
1952
+ // AbortController wired to disconnect events, declared at handler
1953
+ // scope so the outer `finally` can always detach even on early
1954
+ // return. Listeners attach only after the pre-lock validation gates
1955
+ // pass; `abortListenersAttached` guards the detach.
1956
+ const abortController = new AbortController();
1957
+ const abortSocket = res.socket;
1958
+ const onAbortClose = (): void => {
1959
+ abortController.abort();
1960
+ };
1961
+ const onAbortError = (_err: unknown): void => {
1962
+ abortController.abort();
1963
+ };
1964
+ let abortListenersAttached = false;
1965
+ // `runPostDispatchCleanup` runs eagerly after `withExclusive` returns
1966
+ // (so a wedged post-commit persist does not pin abort listeners or
1967
+ // the lease) and also idempotently from the outer `finally` for the
1968
+ // early-return path. These flags keep it a no-op when already run.
1969
+ let cleanupPerformed = false;
1970
+ let leaseReleased = false;
1971
+ // Idle-sweeper bracket flags. Hoisted to outer function scope so
1972
+ // the `finalizeIdleRequest` helper below can see them even though
1973
+ // the `beginRequest()` call lives inside the inner `try`.
1974
+ // Pre-dispatch validation failures (early returns that never called
1975
+ // `beginRequest`) observe `idleRequestStarted === false` and skip
1976
+ // the matching `endRequest()` entirely. `idleRequestEnded` is the
1977
+ // `done` flag that guarantees the decrement fires exactly once
1978
+ // regardless of which of the several finalize paths — outer
1979
+ // `finally`, `res.once('finish')`, `res.once('close')`,
1980
+ // `res.once('error')` — wins the race.
1981
+ //
1982
+ // Listeners are attached EAGERLY at `beginRequest()` time (not
1983
+ // lazily from the outer `finally`) to close the round-4 leak where
1984
+ // a terminal socket event fired *before* the outer `finally` ran:
1985
+ // the lazy attach saw `writableEnded === false && writableFinished
1986
+ // === false` at check time, attached listeners on a socket whose
1987
+ // final event had already been emitted, and `endRequest()` then
1988
+ // never fired, leaving `inFlight` pinned above zero and the
1989
+ // sweeper permanently armed.
1990
+ let idleRequestStarted = false;
1991
+ let idleRequestEnded = false;
1992
+ let idleListenersAttached = false;
1993
+ const finalizeIdleRequest = (): void => {
1994
+ if (!idleRequestStarted) return;
1995
+ if (idleRequestEnded) return;
1996
+ idleRequestEnded = true;
1997
+ idleSweeper?.endRequest();
1998
+ };
1999
+ const onFinalizeEvent = (): void => {
2000
+ finalizeIdleRequest();
2001
+ };
2002
+ try {
2003
+ // Initial snapshot of the live binding. On a continuation we
2004
+ // re-read after `await store.getChain()` and reject if the
2005
+ // binding moved (hot-swap race guard below). Stateless requests
2006
+ // keep the snapshot unchanged.
2007
+ const initialSessionReg: SessionRegistry = lease.registry;
2008
+ const initialInstanceId: number = lease.instanceId;
2009
+
2010
+ let sessionReg: SessionRegistry = initialSessionReg;
2011
+ let currentInstanceId: number | undefined = initialInstanceId;
2012
+
2013
+ const responseId = genId('resp_');
2014
+
2015
+ let priorMessages: ChatMessage[] | undefined;
2016
+ let previousResponseId: string | undefined;
2017
+ // Trailing-record inherited instructions, applied when the caller
2018
+ // omits `body.instructions` (empty string still counts as an
2019
+ // explicit override). Keeps `instructions: "You are a pirate"`
2020
+ // alive across cold replays.
2021
+ let inheritedInstructions: string | null = null;
2022
+ // Precomputed scalar = earliest wall-clock expiry across the
2023
+ // resolved chain (epoch-ms). `ResponseStore.getChain()` aborts on
2024
+ // the first expired ancestor (see
2025
+ // `crates/mlx-db/src/response_store/reader.rs:44-59`), so once
2026
+ // this bound is crossed the chain is unrecoverable and we can
2027
+ // short-circuit the retryable-503 path to permanent 404. Threading
2028
+ // only the scalar (not the record array) keeps background
2029
+ // hard-timeout closures O(1) per pending continuation.
2030
+ let chainEarliestExpiresAtMs: number | undefined = undefined;
2031
+
2032
+ if (body.previous_response_id && store) {
2033
+ try {
2034
+ // Persist-before-getChain race: a client firing back-to-back
2035
+ // `previous_response_id: A` can reach `getChain(A)` before
2036
+ // the producer's off-lock `store.store(A)` has landed. The
2037
+ // pending-writes tracker is registered synchronously inside
2038
+ // `withExclusive` (see `initiatePersist`) so we can observe
2039
+ // the in-flight write and retry.
2040
+ //
2041
+ // Native mlx-db throws `"Response not found: <id>"` on miss
2042
+ // (`crates/mlx-db/src/response_store/reader.rs`); in-memory
2043
+ // mocks return `[]`. Handle both — the lenient /not found/
2044
+ // match routes both into the retry path while letting real
2045
+ // infrastructure errors bubble to the outer catch.
2046
+ let chain: StoredResponseRecord[];
2047
+ let firstAttemptError: unknown = null;
2048
+ try {
2049
+ chain = await store.getChain(body.previous_response_id);
2050
+ } catch (err) {
2051
+ const msg = err instanceof Error ? err.message : String(err);
2052
+ if (!/not found/i.test(msg)) {
2053
+ throw err;
2054
+ }
2055
+ firstAttemptError = err;
2056
+ chain = [];
2057
+ }
2058
+
2059
+ if (chain.length === 0) {
2060
+ const pending = getPendingWritesFor(store).awaitPending(body.previous_response_id);
2061
+ if (pending !== undefined) {
2062
+ // Bound the wait — `awaitPending` returns the raw
2063
+ // `store.store` promise which can hang indefinitely on a
2064
+ // wedged backend. On timeout fall through to the
2065
+ // last-probe branch below.
2066
+ type PendingOutcome = 'landed' | 'timeout';
2067
+ const chainWriteWaitTimeoutMs = getChainWriteWaitTimeoutMs();
2068
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
2069
+ const timeoutPromise = new Promise<PendingOutcome>((resolve) => {
2070
+ timeoutHandle = setTimeout(() => {
2071
+ resolve('timeout');
2072
+ }, chainWriteWaitTimeoutMs);
2073
+ });
2074
+ const pendingOutcome: Promise<PendingOutcome> = pending.then(() => 'landed' as const);
2075
+ let timedOut = false;
2076
+ try {
2077
+ const outcome = await Promise.race([pendingOutcome, timeoutPromise]);
2078
+ timedOut = outcome === 'timeout';
2079
+ } catch {
2080
+ // Write rejection is the producer's problem; the
2081
+ // tracker's .finally() already cleared the entry so
2082
+ // the retry below sees the true post-failure state.
2083
+ } finally {
2084
+ if (timeoutHandle !== undefined) {
2085
+ clearTimeout(timeoutHandle);
2086
+ }
2087
+ }
2088
+ if (timedOut) {
2089
+ // Last-probe race closer: a write landing at
2090
+ // (timeout + epsilon) would have succeeded but 404-ing
2091
+ // here is non-retryable and permanently poisons the
2092
+ // client's chain. If the probe misses too, surface 503
2093
+ // storage_timeout (retryable) instead of 404.
2094
+ let probed: StoredResponseRecord[] | null = null;
2095
+ try {
2096
+ probed = await store.getChain(body.previous_response_id);
2097
+ } catch (err) {
2098
+ const msg = err instanceof Error ? err.message : String(err);
2099
+ if (!/not found/i.test(msg)) {
2100
+ throw err;
2101
+ }
2102
+ probed = null;
2103
+ }
2104
+ if (probed !== null && probed.length > 0) {
2105
+ // Log the wedged-writer condition even on a
2106
+ // successful probe so operators see the slow path
2107
+ // fired.
2108
+ console.warn(
2109
+ `[responses] pending store write for previous_response_id "${body.previous_response_id}" did ` +
2110
+ `not settle within ${chainWriteWaitTimeoutMs}ms, but a last-probe getChain found the record. ` +
2111
+ `Continuing with the probed chain — likely a slow SQLite writer that landed just after the ` +
2112
+ `timeout fired.`,
2113
+ );
2114
+ chain = probed;
2115
+ } else {
2116
+ // Once the chain's earliest-recoverable expiry has
2117
+ // passed, `getChain()` can never succeed (the reader
2118
+ // aborts on the first expired ancestor, see
2119
+ // `reader.rs:44-59`). Short-circuit to permanent 404
2120
+ // rather than loop the client on retryable 503 for an
2121
+ // unrecoverable chain.
2122
+ const earliestMs = getPendingWritesFor(store).getEarliestExpiresAtMs(body.previous_response_id);
2123
+ if (earliestMs !== undefined && Date.now() >= earliestMs) {
2124
+ console.warn(
2125
+ `[responses] timed out after ${chainWriteWaitTimeoutMs}ms waiting for pending store write ` +
2126
+ `for previous_response_id "${body.previous_response_id}"; last-probe getChain still missed. ` +
2127
+ `Earliest recoverable expiry (${earliestMs}ms) already crossed — returning 404 NotFound ` +
2128
+ `rather than retryable 503 because getChain() can no longer succeed for this chain.`,
2129
+ );
2130
+ sendNotFound(res, `Previous response "${body.previous_response_id}" not found`);
2131
+ return;
2132
+ }
2133
+ console.warn(
2134
+ `[responses] timed out after ${chainWriteWaitTimeoutMs}ms waiting for pending store write ` +
2135
+ `for previous_response_id "${body.previous_response_id}"; last-probe getChain still missed. ` +
2136
+ `Returning 503 storage_timeout — the underlying store.store(...) promise did not settle in time, ` +
2137
+ `likely a wedged SQLite writer or stuck native backend. The client may retry with the same ` +
2138
+ `previous_response_id.`,
2139
+ );
2140
+ sendStorageTimeout(
2141
+ res,
2142
+ `Storage write for "${body.previous_response_id}" did not settle within ${chainWriteWaitTimeoutMs}ms. ` +
2143
+ `This is a transient backend condition — retry the request with the same previous_response_id.`,
2144
+ );
2145
+ return;
2146
+ }
2147
+ } else {
2148
+ try {
2149
+ chain = await store.getChain(body.previous_response_id);
2150
+ } catch (err) {
2151
+ const msg = err instanceof Error ? err.message : String(err);
2152
+ if (!/not found/i.test(msg)) {
2153
+ throw err;
2154
+ }
2155
+ chain = [];
2156
+ }
2157
+ }
2158
+ } else if (firstAttemptError !== null) {
2159
+ // Hard-timed-out marker path: the post-commit persist
2160
+ // hit the hard breaker but the raw write may still land.
2161
+ // Classify as retryable 503 (not 404) so clients keep
2162
+ // the chain alive; re-probe once first to catch a write
2163
+ // that slipped in between marker-set and now.
2164
+ if (getPendingWritesFor(store).isHardTimedOut(body.previous_response_id)) {
2165
+ let lastChance: StoredResponseRecord[] | null = null;
2166
+ try {
2167
+ lastChance = await store.getChain(body.previous_response_id);
2168
+ } catch (err) {
2169
+ const msg = err instanceof Error ? err.message : String(err);
2170
+ if (!/not found/i.test(msg)) {
2171
+ throw err;
2172
+ }
2173
+ lastChance = null;
2174
+ }
2175
+ if (lastChance !== null && lastChance.length > 0) {
2176
+ console.warn(
2177
+ `[responses] previous_response_id "${body.previous_response_id}" missing on first lookup and ` +
2178
+ `its post-commit persist crossed the hard-timeout breaker, but a last-probe getChain found ` +
2179
+ `the record. Continuing with the probed chain — likely a wedged SQLite writer that landed ` +
2180
+ `just after the marker was set.`,
2181
+ );
2182
+ chain = lastChance;
2183
+ } else {
2184
+ console.warn(
2185
+ `[responses] previous_response_id "${body.previous_response_id}" missing from store, but its ` +
2186
+ `post-commit persist crossed the hard-timeout breaker and is still unresolved (last-probe ` +
2187
+ `getChain still missed). Returning 503 storage_timeout so the client retries with the same ` +
2188
+ `id rather than discarding the chain as permanently invalid.`,
2189
+ );
2190
+ sendStorageTimeout(
2191
+ res,
2192
+ `Storage write for "${body.previous_response_id}" crossed the post-commit persist hard-timeout ` +
2193
+ `breaker and has not yet settled. This is a transient backend condition — retry the request ` +
2194
+ `with the same previous_response_id.`,
2195
+ );
2196
+ return;
2197
+ }
2198
+ } else {
2199
+ // Genuine 404: first call missed, no pending write, no
2200
+ // hard-timed-out marker. Rethrow so outer catch emits 404.
2201
+ throw firstAttemptError;
2202
+ }
2203
+ }
2204
+ if (chain.length === 0) {
2205
+ // Mirror the rethrow branch: a mock-compatible store that
2206
+ // returned `[]` rather than throwing still needs the
2207
+ // hard-timed-out marker retryable-503 classification.
2208
+ if (getPendingWritesFor(store).isHardTimedOut(body.previous_response_id)) {
2209
+ let lastChance: StoredResponseRecord[] | null = null;
2210
+ try {
2211
+ lastChance = await store.getChain(body.previous_response_id);
2212
+ } catch (err) {
2213
+ const msg = err instanceof Error ? err.message : String(err);
2214
+ if (!/not found/i.test(msg)) {
2215
+ throw err;
2216
+ }
2217
+ lastChance = null;
2218
+ }
2219
+ if (lastChance !== null && lastChance.length > 0) {
2220
+ console.warn(
2221
+ `[responses] previous_response_id "${body.previous_response_id}" missing on first lookup and ` +
2222
+ `its post-commit persist crossed the hard-timeout breaker, but a last-probe getChain found ` +
2223
+ `the record. Continuing with the probed chain — likely a wedged SQLite writer that landed ` +
2224
+ `just after the marker was set.`,
2225
+ );
2226
+ chain = lastChance;
2227
+ } else {
2228
+ console.warn(
2229
+ `[responses] previous_response_id "${body.previous_response_id}" missing from store, but its ` +
2230
+ `post-commit persist crossed the hard-timeout breaker and is still unresolved (last-probe ` +
2231
+ `getChain still missed). Returning 503 storage_timeout so the client retries with the same ` +
2232
+ `id rather than discarding the chain as permanently invalid.`,
2233
+ );
2234
+ sendStorageTimeout(
2235
+ res,
2236
+ `Storage write for "${body.previous_response_id}" crossed the post-commit persist hard-timeout ` +
2237
+ `breaker and has not yet settled. This is a transient backend condition — retry the request ` +
2238
+ `with the same previous_response_id.`,
2239
+ );
2240
+ return;
2241
+ }
2242
+ } else {
2243
+ sendNotFound(res, `Previous response "${body.previous_response_id}" not found`);
2244
+ return;
2245
+ }
2246
+ }
2247
+ }
2248
+
2249
+ // Hot-swap race guard (getChain await window): re-read the
2250
+ // binding and reject if `registry.register(body.model, …)`
2251
+ // re-pointed the name while we awaited. The in-lock guard
2252
+ // below covers the mutex-wait window; this one covers the
2253
+ // getChain window.
2254
+ const refreshedSessionReg = registry.getSessionRegistry(body.model);
2255
+ const refreshedInstanceId = registry.getInstanceId(body.model);
2256
+ if (
2257
+ refreshedSessionReg === undefined ||
2258
+ refreshedInstanceId === undefined ||
2259
+ refreshedSessionReg !== initialSessionReg ||
2260
+ refreshedInstanceId !== initialInstanceId
2261
+ ) {
2262
+ sendBadRequest(
2263
+ res,
2264
+ `Model "${body.model}" binding changed while the request was resolving its previous_response_id ` +
2265
+ `chain. A concurrent register() re-pointed the name at a different model instance (or released ` +
2266
+ `it entirely) during the store lookup, so the session registry and instance id captured before ` +
2267
+ `the await no longer match the live binding. Dispatching anyway would replay the stored chain ` +
2268
+ `through the wrong model. Retry the request — if the swap was intentional, the new binding will ` +
2269
+ `service the retry cleanly.`,
2270
+ 'model',
2271
+ );
2272
+ return;
2273
+ }
2274
+ sessionReg = refreshedSessionReg;
2275
+ currentInstanceId = refreshedInstanceId;
2276
+
2277
+ // Cross-model continuation guard keyed on MODEL-INSTANCE
2278
+ // IDENTITY (not friendly name): friendly-name equality would
2279
+ // accept a chain produced by the pre-hot-swap instance and
2280
+ // silently replay through a different tokenizer / chat
2281
+ // template / KV layout. Aliases to the same instance are
2282
+ // handled transparently by shared `SessionRegistry` routing.
2283
+ //
2284
+ // Restart safety: the instance-id comparison is only
2285
+ // meaningful WITHIN a single process lifetime. The stored
2286
+ // row's `serverBootId` gates the comparison — when it does
2287
+ // NOT match the live boot id (or is absent on a row written
2288
+ // before this field was added) the stored `modelInstanceId`
2289
+ // belongs to a dead process and is meaningless, so the
2290
+ // strict guard is skipped and the continuation falls back
2291
+ // to name-based resume against whatever model is currently
2292
+ // bound to `body.model`. Truly legacy rows that carry
2293
+ // NEITHER `modelInstanceId` NOR `serverBootId` are still
2294
+ // rejected outright (cannot verify anything).
2295
+ const trailingRecord = chain[chain.length - 1]!;
2296
+ const storedIdentity = readStoredModelIdentity(trailingRecord);
2297
+ if (storedIdentity.kind === 'malformed') {
2298
+ sendBadRequest(
2299
+ res,
2300
+ `previous_response_id "${body.previous_response_id}" points at a stored record whose ` +
2301
+ `configJson blob failed to parse — the server cannot verify the model identity or prior ` +
2302
+ `config state it was produced under, so continuing the chain through any model would ` +
2303
+ `silently replay against an unreadable prior turn. Start a new chain without ` +
2304
+ `previous_response_id.`,
2305
+ 'previous_response_id',
2306
+ );
2307
+ return;
2308
+ }
2309
+ if (storedIdentity.kind === 'absent') {
2310
+ sendBadRequest(
2311
+ res,
2312
+ `previous_response_id "${body.previous_response_id}" points at a legacy stored record ` +
2313
+ `that does not carry a modelInstanceId — the server cannot verify which model instance ` +
2314
+ `produced the chain, so continuing it through any model risks silently replaying ` +
2315
+ `under the wrong tokenizer, chat template, or KV layout. Start a new chain without ` +
2316
+ `previous_response_id.`,
2317
+ 'previous_response_id',
2318
+ );
2319
+ return;
2320
+ }
2321
+ // kind === 'present': apply the strict instance-id guard ONLY when
2322
+ // the stored row carries a boot id that matches the live process.
2323
+ // A missing or non-matching stored boot id means the row was
2324
+ // produced by a prior process (or pre-bootId rollout), so the
2325
+ // stored instance id cannot be compared against anything live.
2326
+ const liveBootId = getServerBootId();
2327
+ const sameProcess = storedIdentity.bootId !== undefined && storedIdentity.bootId === liveBootId;
2328
+ if (sameProcess && (currentInstanceId === undefined || storedIdentity.instanceId !== currentInstanceId)) {
2329
+ sendBadRequest(
2330
+ res,
2331
+ `previous_response_id "${body.previous_response_id}" belongs to a chain produced by a different ` +
2332
+ `model instance than the one currently bound to "${body.model}". This happens when the named ` +
2333
+ `model has been hot-swapped to a different underlying object since the chain was stored or ` +
2334
+ `when the original binding has been released entirely. Continuations cannot cross model ` +
2335
+ `boundaries — a stored chain is tied to the tokenizer, chat template, and KV layout of the ` +
2336
+ `exact model object that produced it, and replaying it through a different model would ` +
2337
+ `silently corrupt the conversation. Start a new chain without previous_response_id.`,
2338
+ 'model',
2339
+ );
2340
+ return;
2341
+ }
2342
+ priorMessages = reconstructMessagesFromChain(chain);
2343
+ previousResponseId = body.previous_response_id;
2344
+ // Fold chain expiries (epoch-seconds → ms) into a single
2345
+ // scalar. The full chain is NOT retained on outer scope, so
2346
+ // the hard-timeout closure below does not capture ancestor
2347
+ // JSON payloads. Rows with missing/malformed `expiresAt` are
2348
+ // skipped; all-missing chains leave the scalar `undefined`.
2349
+ const chainExpirySeconds =
2350
+ chain.length > 0
2351
+ ? Math.min(...chain.map((r) => r.expiresAt).filter((v): v is number => v != null && Number.isFinite(v)))
2352
+ : Number.POSITIVE_INFINITY;
2353
+ chainEarliestExpiresAtMs = Number.isFinite(chainExpirySeconds) ? chainExpirySeconds * 1000 : undefined;
2354
+ // Inherit the trailing record's `instructions` when the
2355
+ // request omits `body.instructions` (empty string still counts
2356
+ // as explicit override). The trailing record carries the
2357
+ // effective instructions in force for that turn, so no
2358
+ // full-chain walk is required. The effective value is also
2359
+ // threaded into the `SessionRegistry` cache key so a hot hit
2360
+ // under stale system context forces a cold replay.
2361
+ //
2362
+ // Empty-string stored instructions MUST be inherited as `""`,
2363
+ // not dropped to `null`: a chain that intentionally cleared
2364
+ // instructions with an explicit empty string was adopted
2365
+ // against the registry under `requestedInstructions = ""`, so
2366
+ // resolving a later no-instructions turn to `null` would
2367
+ // silently flip the byte-for-byte comparison in
2368
+ // `SessionRegistry.getOrCreate` and force a cold replay on
2369
+ // every follow-up. Gate on `typeof === 'string'` so only a
2370
+ // genuinely absent stored value (legacy rows, or rows whose
2371
+ // turn had no instructions in force) short-circuits inheritance.
2372
+ if (typeof body.instructions !== 'string') {
2373
+ const storedInstructions = chain[chain.length - 1]!.instructions;
2374
+ if (typeof storedInstructions === 'string') {
2375
+ inheritedInstructions = storedInstructions;
2376
+ }
2377
+ }
2378
+ } catch (err) {
2379
+ const msg = err instanceof Error ? err.message : '';
2380
+ if (/not found/i.test(msg)) {
2381
+ sendNotFound(res, `Previous response "${body.previous_response_id}" not found or expired`);
2382
+ } else {
2383
+ sendInternalError(res, `Failed to retrieve previous response: ${msg || 'unknown error'}`);
2384
+ }
2385
+ return;
2386
+ }
2387
+ } else if (body.previous_response_id && !store) {
2388
+ sendBadRequest(res, 'previous_response_id requires a response store to be configured');
2389
+ return;
2390
+ }
2391
+
2392
+ // Echoed `function_call` items on a continuation are validated
2393
+ // for ownership (call_id in stored trailing assistant turn) then
2394
+ // stripped. `mapRequest` would otherwise rebuild each echo into a
2395
+ // synthetic assistant message at the tail of `messages`, letting
2396
+ // a forged echo rewrite the trailing assistant turn and bypass
2397
+ // the fan-out gate. `priorMessages` is the authoritative copy.
2398
+ let effectiveInput = body.input;
2399
+ if (previousResponseId && priorMessages && Array.isArray(body.input)) {
2400
+ const storedCallIds = buildTrailingAssistantToolCallIds(priorMessages);
2401
+ const filtered: typeof body.input = [];
2402
+ for (const item of body.input) {
2403
+ if (item != null && typeof item === 'object' && (item as { type?: string }).type === 'function_call') {
2404
+ const fc = item as { call_id?: unknown };
2405
+ const callId = typeof fc.call_id === 'string' ? fc.call_id : null;
2406
+ if (!callId || !storedCallIds || !storedCallIds.has(callId)) {
2407
+ sendBadRequest(
2408
+ res,
2409
+ `echoed function_call item references an unknown call_id "${callId ?? ''}" — the stored ` +
2410
+ `trailing assistant turn is the authoritative copy, and any echoed function_call must ` +
2411
+ `reference one of its outstanding tool calls. Drop the echoed item or resolve the ` +
2412
+ `continuation against the correct previous_response_id.`,
2413
+ 'input',
2414
+ );
2415
+ return;
2416
+ }
2417
+ // Stored state is authoritative — drop the echo regardless
2418
+ // of whether `name`/`arguments` match byte-for-byte.
2419
+ continue;
2420
+ }
2421
+ filtered.push(item);
2422
+ }
2423
+ effectiveInput = filtered;
2424
+ }
2425
+
2426
+ // Effective instructions = caller's explicit `body.instructions`
2427
+ // or the trailing record's inherited value. Threaded through
2428
+ // `mapRequest` (prepends system msg), the registry cache key,
2429
+ // `buildResponseObject`, and persistence. Applied via a fresh
2430
+ // mapped body rather than mutating `body`.
2431
+ const effectiveInstructions: string | null =
2432
+ typeof body.instructions === 'string' ? body.instructions : inheritedInstructions;
2433
+
2434
+ let messages: ChatMessage[];
2435
+ let config: ChatConfig;
2436
+ const mappedBody: ResponsesAPIRequest =
2437
+ effectiveInput === body.input && effectiveInstructions === (body.instructions ?? null)
2438
+ ? body
2439
+ : {
2440
+ ...body,
2441
+ input: effectiveInput,
2442
+ instructions: effectiveInstructions ?? undefined,
2443
+ };
2444
+ try {
2445
+ ({ messages, config } = mapRequest(mappedBody, priorMessages));
2446
+ } catch (err) {
2447
+ sendBadRequest(res, err instanceof Error ? err.message : 'Invalid request input', 'input');
2448
+ return;
2449
+ }
2450
+
2451
+ // New-only messages (what this request added). Instructions are
2452
+ // stored separately — persisting them as input messages would
2453
+ // replay stale system messages on cold chain. Mirror `mapRequest`'s
2454
+ // truthy check (empty string contributes zero offset).
2455
+ const instructionsOffset = mappedBody.instructions ? 1 : 0;
2456
+ const priorOffset = instructionsOffset + (priorMessages?.length ?? 0);
2457
+ let newInputMessages = messages.slice(priorOffset);
2458
+
2459
+ // Every tool message in the continuation delta must carry a
2460
+ // non-empty `tool_call_id`. Correctness-critical: the id-set gate
2461
+ // below silently ignores anonymous tool messages, and native
2462
+ // backends that pair results positionally would bind the
2463
+ // anonymous entry to the wrong call.
2464
+ for (const m of newInputMessages) {
2465
+ if (m.role === 'tool' && (typeof m.toolCallId !== 'string' || m.toolCallId.length === 0)) {
2466
+ sendBadRequest(res, 'tool message missing tool_call_id', 'input');
2467
+ return;
2468
+ }
2469
+ }
2470
+
2471
+ // `SessionRegistry` cache key — passing the effective value lets
2472
+ // the registry force a cold replay on instructions mismatch.
2473
+ const requestedInstructions: string | null = effectiveInstructions;
2474
+
2475
+ // The native model is a single mutable resource (one
2476
+ // `cached_token_history`, one `caches` vector) so every dispatch
2477
+ // through `/v1/responses` and `/v1/messages` for the same binding
2478
+ // serializes through `sessionReg.withExclusive`. The mutex spans
2479
+ // `getOrCreate → dispatch → adopt/drop`.
2480
+ const preLockSessionReg = sessionReg;
2481
+ const preLockInstanceId = currentInstanceId;
2482
+
2483
+ // Arm the abort listeners. `@mlx-node/lm`'s streaming wrappers
2484
+ // plumb the signal into `_runChatStream`, which calls
2485
+ // `handle.cancel()` on the native stream handle AND pushes a
2486
+ // synthetic marker to unblock the next `waitForItem()`. Attached
2487
+ // here (not at function entry) so early-return validation gates
2488
+ // above don't need paired detach calls.
2489
+ res.once('close', onAbortClose);
2490
+ res.once('error', onAbortError);
2491
+ if (abortSocket != null) {
2492
+ abortSocket.once('close', onAbortClose);
2493
+ }
2494
+ if (httpReq) {
2495
+ httpReq.once('close', onAbortClose);
2496
+ httpReq.once('error', onAbortError);
2497
+ }
2498
+ // Catch-up abort: a response torn down BEFORE the attach above has
2499
+ // already emitted its terminal event, so the `once('close')`
2500
+ // listeners will never fire. Consult the response-side socket state
2501
+ // directly (the REQUEST side is deliberately excluded — a fully
2502
+ // consumed IncomingMessage auto-destroys after 'end' on every normal
2503
+ // request, so `httpReq.destroyed` is not a disconnect signal). This
2504
+ // makes `streamSignal.aborted` authoritative for the H2 pre-dispatch
2505
+ // disconnect check inside the mutex callback.
2506
+ if (res.destroyed || res.writableEnded || abortSocket?.destroyed === true) {
2507
+ abortController.abort();
2508
+ }
2509
+ abortListenersAttached = true;
2510
+ const streamSignal: AbortSignal = abortController.signal;
2511
+
2512
+ // Persistence is a two-step dance.
2513
+ //
2514
+ // (1) INSIDE the per-model mutex (on the happy path only):
2515
+ // synchronously kick off `store.store(record)` via
2516
+ // `initiatePersist` — which registers the in-flight
2517
+ // promise in a per-store pending-write tracker keyed on
2518
+ // the response id. The mutex releases BEFORE the write
2519
+ // lands in SQLite.
2520
+ //
2521
+ // (2) AFTER the mutex releases: await the in-flight promise
2522
+ // just to surface errors to the log. The write is
2523
+ // already on its way; the caller waits purely for
2524
+ // logging completeness.
2525
+ //
2526
+ // A back-to-back `previous_response_id` continuation that fires
2527
+ // between mutex release and SQLite land observes the pending
2528
+ // write through the tracker (see the `getChain`-empty retry at
2529
+ // the top of this handler) and awaits it before falling
2530
+ // through to the 404 epilogue. This closes the race where a
2531
+ // fresh response id on the wire could transiently 404 under
2532
+ // `getChain`.
2533
+ //
2534
+ // `pendingPersistOuter` is the in-flight promise captured
2535
+ // inside the lock; the out-of-lock awaiter just catches errors
2536
+ // and logs them. `persistMode` is populated alongside so the
2537
+ // log line keeps the streaming / non-streaming discrimination.
2538
+ let pendingPersistOuter: Promise<void> | null = null;
2539
+ let persistMode: 'streaming' | 'non-streaming' | null = null;
2540
+ // Structural scaffolding for the binding retain paired with the
2541
+ // in-flight persist. The persist's `.finally(...)` calls this
2542
+ // closure on settlement to balance the `retainBinding` taken at
2543
+ // dispatch time — the closure's idempotency flag matters only
2544
+ // to that one call site today.
2545
+ //
2546
+ // The box shape is kept deliberately so a future iteration can
2547
+ // reintroduce a surgical "split teardown" (e.g. release heavy
2548
+ // resources on timeout while keeping identity pinned until
2549
+ // settlement) without rewiring the retain wrappers in both
2550
+ // dispatch branches. Do NOT force-release on post-commit
2551
+ // timeout: a slow-but-eventual persist can still land after
2552
+ // the timer fires, and releasing the retain before the write
2553
+ // settles lets an intervening same-object `unregister()` +
2554
+ // `register()` finalise the old binding and mint a fresh
2555
+ // instance id, causing the late write to record a stale id and
2556
+ // break the next `previous_response_id` continuation.
2557
+ //
2558
+ // Held in a box because TypeScript's control-flow analysis
2559
+ // otherwise narrows the in-closure assignment to `never`
2560
+ // across the intervening `await` / try-catch boundaries.
2561
+ const persistRetainBox: { release: (() => void) | null } = { release: null };
2562
+ // `failureMode` carries the streaming failure-epilogue reason
2563
+ // from `handleStreamingNative` out to the outer adopt gate.
2564
+ // A final-chunk commit followed by a post-terminal `res.close`
2565
+ // takes the `client_abort` branch and flushes `response.failed`
2566
+ // successfully, which would otherwise flip `safeToSuppress =
2567
+ // true` and let the adopt gate cache a session under a response
2568
+ // id the client will never chain off of. The gate refuses to
2569
+ // adopt when `failureMode === 'client_abort'` regardless of how
2570
+ // `committed` / `safeToSuppress` landed.
2571
+ let streamFailureMode: StreamingHandlerOutcome['failureMode'] = null;
2572
+
2573
+ // Bracket native-model dispatch with the idle-sweeper counter.
2574
+ // Must happen AFTER request validation / store lookup but BEFORE
2575
+ // any native prefill or decode runs — so the pending-drain timer
2576
+ // is cancelled in time to avoid racing the allocator with a live
2577
+ // decode, and the post-dispatch `endRequest` arms a fresh drain
2578
+ // only after every native stream byte has been emitted.
2579
+ // Only inference traffic participates; `/v1/models`, health, and
2580
+ // CORS preflights intentionally do not touch the counter.
2581
+ //
2582
+ // Attach the terminal-event listeners BEFORE any `await` — this
2583
+ // is the round-4 fix for a sweeper leak where a fast terminal
2584
+ // event (e.g. `endJson()` rejecting after the socket already
2585
+ // emitted `close`) fired before the outer `finally` got a chance
2586
+ // to attach its listeners, leaving `inFlight` pinned above zero.
2587
+ // `finalizeIdleRequest` is the idempotency barrier — whichever
2588
+ // of the listener events, the outer `finally`, or a pre-dispatch
2589
+ // path fires first wins and the rest are no-ops.
2590
+ idleSweeper?.beginRequest();
2591
+ idleRequestStarted = true;
2592
+ res.once('finish', onFinalizeEvent);
2593
+ res.once('close', onFinalizeEvent);
2594
+ res.once('error', onFinalizeEvent);
2595
+ idleListenersAttached = true;
2596
+
2597
+ try {
2598
+ const mutexQueuedAt = Date.now();
2599
+ const runInference = () => {
2600
+ return withAdmissionControlledInference(sessionReg, modelWorkCoordinator, preDispatchAdmission, async () => {
2601
+ const serverTiming: ServerTimingForUsage = {
2602
+ server_queue_ms: Date.now() - mutexQueuedAt,
2603
+ server_pre_inference_ms: Date.now() - handlerStartedAt,
2604
+ ...resolveServerTuningForUsage(),
2605
+ };
2606
+
2607
+ // Hot-swap race guard inside the mutex.
2608
+ //
2609
+ // `withExclusive` can park this waiter behind a long-running
2610
+ // dispatch on the same model, and `ModelRegistry.register()` is
2611
+ // NOT coordinated with that lock — a concurrent
2612
+ // `registry.register(body.model, newModel)` can re-point the
2613
+ // friendly name while we are parked. Without this in-lock re-read
2614
+ // the closure would still lease a session out of the already-
2615
+ // captured `preLockSessionReg`, adopt under the dead
2616
+ // `preLockInstanceId`, and persist the new chain under a binding
2617
+ // that `body.model` no longer resolves to. The pre-lock
2618
+ // re-read only covered the `store.getChain()` await window; the
2619
+ // mutex-wait window is strictly later and equally unsafe.
2620
+ //
2621
+ // Compare the live binding to the pre-lock snapshot (captured
2622
+ // just before entering the mutex — already refreshed on the
2623
+ // continuation path, identical to the handler-top snapshot
2624
+ // on the stateless path). Any drift — nullable or value — is
2625
+ // fatal and rejected with the same 400 envelope the pre-lock
2626
+ // guard uses, so clients see a consistent "binding changed"
2627
+ // error regardless of which await window caught the race.
2628
+ const lockedSessionReg = registry.getSessionRegistry(body.model);
2629
+ const lockedInstanceId = registry.getInstanceId(body.model);
2630
+ if (
2631
+ lockedSessionReg === undefined ||
2632
+ lockedInstanceId === undefined ||
2633
+ lockedSessionReg !== preLockSessionReg ||
2634
+ lockedInstanceId !== preLockInstanceId
2635
+ ) {
2636
+ sendBadRequest(
2637
+ res,
2638
+ `Model "${body.model}" binding changed while the request was queued behind the per-model ` +
2639
+ `execution mutex. A concurrent register() re-pointed the name at a different model instance ` +
2640
+ `(or released it entirely) while this waiter was parked, so the session registry and instance ` +
2641
+ `id captured before the mutex wait no longer match the live binding. Dispatching anyway would ` +
2642
+ `route the request through the wrong model — priming, decoding, and persisting under a dead ` +
2643
+ `binding. Retry the request — if the swap was intentional, the new binding will service the ` +
2644
+ `retry cleanly.`,
2645
+ 'model',
2646
+ );
2647
+ return;
2648
+ }
2649
+
2650
+ // Route the request through a `ChatSession` looked up by the prior
2651
+ // response id. A miss (null id, unknown id, expired entry, or
2652
+ // prefix-state mismatch) returns a fresh session; a hit leases the
2653
+ // cached session out of the registry (single-use — the entry is
2654
+ // removed on hit so overlapping requests against the same prior id
2655
+ // cannot race on the same single-flight ChatSession).
2656
+ //
2657
+ // Hot-path eligibility gate: the high-level chat-session API only
2658
+ // serves a SINGLE `user` or `tool` continuation message — the
2659
+ // `send` / `sendToolResult` entry points cover exactly that
2660
+ // shape. A single `assistant` / `system` continuation cannot
2661
+ // be advanced incrementally against the warm KV cache and
2662
+ // must be handled via reset + cold re-prime. That branch is
2663
+ // still VALID — it just routes through `runSession*`'s
2664
+ // `session.turns === 0` fall-through (`primeHistory` +
2665
+ // `startFromHistory*`) instead of the `send` / `sendToolResult`
2666
+ // session continuation path. Crucially, a tier-1 HIT on this branch is still
2667
+ // useful: `resetPreservingNativeCacheForWarmReuse(session)` keeps
2668
+ // the warm native KV cache, and the subsequent
2669
+ // `chat_session_start_sync` -> `verify_cache_prefix_direct`
2670
+ // recovers the reused prefix even across a full-history
2671
+ // replay. So we must NOT rewrite `previousResponseId` to null
2672
+ // to force a cold-replay lookup — the tier-1 lease is exactly
2673
+ // what makes warm reuse work. (Pre-Round 5 this branch passed
2674
+ // `null` to force a miss, but that was wrong: it threw away a
2675
+ // usable warm lease AND mislabeled the turn as `cold_replay`
2676
+ // when the native prefix verifier was about to reuse the
2677
+ // entire previous-turn prefix.) The hot-path-ineligibility
2678
+ // condition (single non-user/non-tool continuation) is no
2679
+ // longer consulted at lookup time — it's just the natural
2680
+ // fall-through to the `session.turns === 0 || multi-message`
2681
+ // cold-re-prime branch in `runSession*`, which correctly
2682
+ // preserves the warm native cache on HIT via
2683
+ // `resetPreservingNativeCacheForWarmReuse(session)`.
2684
+ // Normalize the caller-supplied `prompt_cache_key` into the
2685
+ // `string | null` shape the registry expects. `undefined` and
2686
+ // missing both map to `null`; an explicit empty string is
2687
+ // preserved distinct from `null` so the registry's tier-2
2688
+ // scan treats "no key" and "empty key" as different tenants
2689
+ // (prevents an unkeyed client from accidentally colliding
2690
+ // with one that explicitly empty-keyed).
2691
+ const promptCacheKey: string | null =
2692
+ typeof body.prompt_cache_key === 'string' ? body.prompt_cache_key : null;
2693
+ // Precedence gate: when `previous_response_id` is present, tier-2
2694
+ // (prompt-cache-key) lookup is DISABLED — even if the request is
2695
+ // hot-path ineligible (single `assistant` / `system` continuation).
2696
+ // The documented precedence is "prev-id wins; tier-1 miss falls
2697
+ // through to FRESH, not tier-2", and that rule has to hold whether
2698
+ // we take the hot path or the ineligible cold-replay branch. If we
2699
+ // let the ineligible branch fall through to tier-2, a mis-routed
2700
+ // prev-id request could lease an UNRELATED warm session that
2701
+ // happens to share `prompt_cache_key`, then cold-replay on top of
2702
+ // it — which `session.reset()` + `primeHistory()` would destroy,
2703
+ // corrupting an unrelated chain. Force `null` for the cache key on
2704
+ // both branches whenever a prev-id is set; tier-2 only runs for
2705
+ // requests with no prev-id at all.
2706
+ const effectivePromptCacheKey = previousResponseId != null ? null : promptCacheKey;
2707
+ // Integrator nudge: if the caller supplied a non-empty
2708
+ // `prompt_cache_key` but tier-2 prerequisites are missing
2709
+ // (env gate off or key below the min-length floor) the turn
2710
+ // silently cold-starts. Emit a once-per-distinct-raw-key
2711
+ // stderr warning so `X-Session-Cache: fresh` on every request
2712
+ // can be diagnosed without reading source. Gated on
2713
+ // `effectivePromptCacheKey` (not raw `promptCacheKey`) so a
2714
+ // request that suppresses the key via `previous_response_id`
2715
+ // precedence does not also log a misleading "key ignored"
2716
+ // message — that case is documented precedence, not a
2717
+ // misconfiguration.
2718
+ if (effectivePromptCacheKey !== null) {
2719
+ maybeWarnPromptCacheKeyIneligible(effectivePromptCacheKey);
2720
+ }
2721
+
2722
+ // H2 pre-dispatch disconnect check (non-streaming only). A
2723
+ // client that vanished while this request was parked behind the
2724
+ // per-model mutex must not burn a whole prefill+decode budget
2725
+ // producing a JSON body nobody can receive. Checked BEFORE the
2726
+ // session lease so no warm entry is consumed and no native
2727
+ // state is touched — the early return composes with the permit
2728
+ // lifecycle exactly like the binding-changed return above (the
2729
+ // pre-dispatch permit was already consumed atomically by
2730
+ // `withExclusive`, and the handler's outer `finally` release is
2731
+ // an idempotent no-op after that handoff). Streaming keeps its
2732
+ // existing paths: the signal fast-aborts `_runChatStream` and
2733
+ // the SSE drain loop breaks on `clientAborted` at loop-top.
2734
+ if (mappedBody.stream !== true && abortController.signal.aborted) {
2735
+ return;
2736
+ }
2737
+
2738
+ // Multi-tool-call fan-out gate.
2739
+ //
2740
+ // The chat-session API cannot interleave tool results for a
2741
+ // multi-call fan-out turn (each `sendToolResult` dispatch re-opens
2742
+ // the assistant turn, so responding to the siblings would weave new
2743
+ // assistant replies between the results — see
2744
+ // `ChatSession.pendingUnresolvedToolCallCount`). The only valid forward
2745
+ // progress from such a turn is an atomic replay that resolves every
2746
+ // sibling call in one cold-restart, so we reject any continuation
2747
+ // whose submitted `function_call_output` set does not exactly match
2748
+ // the outstanding call ids.
2749
+ //
2750
+ // The gate only runs for `previous_response_id` continuations, where
2751
+ // the STORED prior chain (`priorMessages`, reconstructed via
2752
+ // `reconstructMessagesFromChain`) is the authoritative view of the
2753
+ // trailing assistant turn and `newInputMessages` contains only the
2754
+ // caller's continuation delta. Stateless requests (no
2755
+ // `previous_response_id`) carry a full self-contained history in
2756
+ // `input`, and historical tool outputs for prior resolved turns
2757
+ // would otherwise be misclassified against the latest assistant's
2758
+ // outstanding id set — leave cold-start histories to the jinja
2759
+ // template / chat-session prefill to handle as-is.
2760
+ const expectedOutstandingIds = priorMessages ? extractOutstandingToolCallIds(priorMessages) : null;
2761
+
2762
+ // Forged-tool-output guard. A `previous_response_id` continuation that
2763
+ // submits any `function_call_output` when the stored prior chain has
2764
+ // ZERO outstanding tool calls is structurally invalid: there is no
2765
+ // assistant tool call for the result to resolve, so dispatching it
2766
+ // would inject a synthetic `<tool_response>` delta into a thread the
2767
+ // model never asked to call. Native backends do not authenticate
2768
+ // `tool_call_id` against prior state — several just append the
2769
+ // delta verbatim — so the gate must live here. Stateless requests
2770
+ // (no `previous_response_id`) carry a full self-contained history
2771
+ // and are left to the jinja template / chat-session prefill.
2772
+ if (previousResponseId && expectedOutstandingIds === null) {
2773
+ for (const m of newInputMessages) {
2774
+ if (m.role === 'tool') {
2775
+ sendBadRequest(
2776
+ res,
2777
+ `function_call_output submitted against a thread with no outstanding tool call. ` +
2778
+ `The prior assistant turn either never emitted a tool call or every sibling call has ` +
2779
+ `already been resolved, so there is nothing for this function_call_output to answer. ` +
2780
+ `Dispatching it anyway would synthesize a tool-response delta for a call the model ` +
2781
+ `never made and corrupt the conversation structure. Drop the function_call_output, ` +
2782
+ `or start a new chain without previous_response_id.`,
2783
+ 'input',
2784
+ );
2785
+ return;
2786
+ }
2787
+ }
2788
+ }
2789
+
2790
+ if (expectedOutstandingIds !== null) {
2791
+ // Contiguous-prefix guard: function_call_output items must appear
2792
+ // as an unbroken prefix of the continuation delta, before any
2793
+ // user/assistant/system message. A shape like
2794
+ // `[tool(call_a), user(hi), tool(call_b)]` would otherwise pass
2795
+ // every id-set check below (both outstanding ids present, no
2796
+ // duplicates, no stale ids) while still orphaning the fan-out,
2797
+ // because the interleaved user turn re-opens the assistant turn
2798
+ // between the two tool results. Reject early so the caller cannot
2799
+ // smuggle a user turn into the middle of a resolved fan-out.
2800
+ let seenNonTool = false;
2801
+ for (const m of newInputMessages) {
2802
+ if (m.role === 'tool') {
2803
+ if (seenNonTool) {
2804
+ sendBadRequest(
2805
+ res,
2806
+ `function_call_output items must appear as a contiguous prefix of the continuation ` +
2807
+ `before any user, assistant, or system message. Interleaving a non-tool message ` +
2808
+ `between sibling function_call_output items orphans the fan-out by weaving a new ` +
2809
+ `assistant turn between the tool results. Reorder the submission so every ` +
2810
+ `function_call_output precedes any subsequent message, or start a new chain ` +
2811
+ `without previous_response_id.`,
2812
+ 'input',
2813
+ );
2814
+ return;
2815
+ }
2816
+ } else {
2817
+ seenNonTool = true;
2818
+ }
2819
+ }
2820
+
2821
+ const submittedIds: string[] = [];
2822
+ for (const m of newInputMessages) {
2823
+ if (m.role === 'tool' && typeof m.toolCallId === 'string' && m.toolCallId.length > 0) {
2824
+ submittedIds.push(m.toolCallId);
2825
+ }
2826
+ }
2827
+
2828
+ // Short-circuit: a plain user continuation (zero tool results)
2829
+ // would orphan the outstanding call(s) just as surely as a
2830
+ // partial tool-result submission. Reject both paths with the
2831
+ // same 400.
2832
+ const plural = expectedOutstandingIds.length > 1;
2833
+ if (submittedIds.length === 0) {
2834
+ sendBadRequest(
2835
+ res,
2836
+ `Previous assistant turn has ${expectedOutstandingIds.length} unresolved tool call${plural ? 's' : ''} ` +
2837
+ `(${expectedOutstandingIds.join(', ')}); the chat-session API requires every outstanding ` +
2838
+ `function_call_output to be submitted before the thread can advance. A plain user turn ` +
2839
+ `would orphan the unresolved call${plural ? 's' : ''}. Submit function_call_output items for ` +
2840
+ `every outstanding id, or start a new chain without previous_response_id.`,
2841
+ 'input',
2842
+ );
2843
+ return;
2844
+ }
2845
+
2846
+ const expectedSet = new Set(expectedOutstandingIds);
2847
+ const seen = new Set<string>();
2848
+ for (const id of submittedIds) {
2849
+ if (seen.has(id)) {
2850
+ sendBadRequest(
2851
+ res,
2852
+ `Duplicate function_call_output call_id "${id}" — each outstanding tool call must be answered exactly once.`,
2853
+ 'input',
2854
+ );
2855
+ return;
2856
+ }
2857
+ seen.add(id);
2858
+ if (!expectedSet.has(id)) {
2859
+ sendBadRequest(
2860
+ res,
2861
+ `Unexpected function_call_output call_id "${id}"; the outstanding multi-tool-call set is ` +
2862
+ `${expectedOutstandingIds.join(', ')}. Submitting an unrelated or stale call_id would advance ` +
2863
+ `the chain past an unresolved turn.`,
2864
+ 'input',
2865
+ );
2866
+ return;
2867
+ }
2868
+ }
2869
+ if (seen.size !== expectedSet.size) {
2870
+ const missing: string[] = [];
2871
+ for (const id of expectedOutstandingIds) {
2872
+ if (!seen.has(id)) missing.push(id);
2873
+ }
2874
+ sendBadRequest(
2875
+ res,
2876
+ `Missing function_call_output items for outstanding tool calls: ${missing.join(', ')}. ` +
2877
+ `Partial submissions would orphan the sibling tool calls and advance the chain past an ` +
2878
+ `unresolved turn. Resubmit with every sibling output, or start a new chain without ` +
2879
+ `previous_response_id.`,
2880
+ 'input',
2881
+ );
2882
+ return;
2883
+ }
2884
+
2885
+ // All outstanding ids are accounted for. Canonicalize the submitted
2886
+ // tool-message order to the stored sibling order before the replay
2887
+ // runs — both `messages` (primed into the fresh session on the cold
2888
+ // path) and `newInputMessages` (persisted verbatim into the store
2889
+ // for future chain reconstruction) must reflect the canonical
2890
+ // order, otherwise a caller can swap outputs and silently poison
2891
+ // replay even after the id-set gate passes.
2892
+ //
2893
+ // Compute the tool block's end as the contiguous-prefix run of
2894
+ // `role === 'tool'` messages starting at `priorOffset`. The
2895
+ // contiguous-prefix guard above already rejected any shape that
2896
+ // interleaves a non-tool message inside the delta's tool block,
2897
+ // so this simple forward scan matches the exact block the gate
2898
+ // just authenticated. Passing an explicit `blockEnd` keeps the
2899
+ // helper from accidentally walking into any later turn that
2900
+ // `mapRequest` may have appended to `messages`.
2901
+ let deltaBlockEnd = priorOffset;
2902
+ while (deltaBlockEnd < messages.length && messages[deltaBlockEnd]!.role === 'tool') {
2903
+ deltaBlockEnd++;
2904
+ }
2905
+ canonicalizeToolMessageOrder(messages, priorOffset, deltaBlockEnd, expectedOutstandingIds);
2906
+ newInputMessages = messages.slice(priorOffset);
2907
+ }
2908
+
2909
+ // Walk the full merged history and canonicalize every assistant
2910
+ // fan-out's trailing tool block against its declared sibling order.
2911
+ //
2912
+ // The multi-tool-call gate above only fires on `previous_response_id`
2913
+ // continuations, and even there it only handles the caller's delta
2914
+ // block against the STORED prior chain's trailing assistant. That
2915
+ // leaves two cases uncovered:
2916
+ //
2917
+ // 1. Stateless cold-start histories (no `previous_response_id`).
2918
+ // The caller ships a full self-contained conversation through
2919
+ // `input`; the gate is skipped entirely and the caller-supplied
2920
+ // tool-message order flows straight into `primeHistory()`. A
2921
+ // caller can reverse two sibling tool outputs, and since
2922
+ // several native session backends pair tool results to
2923
+ // fan-out calls POSITIONALLY (not by id), each result binds
2924
+ // to the wrong sibling call.
2925
+ // 2. Earlier fan-outs embedded inside the stored prior history
2926
+ // on a continuation. Those came from the server's own store
2927
+ // so they should already be canonical, but defense in depth
2928
+ // is cheap — a single full-history walk covers every shape.
2929
+ //
2930
+ // Malformed histories (missing/duplicate/unknown ids, orphan tool
2931
+ // messages, unresolved trailing fan-out in a stateless request)
2932
+ // are rejected with a clear 400 instead of silently rewritten.
2933
+ const historyError = validateAndCanonicalizeHistoryToolOrder(messages);
2934
+ if (historyError !== null) {
2935
+ sendBadRequest(res, historyError, 'input');
2936
+ return;
2937
+ }
2938
+ // Canonicalization may have reordered tool messages inside the
2939
+ // continuation delta (on the stateless-history walk over the
2940
+ // post-priorOffset portion), so recompute `newInputMessages` from
2941
+ // the now-canonical `messages`.
2942
+ newInputMessages = messages.slice(priorOffset);
2943
+
2944
+ // Lease only after every request-shape validation has passed. Once a
2945
+ // session is removed from the warm slot, the try/finally below owns
2946
+ // it until a clean turn adopts it again.
2947
+ const pagedActive = leaseModel.hasBlockPagedCache?.() === true;
2948
+ const lookup = sessionReg.getOrCreate(
2949
+ previousResponseId ?? null,
2950
+ requestedInstructions,
2951
+ effectivePromptCacheKey,
2952
+ config.cacheSalt ?? null,
2953
+ );
2954
+ const session = lookup.session;
2955
+ await sessionReg.flushPendingDisposals();
2956
+ let sessionRetained = false;
2957
+ let sessionCleanupStarted = false;
2958
+ const disposeUnretainedSession = async (): Promise<void> => {
2959
+ if (sessionRetained || sessionCleanupStarted) return;
2960
+ sessionCleanupStarted = true;
2961
+ try {
2962
+ await sessionReg.disposeSession(session);
2963
+ } catch (error) {
2964
+ console.error('[responses] failed to release an unretained chat-session cache owner:', error);
2965
+ }
2966
+ };
2967
+
2968
+ // Visibility / wire-format tracker shared between the handler
2969
+ // body and the outer catch. Declared outside the `try` so the
2970
+ // catch can branch on `responseMode` (JSON vs SSE) and know
2971
+ // whether a terminal artefact already landed — both signals
2972
+ // are authoritative, unlike `res.headersSent`.
2973
+ const visibility = createVisibility();
2974
+
2975
+ try {
2976
+ const tier2Hit = previousResponseId == null && lookup.hit;
2977
+ const isStreaming = mappedBody.stream === true;
2978
+ // Streaming headers are conservative because native cached-token
2979
+ // evidence is not available until after SSE headers have flushed.
2980
+ let sessionCacheStatus: SessionCacheStatus =
2981
+ previousResponseId == null
2982
+ ? tier2Hit && !isStreaming
2983
+ ? 'prefix_hit'
2984
+ : 'fresh'
2985
+ : lookup.hit
2986
+ ? 'hit'
2987
+ : 'cold_replay';
2988
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
2989
+
2990
+ // `runSession*` plumbs an honest commit signal out of the helper:
2991
+ // `ChatSession` only advances `turns` on a successful non-error
2992
+ // final chunk (streaming) or a resolved native promise
2993
+ // (non-streaming). The streaming safety-net path (generator
2994
+ // exhausts without a `done` event, see `handleStreamingNative`
2995
+ // fallback) and the `finishReason === 'error'` final chunk both
2996
+ // leave `turns` unchanged. The helper captures its baseline
2997
+ // AFTER any internal `session.reset()` on the multi-message
2998
+ // reset-and-cold-restart branch, so the signal is honest there
2999
+ // too — a pre-helper snapshot would be stale.
3000
+ let committed: boolean;
3001
+ // Pass `mappedBody` (not the raw `body`) so the response
3002
+ // object and the persisted record carry the EFFECTIVE
3003
+ // instructions, including any value inherited from the
3004
+ // trailing stored record via instruction inheritance.
3005
+ // Using `body` here
3006
+ // would re-drop the inherited value on the wire — the
3007
+ // client's response would report `instructions: null` even
3008
+ // though the turn was run against the inherited system
3009
+ // context, and the next cold replay would have nothing to
3010
+ // re-inherit from.
3011
+ // Wrap the handler call in its own try/catch so that a
3012
+ // post-commit persistence failure does not prevent adopt.
3013
+ // Post-commit store failures are caught inside the handlers
3014
+ // themselves (handleNonStreaming / handleStreamingNative) and
3015
+ // demoted to log-only. A handlerError at this level therefore
3016
+ // comes from non-persistence failures (response construction,
3017
+ // SSE write, res.writeHead/end crash).
3018
+ //
3019
+ // `res.headersSent` is NOT a reliable proxy for "the client
3020
+ // received the response": Node's `writeHead` flips
3021
+ // `headersSent = true` synchronously before any body bytes
3022
+ // leave the buffer, and the sync return of `res.end()` /
3023
+ // `writeSSEEvent` only proves the bytes were queued — an
3024
+ // async socket failure after the queue could still leave
3025
+ // the client with no terminal. Picking JSON-vs-SSE fallback
3026
+ // from `res.headersSent` is also unsafe because a
3027
+ // `writeHead(200, 'application/json')` → `res.end()` crash
3028
+ // would otherwise emit SSE frames into a JSON-declared
3029
+ // response.
3030
+ //
3031
+ // The `TransportVisibility` record instead tracks both the
3032
+ // wire format the handler committed to (`responseMode`)
3033
+ // AND whether the client observed a terminal artefact
3034
+ // (`responseBodyWritten` / `terminalEmitted`). Both flags
3035
+ // are flipped only from the kernel-ack callback of the
3036
+ // underlying `res.end` / `res.write` — synchronous return
3037
+ // is NOT treated as proof of visibility. The outer catch
3038
+ // branches on `responseMode` to choose the clean-up shape
3039
+ // (JSON error, SSE `error` frame, or socket destroy).
3040
+ let handlerError: Error | null = null;
3041
+
3042
+ if (mappedBody.stream) {
3043
+ const outcome = await runSessionStreaming(
3044
+ session,
3045
+ messages,
3046
+ newInputMessages,
3047
+ config,
3048
+ streamSignal,
3049
+ !lookup.hit && !pagedActive,
3050
+ );
3051
+ const streamingWasCommitted = () => outcome.wasCommitted();
3052
+ try {
3053
+ const handlerOutcome = await handleStreamingNative(
3054
+ res,
3055
+ outcome.stream,
3056
+ mappedBody,
3057
+ responseId,
3058
+ previousResponseId,
3059
+ streamingWasCommitted,
3060
+ httpReq,
3061
+ visibility,
3062
+ serverTiming,
3063
+ );
3064
+ streamFailureMode = handlerOutcome.failureMode;
3065
+ if (handlerOutcome.terminalToPersist != null && store && body.store !== false) {
3066
+ // Initiate the write SYNCHRONOUSLY inside the mutex so
3067
+ // the pending-write tracker observes it before the
3068
+ // mutex releases. The promise is awaited off-lock in
3069
+ // the outer finally block.
3070
+ const record = buildResponseRecord(
3071
+ handlerOutcome.terminalToPersist,
3072
+ newInputMessages,
3073
+ previousResponseId,
3074
+ currentInstanceId,
3075
+ effectiveRetentionSec,
3076
+ );
3077
+ // Pair a `retainBinding` against the persist promise
3078
+ // so the binding's `modelInstanceId` survives a
3079
+ // concurrent same-model unregister + re-register that
3080
+ // races the post-commit write. `releaseBinding` runs
3081
+ // in the persist's `.finally(...)` regardless of
3082
+ // outcome, so the retention counter stays balanced
3083
+ // whether the write fulfils or rejects.
3084
+ //
3085
+ // Leaving the retain pinned forever on a wedged write
3086
+ // would make the binding unreclaimable until process
3087
+ // restart, so an INDEPENDENT hard-timeout timer is
3088
+ // armed alongside the persist (see
3089
+ // `getPostCommitPersistHardTimeoutMs` for the default).
3090
+ // If the persist settles naturally the timer is
3091
+ // cancelled via `clearTimeout` inside the same
3092
+ // `.finally(...)` — slow-but-eventual writes are
3093
+ // unaffected. If the persist is still wedged past the
3094
+ // hard bound, the timer fires and force-releases the
3095
+ // retain via the idempotent `persistRetainBox`. The
3096
+ // hard timer is armed off the handler's await path, so
3097
+ // the response is never delayed by it.
3098
+ //
3099
+ // Before the hard timeout force-releases the retain
3100
+ // (which unblocks binding teardown), it calls
3101
+ // `registry.retireInstanceIdForForceRelease(leaseModel)`
3102
+ // to tombstone the binding's current instance id on
3103
+ // the model object. A subsequent `register()` of the
3104
+ // SAME model object inherits that retired id rather
3105
+ // than minting fresh — so the late-landing persist's
3106
+ // record (stamped with the retired id) still matches
3107
+ // the live binding and stays chainable through
3108
+ // `previous_response_id`. Only a true hot-swap
3109
+ // (re-register with a DIFFERENT model object) mints a
3110
+ // fresh id, and the 400 instance-mismatch that results
3111
+ // is the correct semantic outcome because the new
3112
+ // model is semantically different from the one that
3113
+ // produced the stored record. Retirement MUST happen
3114
+ // BEFORE release so `instanceIds.get(model)` still
3115
+ // returns the live id the record carries.
3116
+ //
3117
+ // The tombstone's lifetime is scoped to the pending
3118
+ // persists that installed it — the `.finally(...)`
3119
+ // calls `registry.releaseTombstone(leaseModel)` so
3120
+ // that when the late write eventually settles
3121
+ // (fulfills or rejects), the shared refcount drops
3122
+ // and, once every outstanding persist has released,
3123
+ // any subsequent re-registration correctly mints a
3124
+ // fresh id. Without this scoping, a past hard-timeout
3125
+ // event would permanently re-enable id inheritance
3126
+ // across unrelated later lifecycles — reopening
3127
+ // stale-chain replay across what should be logically
3128
+ // dead bindings. The refcounted single-entry layout
3129
+ // handles OVERLAPPING hard-timeouts on the same live
3130
+ // instance id in bounded space: every breaker targets
3131
+ // the SAME retired id (the register-inherit path
3132
+ // keeps using it while the tombstone is alive) so one
3133
+ // shared refcount safely collapses every in-flight
3134
+ // retire, and memory stays O(1) per model even under
3135
+ // a truly wedged store that never settles.
3136
+ registry.retainBinding(leaseModel);
3137
+ let persistRetainReleased = false;
3138
+ persistRetainBox.release = () => {
3139
+ if (persistRetainReleased) return;
3140
+ persistRetainReleased = true;
3141
+ registry.releaseBinding(leaseModel);
3142
+ };
3143
+ const streamingPersistMode = 'streaming' as const;
3144
+ const streamingHardTimeoutMs = getPostCommitPersistHardTimeoutMs();
3145
+ let retiredTombstone: { instanceId: number } | undefined;
3146
+ // Compute the scalar `absoluteExpiresAtMs` ONCE up
3147
+ // front — the MINIMUM of the newly produced record's
3148
+ // own row expiry and the earliest expiry across any
3149
+ // resolved ancestor chain. This value is threaded
3150
+ // into both the pending-write tracker at
3151
+ // `initiatePersist()` time (so the pre-breaker
3152
+ // `awaitPending` path can short-circuit to 404 once
3153
+ // the bound is crossed) AND the hard-timeout marker
3154
+ // at breaker-fire time (absolute cap). The
3155
+ // hard-timeout closure captures ONLY this scalar —
3156
+ // NOT the full resolved chain — so the closure's
3157
+ // retained heap stays O(1) under sustained pending
3158
+ // continuations against a degraded backend.
3159
+ //
3160
+ // `record.expiresAt` is epoch-seconds (see
3161
+ // `buildResponseRecord` — it adds
3162
+ // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now() /
3163
+ // 1000)`); convert to ms at this boundary. If both
3164
+ // the record and the chain lack a finite expiry
3165
+ // (legacy rows), fall back to
3166
+ // `Number.POSITIVE_INFINITY` at the marker call site
3167
+ // so TTL-only bounding still holds.
3168
+ const recordExpiresAtMs =
3169
+ record.expiresAt != null && Number.isFinite(record.expiresAt) ? record.expiresAt * 1000 : undefined;
3170
+ const absoluteExpiresAtMs =
3171
+ recordExpiresAtMs !== undefined && chainEarliestExpiresAtMs !== undefined
3172
+ ? Math.min(recordExpiresAtMs, chainEarliestExpiresAtMs)
3173
+ : (recordExpiresAtMs ?? chainEarliestExpiresAtMs);
3174
+ const streamingHardTimeoutHandle: ReturnType<typeof setTimeout> | null =
3175
+ streamingHardTimeoutMs > 0
3176
+ ? setTimeout(() => {
3177
+ if (persistRetainReleased) return;
3178
+ console.error(
3179
+ `[responses] post-commit persist HARD timeout (${streamingHardTimeoutMs}ms, ` +
3180
+ `${streamingPersistMode}): underlying store.store(...) has not settled; assuming ` +
3181
+ `wedged backend, force-releasing the binding retain so the binding can be torn ` +
3182
+ `down. Retiring the current instance id via tombstone so a same-object ` +
3183
+ `re-registration inherits it and a late-landing persist remains chainable; a ` +
3184
+ `hot-swap to a DIFFERENT model object will mint a fresh id and the stale chain ` +
3185
+ `will correctly fail with 400 instance-mismatch.`,
3186
+ );
3187
+ // Move the pending-write tracker entry into
3188
+ // the hard-timed-out marker state for this
3189
+ // response id. The pending entry is dropped
3190
+ // so a wedged store.store(...) does not pin
3191
+ // one promise closure + tracker entry per
3192
+ // hard-timed-out request, AND the id is added
3193
+ // to the `hardTimedOut` marker so a concurrent
3194
+ // `previous_response_id` continuation can
3195
+ // tell the difference between a permanent
3196
+ // 404 and a slow-but-eventual persist that
3197
+ // crossed the hard timeout. The continuation
3198
+ // path consults `isHardTimedOut(id)` before
3199
+ // falling through to `sendNotFound(...)` and
3200
+ // returns retryable 503 `storage_timeout`
3201
+ // instead, so clients keep retrying rather
3202
+ // than discarding the chain. The marker has
3203
+ // two cleanup paths: (1) fast — the underlying
3204
+ // store promise's `.finally(...)` inside
3205
+ // `track()` fires when the wedged store
3206
+ // unwedges; (2) slow — an independent TTL
3207
+ // (`MLX_HARD_TIMEOUT_MARKER_TTL_MS`, default
3208
+ // 300s) bounds memory at O(requestRate × TTL)
3209
+ // even against a truly wedged store that
3210
+ // NEVER settles. Marker lifetime =
3211
+ // min(settlement, TTL expiry).
3212
+ //
3213
+ // Pass the record's absolute row expiry as a
3214
+ // hard cap on the marker. The record's
3215
+ // `expiresAt` field is epoch-seconds (see
3216
+ // `buildResponseRecord` — it adds
3217
+ // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now()
3218
+ // / 1000)`), so convert to ms for the marker
3219
+ // map. Once the absolute bound passes,
3220
+ // `ResponseStore.getChain()` hides the row and
3221
+ // the retryable-503 classification is factually
3222
+ // wrong — the marker must flip to 404 regardless
3223
+ // of ongoing client retries.
3224
+ //
3225
+ // Capture ONLY the precomputed scalar
3226
+ // `absoluteExpiresAtMs` in this closure — NOT
3227
+ // the full resolved chain. The scalar is
3228
+ // `min(record.expiresAt * 1000,
3229
+ // chainEarliestExpiresAtMs)`, computed once
3230
+ // when the hard-timeout handle was armed
3231
+ // above. `ResponseStore.getChain()` walks
3232
+ // ancestors and aborts on the first expired
3233
+ // link (see
3234
+ // `crates/mlx-db/src/response_store/reader.rs:44-59`),
3235
+ // so clamping the marker at whichever link
3236
+ // would disappear from `getChain()` first is
3237
+ // the authoritative bound. Capturing only
3238
+ // the scalar means background pending
3239
+ // continuations under a degraded store do
3240
+ // not retain ancestor transcripts —
3241
+ // heap growth stays O(1) per hard-timed-out
3242
+ // persist regardless of chain length.
3243
+ getPendingWritesFor(store).markHardTimedOut(
3244
+ record.id,
3245
+ getHardTimedOutMarkerTtlMs(),
3246
+ absoluteExpiresAtMs ?? Number.POSITIVE_INFINITY,
3247
+ );
3248
+ // Retire the id FIRST (binding is still alive
3249
+ // here — retirement reads the live id) then
3250
+ // drop the retain, which may trigger the
3251
+ // deferred teardown. Capture the retired id so
3252
+ // the persist's `.finally(...)` can release
3253
+ // the tombstone once the late write eventually
3254
+ // settles. The registry stores one refcounted
3255
+ // tombstone per model regardless of how many
3256
+ // hard-timeouts overlap — each retire
3257
+ // increments the shared counter and each
3258
+ // release decrements it — so the returned
3259
+ // `{ instanceId }` is captured as a presence
3260
+ // flag and `releaseTombstone(leaseModel)` is
3261
+ // called in the persist's `.finally(...)`.
3262
+ retiredTombstone = registry.retireInstanceIdForForceRelease(leaseModel);
3263
+ persistRetainBox.release?.();
3264
+ }, streamingHardTimeoutMs)
3265
+ : null;
3266
+ pendingPersistOuter = initiatePersist(store, record, absoluteExpiresAtMs).finally(() => {
3267
+ if (streamingHardTimeoutHandle !== null) {
3268
+ clearTimeout(streamingHardTimeoutHandle);
3269
+ }
3270
+ // If the hard-timeout breaker fired and installed a
3271
+ // tombstone on `leaseModel`, decrement the shared
3272
+ // refcount now that this persist has settled. The
3273
+ // single-entry refcount layout means overlapping
3274
+ // breakers share one slot — releasing one balances
3275
+ // one retire, and the entry survives until the
3276
+ // last outstanding persist releases.
3277
+ if (retiredTombstone !== undefined) {
3278
+ registry.releaseTombstone(leaseModel);
3279
+ }
3280
+ persistRetainBox.release?.();
3281
+ });
3282
+ persistMode = streamingPersistMode;
3283
+ }
3284
+ } catch (err) {
3285
+ handlerError = err instanceof Error ? err : new Error(String(err));
3286
+ }
3287
+ committed = streamingWasCommitted();
3288
+ } else {
3289
+ // Non-streaming cancellation (H2): `streamSignal` threads
3290
+ // through `ChatSession.send/sendToolResult/startFromHistory`
3291
+ // into the normal public session method; the wrapper maps it to
3292
+ // the internal native operation, so a client that disconnects mid-generation
3293
+ // flips the controller, the native turn unwinds at the
3294
+ // next safepoint, and the dispatch REJECTS with
3295
+ // "chat session cancelled" (routed through the ordinary
3296
+ // uncommitted-error epilogue below — no adopt, no
3297
+ // persist). A disconnect that lands before dispatch takes
3298
+ // the pre-dispatch early return above instead. The
3299
+ // disconnect-aware skip inside `handleNonStreaming` /
3300
+ // `endJson` remains the last line of defense for a
3301
+ // disconnect racing the final flush.
3302
+ const outcome = await runSessionNonStreaming(
3303
+ session,
3304
+ messages,
3305
+ newInputMessages,
3306
+ config,
3307
+ !lookup.hit && !pagedActive,
3308
+ streamSignal,
3309
+ );
3310
+ // Prefix-cache observability headers for the non-streaming
3311
+ // path. `res.end` has not fired yet (the handler's
3312
+ // `endJson` call below is what flushes), so `setHeader`
3313
+ // still lands on the wire. We re-classify the
3314
+ // `X-Session-Cache` header here so a tier-2 lookup that
3315
+ // did NOT actually produce native prefix reuse
3316
+ // (`cachedTokens === 0`) gets demoted from the optimistic
3317
+ // `prefix_hit` back to `fresh` — matching the plan's
3318
+ // contract that `prefix_hit` only fires when the registry
3319
+ // served a match via `promptCacheKey` AND the ChatResult
3320
+ // reports `cachedTokens > 0`. The companion
3321
+ // `X-Cached-Tokens: N` header reports the exact count for
3322
+ // operators and downstream telemetry whenever reuse
3323
+ // happened.
3324
+ if (tier2Hit && outcome.result.cachedTokens === 0) {
3325
+ sessionCacheStatus = 'fresh';
3326
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
3327
+ }
3328
+ if (outcome.result.cachedTokens > 0) {
3329
+ res.setHeader('X-Cached-Tokens', String(outcome.result.cachedTokens));
3330
+ }
3331
+ try {
3332
+ const handlerOutcome = await handleNonStreaming(
3333
+ res,
3334
+ outcome.result,
3335
+ mappedBody,
3336
+ responseId,
3337
+ previousResponseId,
3338
+ visibility,
3339
+ serverTiming,
3340
+ );
3341
+ if (store && body.store !== false) {
3342
+ // Same in-lock-initiate / off-lock-await split as the
3343
+ // streaming branch. The non-streaming handler only
3344
+ // returns when the JSON body's `res.end()` callback
3345
+ // has fired, so reaching this point means the client
3346
+ // observed the turn — the pending-write tracker
3347
+ // protects a back-to-back continuation from a
3348
+ // transient 404.
3349
+ const record = buildResponseRecord(
3350
+ handlerOutcome.response,
3351
+ newInputMessages,
3352
+ previousResponseId,
3353
+ currentInstanceId,
3354
+ effectiveRetentionSec,
3355
+ );
3356
+ // See the streaming branch for the retain/release
3357
+ // rationale — a same-model unregister + re-register
3358
+ // during the slow persist must not mint a fresh
3359
+ // `modelInstanceId` that invalidates the row this
3360
+ // write is about to land. The idempotent-release
3361
+ // scaffolding is a structural hook for a future split
3362
+ // teardown; the post-commit SOFT timeout arm does not
3363
+ // force-fire it.
3364
+ //
3365
+ // A wedged persist would otherwise leak the binding
3366
+ // retain for the lifetime of the process, so the
3367
+ // hard-timeout timer is armed here in the same shape
3368
+ // as the streaming branch, cancelled from the
3369
+ // persist's own `.finally(...)` when the write settles
3370
+ // naturally, and fires a force-release through the
3371
+ // idempotent `persistRetainBox` otherwise. Default
3372
+ // 60s, override via
3373
+ // `MLX_POST_COMMIT_PERSIST_HARD_TIMEOUT_MS`, `'0'`
3374
+ // disables. Empty string is treated as unset (falls
3375
+ // back to the 60000ms default) so a config-templating
3376
+ // typo cannot silently disable the breaker.
3377
+ //
3378
+ // The force-release path also calls
3379
+ // `registry.retireInstanceIdForForceRelease(leaseModel)`
3380
+ // BEFORE releasing the retain so a same-object
3381
+ // re-registration AFTER teardown inherits the retired
3382
+ // instance id from the tombstone — a late-landing
3383
+ // persist against the retired id stays chainable. A
3384
+ // hot-swap to a DIFFERENT model object mints a fresh
3385
+ // id and the 400 instance-mismatch is correct.
3386
+ //
3387
+ // The tombstone's lifetime is scoped to the pending
3388
+ // persists that installed it — the `.finally(...)`
3389
+ // calls `registry.releaseTombstone(leaseModel)` so
3390
+ // that when the late write eventually settles, the
3391
+ // shared refcount drops and, once every outstanding
3392
+ // persist has released, any subsequent
3393
+ // re-registration correctly mints a fresh id. Without
3394
+ // this scoping, a past hard-timeout event would
3395
+ // permanently re-enable id inheritance across
3396
+ // unrelated later lifecycles — reopening stale-chain
3397
+ // replay across what should be logically dead
3398
+ // bindings. The refcounted single-entry layout
3399
+ // handles OVERLAPPING hard-timeouts on the same live
3400
+ // instance id in bounded space: every breaker targets
3401
+ // the SAME retired id (the register-inherit path
3402
+ // keeps using it while the tombstone is alive) so one
3403
+ // shared refcount safely collapses every in-flight
3404
+ // retire, and memory stays O(1) per model even under
3405
+ // a truly wedged store that never settles.
3406
+ registry.retainBinding(leaseModel);
3407
+ let persistRetainReleased = false;
3408
+ persistRetainBox.release = () => {
3409
+ if (persistRetainReleased) return;
3410
+ persistRetainReleased = true;
3411
+ registry.releaseBinding(leaseModel);
3412
+ };
3413
+ const nonStreamingPersistMode = 'non-streaming' as const;
3414
+ const nonStreamingHardTimeoutMs = getPostCommitPersistHardTimeoutMs();
3415
+ let retiredTombstone: { instanceId: number } | undefined;
3416
+ // See the matching streaming-path comment above —
3417
+ // precompute the scalar `absoluteExpiresAtMs`
3418
+ // (`min(record.expiresAt * 1000,
3419
+ // chainEarliestExpiresAtMs)`) ONCE, thread it into
3420
+ // the tracker at `initiatePersist()` time, and capture
3421
+ // ONLY this scalar in the hard-timeout closure.
3422
+ const recordExpiresAtMs =
3423
+ record.expiresAt != null && Number.isFinite(record.expiresAt) ? record.expiresAt * 1000 : undefined;
3424
+ const absoluteExpiresAtMs =
3425
+ recordExpiresAtMs !== undefined && chainEarliestExpiresAtMs !== undefined
3426
+ ? Math.min(recordExpiresAtMs, chainEarliestExpiresAtMs)
3427
+ : (recordExpiresAtMs ?? chainEarliestExpiresAtMs);
3428
+ const nonStreamingHardTimeoutHandle: ReturnType<typeof setTimeout> | null =
3429
+ nonStreamingHardTimeoutMs > 0
3430
+ ? setTimeout(() => {
3431
+ if (persistRetainReleased) return;
3432
+ console.error(
3433
+ `[responses] post-commit persist HARD timeout (${nonStreamingHardTimeoutMs}ms, ` +
3434
+ `${nonStreamingPersistMode}): underlying store.store(...) has not settled; ` +
3435
+ `assuming wedged backend, force-releasing the binding retain so the binding can ` +
3436
+ `be torn down. Retiring the current instance id via tombstone so a same-object ` +
3437
+ `re-registration inherits it and a late-landing persist remains chainable; a ` +
3438
+ `hot-swap to a DIFFERENT model object will mint a fresh id and the stale chain ` +
3439
+ `will correctly fail with 400 instance-mismatch.`,
3440
+ );
3441
+ // Move the pending-write tracker entry into
3442
+ // the hard-timed-out marker state for this
3443
+ // response id. The pending entry is dropped
3444
+ // so a wedged store.store(...) does not pin
3445
+ // one promise closure + tracker entry per
3446
+ // hard-timed-out request, AND the id is added
3447
+ // to the `hardTimedOut` marker so a concurrent
3448
+ // `previous_response_id` continuation can
3449
+ // tell the difference between a permanent
3450
+ // 404 and a slow-but-eventual persist that
3451
+ // crossed the hard timeout. The continuation
3452
+ // path consults `isHardTimedOut(id)` before
3453
+ // falling through to `sendNotFound(...)` and
3454
+ // returns retryable 503 `storage_timeout`
3455
+ // instead, so clients keep retrying rather
3456
+ // than discarding the chain. The marker has
3457
+ // two cleanup paths: (1) fast — the
3458
+ // underlying store promise's `.finally(...)`
3459
+ // inside `track()` fires when the wedged
3460
+ // store unwedges; (2) slow — an independent
3461
+ // TTL (`MLX_HARD_TIMEOUT_MARKER_TTL_MS`,
3462
+ // default 300s) bounds memory at
3463
+ // O(requestRate × TTL) even against a truly
3464
+ // wedged store that NEVER settles. Marker
3465
+ // lifetime = min(settlement, TTL expiry).
3466
+ //
3467
+ // Pass the record's absolute row expiry as a
3468
+ // hard cap on the marker. The record's
3469
+ // `expiresAt` field is epoch-seconds (see
3470
+ // `buildResponseRecord` — it adds
3471
+ // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now()
3472
+ // / 1000)`), so convert to ms for the marker
3473
+ // map. Once the absolute bound passes,
3474
+ // `ResponseStore.getChain()` hides the row and
3475
+ // the retryable-503 classification is factually
3476
+ // wrong — the marker must flip to 404 regardless
3477
+ // of ongoing client retries.
3478
+ //
3479
+ // Capture ONLY the precomputed scalar
3480
+ // `absoluteExpiresAtMs` in this closure — see
3481
+ // the matching streaming-path comment for the
3482
+ // full rationale. The scalar was computed
3483
+ // above when the hard-timeout handle was
3484
+ // armed.
3485
+ getPendingWritesFor(store).markHardTimedOut(
3486
+ record.id,
3487
+ getHardTimedOutMarkerTtlMs(),
3488
+ absoluteExpiresAtMs ?? Number.POSITIVE_INFINITY,
3489
+ );
3490
+ // Retire the id FIRST (binding is still alive
3491
+ // here — retirement reads the live id) then
3492
+ // drop the retain, which may trigger the
3493
+ // deferred teardown. Capture the retired id so
3494
+ // the persist's `.finally(...)` can release
3495
+ // the tombstone once the late write eventually
3496
+ // settles. The registry stores one refcounted
3497
+ // tombstone per model regardless of how many
3498
+ // hard-timeouts overlap — each retire
3499
+ // increments the shared counter and each
3500
+ // release decrements it — so the returned
3501
+ // `{ instanceId }` is captured as a presence
3502
+ // flag and `releaseTombstone(leaseModel)` is
3503
+ // called in the persist's `.finally(...)`.
3504
+ retiredTombstone = registry.retireInstanceIdForForceRelease(leaseModel);
3505
+ persistRetainBox.release?.();
3506
+ }, nonStreamingHardTimeoutMs)
3507
+ : null;
3508
+ pendingPersistOuter = initiatePersist(store, record, absoluteExpiresAtMs).finally(() => {
3509
+ if (nonStreamingHardTimeoutHandle !== null) {
3510
+ clearTimeout(nonStreamingHardTimeoutHandle);
3511
+ }
3512
+ // If the hard-timeout breaker fired and installed a
3513
+ // tombstone on `leaseModel`, decrement the shared
3514
+ // refcount now that this persist has settled. The
3515
+ // single-entry refcount layout means overlapping
3516
+ // breakers share one slot — releasing one balances
3517
+ // one retire, and the entry survives until the
3518
+ // last outstanding persist releases.
3519
+ if (retiredTombstone !== undefined) {
3520
+ registry.releaseTombstone(leaseModel);
3521
+ }
3522
+ persistRetainBox.release?.();
3523
+ });
3524
+ persistMode = nonStreamingPersistMode;
3525
+ }
3526
+ } catch (err) {
3527
+ handlerError = err instanceof Error ? err : new Error(String(err));
3528
+ }
3529
+ committed = outcome.committed;
3530
+ }
3531
+
3532
+ // "Safe to suppress" collapses to: did the client observe a
3533
+ // terminal artefact for this responseId? On the non-
3534
+ // streaming path that is the JSON body landing cleanly on
3535
+ // the wire; on the streaming path it is a terminal SSE
3536
+ // event (`response.completed` or `response.failed`) landing
3537
+ // cleanly on the wire. In either case the client can see
3538
+ // the responseId and knows the turn is over, so adopting
3539
+ // the committed session under that id is safe and
3540
+ // swallowing the (already-surfaced-via-failed-event)
3541
+ // handler error is the only option that does not produce a
3542
+ // malformed double-response.
3543
+ const safeToSuppress = visibility.responseBodyWritten || visibility.terminalEmitted;
3544
+
3545
+ if (previousResponseId) {
3546
+ sessionReg.drop(previousResponseId);
3547
+ }
3548
+ // Only adopt if the turn committed AND either the handler
3549
+ // succeeded or a terminal artefact is already on the wire.
3550
+ // A committed turn whose handler threw before the client
3551
+ // saw anything it can chain off of must NOT be adopted —
3552
+ // the responseId is unreachable from the client, so caching
3553
+ // the session under it creates a permanently dangling warm
3554
+ // session.
3555
+ //
3556
+ // Refuse to adopt whenever the streaming handler took ANY
3557
+ // failure epilogue, not just `client_abort`. The streaming
3558
+ // handler writes `failureMode` for every path that does
3559
+ // not produce a clean `response.completed`:
3560
+ //
3561
+ // * `'client_abort'` — client dropped the socket after
3562
+ // the decode loop committed but before the success
3563
+ // terminal was flushed; `response.failed` goes on the
3564
+ // wire under a responseId the client has abandoned.
3565
+ //
3566
+ // * `'error'` — post-final teardown threw in
3567
+ // the stream adapter's `finally` after the decode
3568
+ // loop had already committed; `terminalToPersist` is
3569
+ // null and the client saw `response.failed`, so the
3570
+ // responseId is not a chainable artefact from the
3571
+ // client's perspective.
3572
+ //
3573
+ // * `'finish_reason_error'` / `'stream_exhausted'` —
3574
+ // terminal derived from a non-clean end of stream.
3575
+ // Same reasoning: `response.failed` on the wire, no
3576
+ // chainable success terminal.
3577
+ //
3578
+ // In every non-null `failureMode` case the session
3579
+ // committed at the native level but the observable wire
3580
+ // state is a failure, so adopting the session under the
3581
+ // responseId would evict the last good hot session for
3582
+ // this model under the single-warm invariant even
3583
+ // though the adopted slot is unreachable.
3584
+ //
3585
+ // `failureMode === null` is the sole signal that the
3586
+ // stream path completed cleanly and the adopted session
3587
+ // is genuinely reachable via the responseId.
3588
+ if (committed && (handlerError == null || safeToSuppress) && streamFailureMode === null) {
3589
+ // Adopt under the SAME `effectivePromptCacheKey` that was
3590
+ // used for `getOrCreate` above — not the raw
3591
+ // `promptCacheKey` from the request body. When a request
3592
+ // carries `previous_response_id` (tier-1 path), tier-2 is
3593
+ // deliberately disabled on the lookup side by forcing
3594
+ // `effectivePromptCacheKey = null`; the adopt side must
3595
+ // follow the same rule or a mixed-mode request
3596
+ // (`previous_response_id=rA + prompt_cache_key=K`) would
3597
+ // store the adopted session under `K` even though the
3598
+ // lookup was resolved via `rA`. A subsequent keyless/
3599
+ // prev-idless request with `prompt_cache_key=K` would then
3600
+ // tier-2 hit and lease rA's chain session — a cross-chain
3601
+ // corruption the precedence rule was explicitly designed
3602
+ // to prevent. Keep adopt's key aligned with lookup's key.
3603
+ sessionReg.adopt(
3604
+ responseId,
3605
+ session,
3606
+ requestedInstructions,
3607
+ effectivePromptCacheKey,
3608
+ config.cacheSalt ?? null,
3609
+ );
3610
+ sessionRetained = true;
3611
+ }
3612
+
3613
+ // Rethrow handler errors when the client hasn't seen a
3614
+ // terminal yet, regardless of commit state. The outer
3615
+ // catch will send a proper 500 (non-streaming) or a last-
3616
+ // ditch SSE `error` event (streaming, after `beginSSE` but
3617
+ // before any terminal). Without this the request would
3618
+ // hang from the client's perspective.
3619
+ if (handlerError && !safeToSuppress) {
3620
+ throw handlerError;
3621
+ }
3622
+ // If a terminal is on the wire but the handler still
3623
+ // threw: log only. Rethrowing would produce a malformed
3624
+ // double-response; the client already has a terminal event
3625
+ // it can parse.
3626
+ if (handlerError) {
3627
+ console.error('[responses] handler error after terminal response already delivered:', handlerError);
3628
+ }
3629
+ } catch (err) {
3630
+ const message = err instanceof Error ? err.message : 'Unknown error during inference';
3631
+ // Branch on `responseMode` (the wire format the handler
3632
+ // committed to), NOT `res.headersSent`
3633
+ // (which flips synchronously in `writeHead` and lies about
3634
+ // which format the client is consuming). Each branch
3635
+ // produces output that matches the Content-Type the client
3636
+ // already received — or no output at all if the terminal
3637
+ // already landed.
3638
+ if (visibility.responseMode === null) {
3639
+ // Capacity failures are deterministic request errors, raised
3640
+ // before native cache mutation. Keep them out of the generic
3641
+ // 500 path so clients can compact/truncate and retry.
3642
+ if (isContextCapacityError(err)) {
3643
+ sendBadRequest(res, message);
3644
+ } else {
3645
+ sendInternalError(res, message);
3646
+ }
3647
+ } else if (visibility.responseMode === 'json') {
3648
+ // We already wrote `Content-Type: application/json` and
3649
+ // possibly some body bytes; emitting an SSE frame here
3650
+ // would corrupt the response. Best we can do is destroy
3651
+ // the socket so the client sees a truncated JSON
3652
+ // response instead of a malformed document with an
3653
+ // unexpected MIME type. If the body was fully written
3654
+ // (`responseBodyWritten === true`) the outcome gate
3655
+ // above already returned without rethrowing, so reaching
3656
+ // this branch means the JSON never fully landed.
3657
+ try {
3658
+ res.destroy(err instanceof Error ? err : new Error(message));
3659
+ } catch {
3660
+ // Socket may already be gone; nothing more we can do.
3661
+ }
3662
+ } else {
3663
+ // `responseMode === 'sse'`: headers advertise SSE and
3664
+ // some (or all) of the stream already went out. If a
3665
+ // terminal event already landed, emitting another frame
3666
+ // is a no-op from the client's perspective but we still
3667
+ // close the stream cleanly. If no terminal landed (early
3668
+ // `writeSSEEvent` crash before `response.created`), emit
3669
+ // a best-effort streaming `error` frame so the client
3670
+ // sees SOMETHING it can parse.
3671
+ if (!visibility.terminalEmitted) {
3672
+ writeFallbackErrorSSE(res, 'error', { error_type: 'server_error', message });
3673
+ }
3674
+ try {
3675
+ endSSE(res);
3676
+ } catch {
3677
+ // Already closed / destroyed.
3678
+ }
3679
+ }
3680
+ } finally {
3681
+ await disposeUnretainedSession();
3682
+ await sessionReg.flushPendingDisposals();
3683
+ }
3684
+ });
3685
+ };
3686
+ await runInference();
3687
+ } catch (err) {
3688
+ // Admission-control rejection from the per-model queue cap
3689
+ // (`SessionRegistry.withExclusive` threw before chaining into
3690
+ // the FIFO). Emit HTTP 429 so clients back off instead of
3691
+ // silently piling up more waiters. Post-dispatch cleanup below
3692
+ // still runs via the idempotent `finally` — abort listeners
3693
+ // were never fully armed for a never-dispatched request, and
3694
+ // the dispatch lease MUST be released exactly once against the
3695
+ // originally captured `leaseModel`.
3696
+ //
3697
+ // Any other error continues to propagate up to the handler's
3698
+ // outer try/catch so existing failure-epilogue behaviour is
3699
+ // preserved untouched.
3700
+ if (err instanceof QueueFullError) {
3701
+ if (!res.headersSent) {
3702
+ sendRateLimit(res, `${err.message}. Retry after 1s.`);
3703
+ }
3704
+ } else {
3705
+ throw err;
3706
+ }
3707
+ }
3708
+
3709
+ // RELEASE the dispatch lease and DETACH the abort listeners
3710
+ // IMMEDIATELY now that `withExclusive` returned
3711
+ // and the terminal bytes have either been flushed or the outer
3712
+ // catch has emitted its error frame. The post-commit persist
3713
+ // wait that follows must NOT pin the request's lifecycle — a
3714
+ // wedged `store.store(...)` would otherwise leak socket/abort
3715
+ // listeners, keep the binding's `inFlight` counter elevated,
3716
+ // and block teardown after a hot-swap for the lifetime of the
3717
+ // wedged write.
3718
+ //
3719
+ // The binding's `modelInstanceId` still needs to survive until
3720
+ // the post-commit write has actually landed — otherwise a
3721
+ // same-model unregister + re-register sequence during a slow
3722
+ // persist would mint a fresh id, and the row (when it finally
3723
+ // lands) would reference a dead id that the very next
3724
+ // `previous_response_id` continuation would reject. That
3725
+ // lifetime is covered by the ORTHOGONAL `retainBinding` /
3726
+ // `releaseBinding` retention counter paired around
3727
+ // `initiatePersist` below, so the eager dispatch-lease release
3728
+ // here stays lossless.
3729
+ //
3730
+ // The outer `finally` below re-runs both cleanups idempotently
3731
+ // so an early-return validation failure (before the
3732
+ // `withExclusive` site) still cleans up; `cleanupPerformed` is
3733
+ // the guard.
3734
+ cleanupPerformed = runPostDispatchCleanup();
3735
+
3736
+ // The persist write was INITIATED synchronously inside
3737
+ // `withExclusive` via `initiatePersist` — which registers the
3738
+ // in-flight promise in the per-store pending-write tracker
3739
+ // BEFORE the mutex releases. The SQLite flush is already on
3740
+ // its way; a back-to-back continuation observing the tracker
3741
+ // will block on the same promise instead of spuriously
3742
+ // returning 404 under `getChain` (see the `getChain`-empty
3743
+ // retry at the top of this handler).
3744
+ //
3745
+ // BOUND the wait on the persist promise with
3746
+ // `POST_COMMIT_PERSIST_TIMEOUT_MS`. A wedged native backend
3747
+ // can return a promise that never settles, and an
3748
+ // unconditional `await` would pin this handler forever —
3749
+ // leaking abort listeners and the dispatch lease (handled
3750
+ // above by running cleanup before this wait). On timeout we
3751
+ // leave the promise running in the background: the
3752
+ // pending-writes tracker still holds its reference so chained
3753
+ // continuations can still observe it, and its `.finally(...)`
3754
+ // handler will clear the tracker entry whenever the write
3755
+ // eventually settles (or stays wedged until the process exits).
3756
+ //
3757
+ // Persistence is best-effort — a failed write demotes to a
3758
+ // log line. The pending-write tracker's `.finally(...)`
3759
+ // handler removes the entry regardless of fulfill / reject,
3760
+ // so a rejected write correctly leaves the store empty AND
3761
+ // clears the tracker, and a subsequent `getChain()` then
3762
+ // returns empty legitimately. A `.catch(...)` is attached
3763
+ // synchronously so an eventual rejection from the
3764
+ // backgrounded promise does not trigger an
3765
+ // unhandled-rejection diagnostic after this handler returns.
3766
+ if (pendingPersistOuter != null) {
3767
+ // The local narrowed reference convinces the type-aware
3768
+ // lint that we're awaiting a real Promise; assigning
3769
+ // through `let` loses that narrowing because the closure
3770
+ // above could (in principle) reassign it.
3771
+ const promise: Promise<void> = pendingPersistOuter;
3772
+ // Attach terminal error handling FIRST. The tracker's own
3773
+ // `.finally(...)` is already attached and surfaces nothing
3774
+ // to Node's unhandled-rejection detector; this catch arm
3775
+ // logs the rejection and suppresses it locally so the
3776
+ // raced-against `Promise.race` sees a plain fulfillment
3777
+ // (`'settled' | 'timeout'`) rather than a rejection that
3778
+ // would otherwise require per-branch handling below.
3779
+ const capturedMode = persistMode;
3780
+ const settled: Promise<'settled'> = promise
3781
+ .then(() => 'settled' as const)
3782
+ .catch((err: unknown) => {
3783
+ console.error(`[responses] post-commit persistence failed (${capturedMode ?? 'unknown'}, off-lock):`, err);
3784
+ return 'settled' as const;
3785
+ });
3786
+ const postCommitPersistTimeoutMs = getPostCommitPersistTimeoutMs();
3787
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
3788
+ const timeoutPromise: Promise<'timeout'> = new Promise<'timeout'>((resolve) => {
3789
+ timeoutHandle = setTimeout(() => {
3790
+ resolve('timeout');
3791
+ }, postCommitPersistTimeoutMs);
3792
+ });
3793
+ try {
3794
+ const outcome = await Promise.race([settled, timeoutPromise]);
3795
+ if (outcome === 'timeout') {
3796
+ console.warn(
3797
+ `[responses] post-commit persistence did not settle within ${postCommitPersistTimeoutMs}ms ` +
3798
+ `(${capturedMode ?? 'unknown'}, off-lock); detaching the handler and leaving the write in the ` +
3799
+ `background. The pending-writes tracker still holds a reference so chained continuations can ` +
3800
+ `observe the in-flight write, and the binding retain stays live until the write truly ` +
3801
+ `settles so the binding's modelInstanceId cannot be recycled under the late write. This ` +
3802
+ `condition usually signals a wedged SQLite writer or stuck native backend.`,
3803
+ );
3804
+ // Do NOT force-release the `retainBinding` here on the
3805
+ // soft timeout. `Promise.race` treats any write that
3806
+ // EXCEEDS the timeout as "safe to unpin", but most
3807
+ // timeouts in practice are slow-but-eventual writes —
3808
+ // the promise still fulfils later, and the retain
3809
+ // invariant has to hold for the entire interval until
3810
+ // it does. If a same-object unregister + re-register
3811
+ // happens in the window between timeout and actual
3812
+ // settlement, force-releasing the retain lets
3813
+ // `pendingPersists` drop to 0, the binding fully tears
3814
+ // down, the re-register mints a fresh
3815
+ // `modelInstanceId`, and the late write lands with the
3816
+ // stale id that `buildResponseRecord` stamped into
3817
+ // `configJson` — exactly the chain-break the retain
3818
+ // was introduced to prevent.
3819
+ //
3820
+ // We accept the bounded cost of a TRULY wedged persist
3821
+ // leaking one binding (counters + registry reference)
3822
+ // until process exit. A wedged SQLite writer already
3823
+ // means the server is compromised, and one lingering
3824
+ // binding is much smaller than a user-visible 400
3825
+ // instance-mismatch on the next continuation. The
3826
+ // idempotent `release` stays wired from the persist's
3827
+ // own `.finally(...)`, so the moment the slow write
3828
+ // actually settles — even minutes later — the retain
3829
+ // drops and teardown proceeds normally. The
3830
+ // independent hard-timeout breaker (armed at
3831
+ // `initiatePersist` time) bounds the truly-wedged case
3832
+ // via tombstoned id retirement.
3833
+ //
3834
+ // The pending-writes tracker keeps its own reference
3835
+ // to the detached promise, so chained continuations
3836
+ // can still observe the in-flight write via the
3837
+ // cold-replay path.
3838
+ }
3839
+ } finally {
3840
+ if (timeoutHandle !== undefined) {
3841
+ clearTimeout(timeoutHandle);
3842
+ }
3843
+ }
3844
+ }
3845
+ } finally {
3846
+ // Balance the pre-dispatch admission on EVERY exit that never
3847
+ // handed the permit to `withExclusive`: getChain storage errors,
3848
+ // the binding-changed 400s, disconnects, and any validation
3849
+ // early-return inside the outer `try`. Idempotent and a no-op
3850
+ // after handoff, so the unconditional call is always safe.
3851
+ preDispatchAdmission?.release();
3852
+ modelLoadAdmission?.release();
3853
+ // Idempotent fallback: if the post-dispatch cleanup above
3854
+ // never ran (early-return validation failure, or an exception
3855
+ // raised inside the outer `try` block between lease
3856
+ // acquisition and the `withExclusive` call), make sure the
3857
+ // abort listeners + idle-sweeper listeners are detached, the
3858
+ // in-flight counter is decremented, and the dispatch lease is
3859
+ // released here. `runPostDispatchCleanup` is safe to re-invoke
3860
+ // — the `abortListenersAttached` / `idleListenersAttached` /
3861
+ // `leaseReleased` flags make every sub-step idempotent on a
3862
+ // second pass. `finalizeIdleRequest` inside the helper is also
3863
+ // guarded by `idleRequestEnded`, so the decrement fires exactly
3864
+ // once regardless of which path wins the race between the
3865
+ // eagerly-attached terminal listener firing, the happy-path
3866
+ // cleanup on `withExclusive` return, and this outer fallback.
3867
+ if (!cleanupPerformed) {
3868
+ runPostDispatchCleanup();
3869
+ }
3870
+ }
3871
+
3872
+ function runPostDispatchCleanup(): true {
3873
+ // Drop the AbortController's socket/request listeners so they
3874
+ // do not keep the request object alive past
3875
+ // the handler's return. Only detach when listeners were actually
3876
+ // installed — early-return validation failures exit the outer
3877
+ // try before the installation site, so an unconditional detach
3878
+ // would pull listeners that were never attached.
3879
+ if (abortListenersAttached) {
3880
+ res.removeListener('close', onAbortClose);
3881
+ res.removeListener('error', onAbortError);
3882
+ if (abortSocket != null) {
3883
+ abortSocket.removeListener('close', onAbortClose);
3884
+ }
3885
+ if (httpReq) {
3886
+ httpReq.removeListener('close', onAbortClose);
3887
+ httpReq.removeListener('error', onAbortError);
3888
+ }
3889
+ abortListenersAttached = false;
3890
+ }
3891
+ // Drop the idle-sweeper's finalize listeners AND decrement the
3892
+ // in-flight counter. Post-dispatch cleanup runs AFTER
3893
+ // `handleStreamingNative` / `handleNonStreaming` have awaited
3894
+ // their terminal `res.end()` — the native dispatch is done at
3895
+ // this point, so the sweeper can safely arm a new pending
3896
+ // drain. Firing here (rather than only in the outer `finally`)
3897
+ // means the post-commit persist wait does not keep `inFlight`
3898
+ // pinned above zero on a wedged store. `finalizeIdleRequest`
3899
+ // is idempotent via the `done` flag so a subsequent fire from
3900
+ // the outer finally / a stray listener is a no-op.
3901
+ if (idleListenersAttached) {
3902
+ res.removeListener('finish', onFinalizeEvent);
3903
+ res.removeListener('close', onFinalizeEvent);
3904
+ res.removeListener('error', onFinalizeEvent);
3905
+ idleListenersAttached = false;
3906
+ }
3907
+ finalizeIdleRequest();
3908
+ // Release the dispatch lease on the ORIGINAL model object the
3909
+ // lease was acquired against (not a re-read of `body.model`,
3910
+ // which may have been hot-swapped while we held the mutex). A
3911
+ // pending teardown — `unregister()` called concurrently while
3912
+ // this dispatch held the lease — finalises here once the
3913
+ // in-flight counter drops to zero AND the post-commit persist
3914
+ // retention has also released (see `retainBinding` below).
3915
+ //
3916
+ // This runs BEFORE the post-commit persist wait, not after, so
3917
+ // a wedged `store.store(...)` no longer pins the lease.
3918
+ // Teardown of a same-model unregister is still deferred by the
3919
+ // `retainBinding` counter so the binding's `modelInstanceId`
3920
+ // survives until the pending write has stamped its row
3921
+ // durably — see `initiatePersist`.
3922
+ if (!leaseReleased) {
3923
+ leaseReleased = true;
3924
+ registry.releaseDispatchLease(leaseModel);
3925
+ }
3926
+ return true;
3927
+ }
3928
+ }