@mlx-node/server 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/endpoints/messages.d.ts +13 -0
  2. package/dist/endpoints/messages.d.ts.map +1 -0
  3. package/dist/endpoints/messages.js +511 -0
  4. package/dist/endpoints/models.d.ts +5 -0
  5. package/dist/endpoints/models.d.ts.map +1 -0
  6. package/dist/endpoints/models.js +10 -0
  7. package/dist/endpoints/responses.d.ts +79 -0
  8. package/dist/endpoints/responses.d.ts.map +1 -0
  9. package/dist/endpoints/responses.js +2816 -0
  10. package/dist/errors.d.ts +43 -0
  11. package/dist/errors.d.ts.map +1 -0
  12. package/dist/errors.js +84 -0
  13. package/dist/handler.d.ts +18 -0
  14. package/dist/handler.d.ts.map +1 -0
  15. package/dist/handler.js +35 -0
  16. package/dist/index.d.ts +23 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +16 -0
  19. package/dist/mappers/anthropic-request.d.ts +9 -0
  20. package/dist/mappers/anthropic-request.d.ts.map +1 -0
  21. package/dist/mappers/anthropic-request.js +241 -0
  22. package/dist/mappers/anthropic-response.d.ts +14 -0
  23. package/dist/mappers/anthropic-response.d.ts.map +1 -0
  24. package/dist/mappers/anthropic-response.js +112 -0
  25. package/dist/mappers/request.d.ts +18 -0
  26. package/dist/mappers/request.d.ts.map +1 -0
  27. package/dist/mappers/request.js +206 -0
  28. package/dist/mappers/response.d.ts +13 -0
  29. package/dist/mappers/response.d.ts.map +1 -0
  30. package/dist/mappers/response.js +116 -0
  31. package/dist/pending-writes.d.ts +337 -0
  32. package/dist/pending-writes.d.ts.map +1 -0
  33. package/dist/pending-writes.js +468 -0
  34. package/dist/registry.d.ts +363 -0
  35. package/dist/registry.d.ts.map +1 -0
  36. package/dist/registry.js +497 -0
  37. package/dist/router.d.ts +6 -0
  38. package/dist/router.d.ts.map +1 -0
  39. package/dist/router.js +78 -0
  40. package/dist/server.d.ts +80 -0
  41. package/dist/server.d.ts.map +1 -0
  42. package/dist/server.js +158 -0
  43. package/dist/session-registry.d.ts +297 -0
  44. package/dist/session-registry.d.ts.map +1 -0
  45. package/dist/session-registry.js +403 -0
  46. package/dist/streaming.d.ts +7 -0
  47. package/dist/streaming.d.ts.map +1 -0
  48. package/dist/streaming.js +16 -0
  49. package/dist/tool-call-buffer.d.ts +26 -0
  50. package/dist/tool-call-buffer.d.ts.map +1 -0
  51. package/dist/tool-call-buffer.js +51 -0
  52. package/dist/transport-visibility.d.ts +56 -0
  53. package/dist/transport-visibility.d.ts.map +1 -0
  54. package/dist/transport-visibility.js +161 -0
  55. package/dist/types-anthropic.d.ts +144 -0
  56. package/dist/types-anthropic.d.ts.map +1 -0
  57. package/dist/types-anthropic.js +2 -0
  58. package/dist/types.d.ts +220 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +2 -0
  61. package/package.json +36 -0
@@ -0,0 +1,2816 @@
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
+ import { randomUUID } from 'node:crypto';
11
+ import { sendBadRequest, sendInternalError, sendNotFound, sendRateLimit, sendStorageTimeout } from '../errors.js';
12
+ import { mapRequest, reconstructMessagesFromChain } from '../mappers/request.js';
13
+ import { buildPartialResponse, buildResponseObject, computeOutputText, genId, mapFinishReasonToStatus, } from '../mappers/response.js';
14
+ import { getPendingWritesFor } from '../pending-writes.js';
15
+ import { QueueFullError } from '../session-registry.js';
16
+ import { beginSSE, endSSE, writeSSEEvent } from '../streaming.js';
17
+ import { ToolCallTagBuffer } from '../tool-call-buffer.js';
18
+ import { createVisibility, endJson, flushTerminalSSE, markSSEMode, writeFallbackErrorSSE, } from '../transport-visibility.js';
19
+ /**
20
+ * Fallback retention for stored response rows when no explicit
21
+ * `responseRetentionSec` is threaded in. Production wires retention via
22
+ * `ServerConfig.responseRetentionSec` (default 7 days, see `server.ts`);
23
+ * this 30-minute fallback is only used by legacy direct-invocation callers.
24
+ */
25
+ const RESPONSE_TTL_SECONDS = 1800;
26
+ /**
27
+ * Upper bound (ms) on how long the recovery path waits for an in-flight
28
+ * `store.store(...)` to land. On timeout we re-probe `getChain` once to
29
+ * catch a late-landing write, then surface HTTP 503 (retryable) rather
30
+ * than 404 (permanent). Default 2000ms — short enough to fail fast on a
31
+ * wedged backend, long enough that healthy SQLite writes complete well
32
+ * within it. Override via `MLX_CHAIN_WRITE_WAIT_TIMEOUT_MS`.
33
+ */
34
+ function getChainWriteWaitTimeoutMs() {
35
+ const raw = process.env.MLX_CHAIN_WRITE_WAIT_TIMEOUT_MS;
36
+ if (raw == null || raw === '')
37
+ return 2000;
38
+ const parsed = Number(raw);
39
+ if (!Number.isFinite(parsed) || parsed <= 0)
40
+ return 2000;
41
+ return parsed;
42
+ }
43
+ /**
44
+ * Soft timeout (ms) on how long the outer handler awaits the off-lock
45
+ * `store.store(...)` before detaching and letting the write run in the
46
+ * background. The pending-writes tracker still holds a reference so
47
+ * chained continuations can observe it. Default 5000ms (larger than the
48
+ * chain-write wait because this bound is not client-facing — the client
49
+ * already has its terminal response). Override via
50
+ * `MLX_POST_COMMIT_PERSIST_TIMEOUT_MS`.
51
+ */
52
+ function getPostCommitPersistTimeoutMs() {
53
+ const raw = process.env.MLX_POST_COMMIT_PERSIST_TIMEOUT_MS;
54
+ if (raw == null || raw === '')
55
+ return 5000;
56
+ const parsed = Number(raw);
57
+ if (!Number.isFinite(parsed) || parsed <= 0)
58
+ return 5000;
59
+ return parsed;
60
+ }
61
+ /**
62
+ * Hard timeout (ms) for the off-lock post-commit persist — the
63
+ * second-stage breaker that force-releases the `retainBinding` paired
64
+ * with `initiatePersist` when the write is truly wedged (never settles).
65
+ *
66
+ * The soft persist timeout above only detaches the handler; the retain
67
+ * stays pinned so a slow-but-eventual write still lands against the
68
+ * live `modelInstanceId`. This hard breaker bounds the leak for a
69
+ * genuinely wedged promise at this value instead of process lifetime.
70
+ * On fire, it also retires the instance id via a refcounted tombstone
71
+ * so a same-object re-registration inherits the id and the late write
72
+ * remains chainable — a true hot-swap to a different object still
73
+ * mints a fresh id and correctly fails stale chains with 400.
74
+ *
75
+ * Default 60000ms — well past the soft timeout so slow-but-eventual
76
+ * writes are unaffected. Override via `MLX_POST_COMMIT_PERSIST_HARD_TIMEOUT_MS`:
77
+ * empty/whitespace-only falls back to default (so a config-templating
78
+ * typo cannot silently disable the breaker); `'0'` explicitly disables;
79
+ * non-numeric garbage falls back to default. Exported for unit tests.
80
+ */
81
+ export function getPostCommitPersistHardTimeoutMs() {
82
+ const raw = process.env.MLX_POST_COMMIT_PERSIST_HARD_TIMEOUT_MS;
83
+ const normalized = raw?.trim();
84
+ if (normalized == null || normalized === '')
85
+ return 60_000;
86
+ const parsed = Number(normalized);
87
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 60_000;
88
+ }
89
+ /**
90
+ * TTL (ms) for hard-timed-out markers in the per-store pending-writes
91
+ * tracker. See `pending-writes.ts` for the full lifetime model.
92
+ *
93
+ * An independent TTL with lazy expiry on read bounds marker memory at
94
+ * O(requestRate × TTL) even when the underlying wedged writes never
95
+ * settle (and their `.finally(...)` cleanup therefore never fires).
96
+ * Default 300000ms (5 min) — past this, the best-effort persist
97
+ * contract has long since failed and permanent 404 is the correct
98
+ * eventual outcome. Override via `MLX_HARD_TIMEOUT_MARKER_TTL_MS`
99
+ * (same parse semantics as the hard-timeout env var above). Exported
100
+ * for unit tests.
101
+ */
102
+ export function getHardTimedOutMarkerTtlMs() {
103
+ const raw = process.env.MLX_HARD_TIMEOUT_MARKER_TTL_MS;
104
+ const normalized = raw?.trim();
105
+ if (normalized == null || normalized === '')
106
+ return 300_000;
107
+ const parsed = Number(normalized);
108
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 300_000;
109
+ }
110
+ /**
111
+ * Per-process boot id stamped into every stored response row's
112
+ * `configJson` alongside `modelInstanceId`. The pair enables
113
+ * restart-safe chain continuation while preserving the in-process
114
+ * hot-swap guard:
115
+ *
116
+ * - stored `serverBootId` == live boot id AND `modelInstanceId`
117
+ * matches live → strict hit (in-process hot-swap protection).
118
+ * - stored `serverBootId` != live boot id (or missing, i.e. rows
119
+ * written before this field existed) → cross-restart. The
120
+ * stored `modelInstanceId` belongs to a dead process and is
121
+ * meaningless, so the instance-id check is skipped and the
122
+ * continuation falls back to name-based resume through whatever
123
+ * model is currently bound to the requested name.
124
+ * - stored `configJson` malformed → reject.
125
+ * - stored row has neither `serverBootId` NOR `modelInstanceId`
126
+ * (truly legacy, pre-instance-id) → reject.
127
+ *
128
+ * `getServerBootId()` resolves lazily on every call so tests can
129
+ * install a deterministic boot id via `__setServerBootIdForTesting`
130
+ * before exercising either the persistence or validation path.
131
+ */
132
+ let serverBootId = randomUUID();
133
+ export function getServerBootId() {
134
+ return serverBootId;
135
+ }
136
+ export function __setServerBootIdForTesting(id) {
137
+ serverBootId = id;
138
+ }
139
+ async function handleNonStreaming(res, result, req, responseId, previousResponseId, visibility) {
140
+ const response = buildResponseObject(result, req, responseId, previousResponseId);
141
+ // `chatSession*` has no AbortSignal surface yet, so a mid-decode
142
+ // client disconnect still burns the full decode budget — peer loss
143
+ // is only observable when native decode resolves. Disconnect
144
+ // detection is delegated to `endJson`'s `isSocketGone(res)` check:
145
+ // on a dead peer it rejects AFTER committing `responseMode = 'json'`
146
+ // so the outer catch routes to the JSON error / socket-destroy
147
+ // shape; `responseBodyWritten` flips only from `res.end`'s write
148
+ // callback (proving the kernel accepted the chunk) so the adopt
149
+ // gate refuses to cache the session under an unreachable responseId.
150
+ await endJson(res, JSON.stringify(response), visibility);
151
+ return { response };
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // Streaming path
155
+ // ---------------------------------------------------------------------------
156
+ /**
157
+ * Build a failure terminal `ResponseObject`: `status: 'failed'`,
158
+ * `incomplete_details: { reason }`, and every nested message /
159
+ * function_call item with `status` `in_progress` or `completed`
160
+ * normalized to `incomplete` so a client inspecting `response.output`
161
+ * on a failed envelope cannot see success-shaped items inside it.
162
+ * `ReasoningOutputItem` has no `status` field and is left alone.
163
+ */
164
+ function buildFailedTerminal(partial, outputItems, reason, usage) {
165
+ const normalized = outputItems.map((item) => {
166
+ if (item.type === 'message') {
167
+ const prev = item.status;
168
+ if (prev === 'in_progress' || prev === 'completed') {
169
+ return { ...item, status: 'incomplete' };
170
+ }
171
+ return item;
172
+ }
173
+ if (item.type === 'function_call') {
174
+ if (item.status === 'completed' || item.status === 'incomplete') {
175
+ return { ...item, status: 'incomplete' };
176
+ }
177
+ return item;
178
+ }
179
+ return item;
180
+ });
181
+ return {
182
+ ...partial,
183
+ status: 'failed',
184
+ output: normalized,
185
+ output_text: computeOutputText(normalized),
186
+ incomplete_details: { reason },
187
+ usage,
188
+ };
189
+ }
190
+ async function handleStreamingNative(res, chatStream, req, responseId, previousResponseId, wasCommitted, httpReq, visibility) {
191
+ beginSSE(res);
192
+ // Commit to SSE wire format synchronously so the outer catch
193
+ // branches on `responseMode` (not `headersSent`) and routes an
194
+ // early `writeSSEEvent` failure to the streaming error epilogue
195
+ // instead of corrupting the JSON path.
196
+ markSSEMode(visibility);
197
+ const partial = buildPartialResponse(req, responseId, previousResponseId);
198
+ writeSSEEvent(res, 'response.created', { response: partial });
199
+ writeSSEEvent(res, 'response.in_progress', { response: partial });
200
+ const outputItems = [];
201
+ let outputIndex = 0;
202
+ // State tracking for streaming
203
+ let reasoningItemId = null;
204
+ let reasoningText = '';
205
+ let messageItemId = null;
206
+ let messageText = '';
207
+ let hasEmittedMessage = false;
208
+ let hasEmittedReasoning = false;
209
+ let suppressedMessageIndex = -1;
210
+ const tagBuffer = new ToolCallTagBuffer();
211
+ // Terminal response is captured in the done branch but emitted AFTER
212
+ // the loop drains — `wasCommitted()` only reads authoritative
213
+ // `session.turns` once the producer's finally has run.
214
+ let completedResponse = null;
215
+ let sawDone = false;
216
+ // Fault state. `thrownError` sticks on a generator throw;
217
+ // `clientAborted` sticks on any `close`/`error` from `httpReq`, `res`,
218
+ // or `res.socket`. Either flips the post-loop block to the failure
219
+ // epilogue. Listening on `res` and `res.socket` matters because
220
+ // non-terminal SSE writes can silently "succeed" on a dead socket.
221
+ let thrownError = null;
222
+ let clientAborted = false;
223
+ const onClientClose = () => {
224
+ clientAborted = true;
225
+ };
226
+ const onClientError = (_err) => {
227
+ clientAborted = true;
228
+ };
229
+ const onResClose = () => {
230
+ clientAborted = true;
231
+ };
232
+ const onResError = (_err) => {
233
+ clientAborted = true;
234
+ };
235
+ const resSocketForAbort = res.socket;
236
+ if (httpReq) {
237
+ httpReq.once('close', onClientClose);
238
+ httpReq.once('error', onClientError);
239
+ }
240
+ res.once('close', onResClose);
241
+ res.once('error', onResError);
242
+ if (resSocketForAbort != null) {
243
+ resSocketForAbort.once('close', onResClose);
244
+ }
245
+ try {
246
+ for await (const event of chatStream) {
247
+ // Honor client disconnect at loop-top. Native decode has no
248
+ // AbortSignal yet; `break` drops the generator reference so
249
+ // the producer's `finally` releases per-model locks and the
250
+ // post-loop block routes to the failure epilogue.
251
+ if (clientAborted)
252
+ break;
253
+ if (event.done) {
254
+ sawDone = true;
255
+ // Final event -- close open items and emit completed
256
+ // Flush any remaining pending text (no tool call tag was found)
257
+ const remainingText = tagBuffer.flush();
258
+ if (!tagBuffer.suppressed && remainingText) {
259
+ if (!hasEmittedMessage) {
260
+ hasEmittedMessage = true;
261
+ messageItemId = genId('msg_');
262
+ const messageItem = {
263
+ id: messageItemId,
264
+ type: 'message',
265
+ role: 'assistant',
266
+ status: 'in_progress',
267
+ content: [],
268
+ };
269
+ const miIndex = outputItems.length;
270
+ outputItems.push(messageItem);
271
+ outputIndex = miIndex;
272
+ writeSSEEvent(res, 'response.output_item.added', { output_index: miIndex, item: messageItem });
273
+ const textPart = { type: 'output_text', text: '', annotations: [] };
274
+ writeSSEEvent(res, 'response.content_part.added', {
275
+ item_id: messageItemId,
276
+ output_index: miIndex,
277
+ content_index: 0,
278
+ part: textPart,
279
+ });
280
+ }
281
+ messageText += remainingText;
282
+ writeSSEEvent(res, 'response.output_text.delta', {
283
+ item_id: messageItemId,
284
+ output_index: outputItems.findIndex((i) => i.id === messageItemId),
285
+ content_index: 0,
286
+ delta: remainingText,
287
+ });
288
+ }
289
+ // Close reasoning item if open
290
+ if (hasEmittedReasoning && reasoningItemId) {
291
+ writeSSEEvent(res, 'response.reasoning_summary_text.done', {
292
+ item_id: reasoningItemId,
293
+ output_index: outputItems.length - (hasEmittedMessage ? 1 : 0) - 1,
294
+ summary_index: 0,
295
+ text: event.thinking ?? reasoningText,
296
+ });
297
+ const reasoningItem = {
298
+ id: reasoningItemId,
299
+ type: 'reasoning',
300
+ summary: [{ type: 'summary_text', text: event.thinking ?? reasoningText }],
301
+ };
302
+ const riIndex = outputItems.findIndex((i) => i.id === reasoningItemId);
303
+ if (riIndex >= 0) {
304
+ outputItems[riIndex] = reasoningItem;
305
+ }
306
+ writeSSEEvent(res, 'response.output_item.done', {
307
+ output_index: riIndex >= 0 ? riIndex : 0,
308
+ item: reasoningItem,
309
+ });
310
+ }
311
+ // Close message item if open.
312
+ // Use the final event's parsed text (markup-stripped) as the authoritative content.
313
+ // If the parsed text is empty and there are tool calls, skip the message item entirely
314
+ // (matching the non-streaming buildOutputItems behavior).
315
+ const finalText = event.text;
316
+ const hasToolCalls = event.toolCalls.some((t) => t.status === 'ok');
317
+ const skipMessageItem = !finalText && hasToolCalls;
318
+ // Recovery: if tool-call suppression was triggered but the final event has no
319
+ // parsed tool calls (false alarm — e.g., literal "<tool_call>" in model output),
320
+ // create a message item using the final parsed text.
321
+ if (tagBuffer.suppressed && !hasToolCalls && finalText && !hasEmittedMessage) {
322
+ hasEmittedMessage = true;
323
+ messageItemId = genId('msg_');
324
+ const messageItem = {
325
+ id: messageItemId,
326
+ type: 'message',
327
+ role: 'assistant',
328
+ status: 'in_progress',
329
+ content: [],
330
+ };
331
+ const miIndex = outputItems.length;
332
+ outputItems.push(messageItem);
333
+ outputIndex = miIndex;
334
+ writeSSEEvent(res, 'response.output_item.added', { output_index: miIndex, item: messageItem });
335
+ const textPart = { type: 'output_text', text: '', annotations: [] };
336
+ writeSSEEvent(res, 'response.content_part.added', {
337
+ item_id: messageItemId,
338
+ output_index: miIndex,
339
+ content_index: 0,
340
+ part: textPart,
341
+ });
342
+ messageText = finalText;
343
+ writeSSEEvent(res, 'response.output_text.delta', {
344
+ item_id: messageItemId,
345
+ output_index: miIndex,
346
+ content_index: 0,
347
+ delta: finalText,
348
+ });
349
+ }
350
+ else if (tagBuffer.suppressed && !hasToolCalls && finalText && hasEmittedMessage) {
351
+ // Recovery: text was already being streamed but got cut off by a false-alarm
352
+ // <tool_call> tag. Emit the unsent portion as a delta.
353
+ const unsent = finalText.slice(messageText.length);
354
+ if (unsent) {
355
+ messageText += unsent;
356
+ writeSSEEvent(res, 'response.output_text.delta', {
357
+ item_id: messageItemId,
358
+ output_index: outputItems.findIndex((i) => i.id === messageItemId),
359
+ content_index: 0,
360
+ delta: unsent,
361
+ });
362
+ }
363
+ }
364
+ // Emit any unsent suffix when final text is longer than what was streamed
365
+ if (hasEmittedMessage && finalText && finalText.length > messageText.length && !tagBuffer.suppressed) {
366
+ const unsent = finalText.slice(messageText.length);
367
+ messageText += unsent;
368
+ writeSSEEvent(res, 'response.output_text.delta', {
369
+ item_id: messageItemId,
370
+ output_index: outputItems.findIndex((i) => i.id === messageItemId),
371
+ content_index: 0,
372
+ delta: unsent,
373
+ });
374
+ }
375
+ // Recovery: text was never emitted during streaming but final has text
376
+ // (possible if all text arrived in the final event only)
377
+ if (!hasEmittedMessage && finalText && !skipMessageItem) {
378
+ hasEmittedMessage = true;
379
+ messageItemId = genId('msg_');
380
+ const messageItem = {
381
+ id: messageItemId,
382
+ type: 'message',
383
+ role: 'assistant',
384
+ status: 'in_progress',
385
+ content: [],
386
+ };
387
+ const miIndex = outputItems.length;
388
+ outputItems.push(messageItem);
389
+ outputIndex = miIndex;
390
+ writeSSEEvent(res, 'response.output_item.added', { output_index: miIndex, item: messageItem });
391
+ const textPart = { type: 'output_text', text: '', annotations: [] };
392
+ writeSSEEvent(res, 'response.content_part.added', {
393
+ item_id: messageItemId,
394
+ output_index: miIndex,
395
+ content_index: 0,
396
+ part: textPart,
397
+ });
398
+ messageText = finalText;
399
+ writeSSEEvent(res, 'response.output_text.delta', {
400
+ item_id: messageItemId,
401
+ output_index: miIndex,
402
+ content_index: 0,
403
+ delta: finalText,
404
+ });
405
+ }
406
+ if (hasEmittedMessage && messageItemId && !skipMessageItem) {
407
+ const miIndex = outputItems.findIndex((i) => i.id === messageItemId);
408
+ const contentIndex = 0;
409
+ writeSSEEvent(res, 'response.output_text.done', {
410
+ item_id: messageItemId,
411
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
412
+ content_index: contentIndex,
413
+ text: finalText,
414
+ });
415
+ const textPart = { type: 'output_text', text: finalText, annotations: [] };
416
+ writeSSEEvent(res, 'response.content_part.done', {
417
+ item_id: messageItemId,
418
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
419
+ content_index: contentIndex,
420
+ part: textPart,
421
+ });
422
+ const messageItem = {
423
+ id: messageItemId,
424
+ type: 'message',
425
+ role: 'assistant',
426
+ status: mapFinishReasonToStatus(event.finishReason),
427
+ content: [textPart],
428
+ };
429
+ if (miIndex >= 0) {
430
+ outputItems[miIndex] = messageItem;
431
+ }
432
+ writeSSEEvent(res, 'response.output_item.done', {
433
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
434
+ item: messageItem,
435
+ });
436
+ }
437
+ else if (hasEmittedMessage && messageItemId && skipMessageItem) {
438
+ // A message item was started (output_item.added / content_part.added events already
439
+ // sent to the client) but we now know it should be suppressed because the final
440
+ // text is empty and there are tool calls. Send proper done events to close out
441
+ // the item gracefully so clients do not see a dangling in-progress item, then
442
+ // remove it from outputItems so it does not appear in the completed response.
443
+ const miIndex = outputItems.findIndex((i) => i.id === messageItemId);
444
+ const miOutputIndex = miIndex >= 0 ? miIndex : outputIndex;
445
+ writeSSEEvent(res, 'response.output_text.done', {
446
+ item_id: messageItemId,
447
+ output_index: miOutputIndex,
448
+ content_index: 0,
449
+ text: '',
450
+ });
451
+ const emptyTextPart = { type: 'output_text', text: '', annotations: [] };
452
+ writeSSEEvent(res, 'response.content_part.done', {
453
+ item_id: messageItemId,
454
+ output_index: miOutputIndex,
455
+ content_index: 0,
456
+ part: emptyTextPart,
457
+ });
458
+ const closedMessageItem = {
459
+ id: messageItemId,
460
+ type: 'message',
461
+ role: 'assistant',
462
+ status: 'completed',
463
+ content: [],
464
+ };
465
+ writeSSEEvent(res, 'response.output_item.done', {
466
+ output_index: miOutputIndex,
467
+ item: closedMessageItem,
468
+ });
469
+ // Track suppressed index for exclusion from final response
470
+ // but keep in array so subsequent output_index values remain unique.
471
+ if (miIndex >= 0) {
472
+ suppressedMessageIndex = miIndex;
473
+ }
474
+ }
475
+ // Collect function_call items but defer SSE emission until
476
+ // after the commit gate — otherwise clients can see completed
477
+ // tool calls from a turn the session later refuses to commit.
478
+ for (const tc of event.toolCalls.filter((t) => t.status === 'ok')) {
479
+ const callId = tc.id ?? genId('call_');
480
+ const fcItem = {
481
+ id: genId('fc_'),
482
+ type: 'function_call',
483
+ call_id: callId,
484
+ name: tc.name,
485
+ arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments),
486
+ status: 'completed',
487
+ };
488
+ outputItems.push(fcItem);
489
+ }
490
+ // Build the terminal but do NOT emit `response.completed` yet:
491
+ // commit signal only becomes authoritative after the producer's
492
+ // finally runs. Break so for-await cleanup triggers that finally,
493
+ // then the post-loop block handles emission + persistence.
494
+ const promptTokens = event.promptTokens ?? 0;
495
+ const reasoningTokens = event.reasoningTokens ?? 0;
496
+ const usage = {
497
+ input_tokens: promptTokens,
498
+ output_tokens: event.numTokens,
499
+ output_tokens_details: { reasoning_tokens: reasoningTokens },
500
+ total_tokens: promptTokens + event.numTokens,
501
+ };
502
+ const finalOutput = outputItems.filter((_, idx) => idx !== suppressedMessageIndex);
503
+ completedResponse = {
504
+ ...partial,
505
+ status: mapFinishReasonToStatus(event.finishReason),
506
+ output: finalOutput,
507
+ output_text: computeOutputText(finalOutput),
508
+ incomplete_details: event.finishReason === 'length' ? { reason: 'max_output_tokens' } : null,
509
+ usage,
510
+ };
511
+ break;
512
+ }
513
+ // Delta event
514
+ if (event.isReasoning) {
515
+ // Filter out </think> tag from reasoning deltas
516
+ const deltaText = event.text.replace(/<\/think>/g, '');
517
+ if (!deltaText)
518
+ continue; // Skip empty deltas (e.g., just the </think> token)
519
+ if (!hasEmittedReasoning) {
520
+ // First reasoning chunk -- add reasoning item
521
+ hasEmittedReasoning = true;
522
+ reasoningItemId = genId('rs_');
523
+ const reasoningItem = {
524
+ id: reasoningItemId,
525
+ type: 'reasoning',
526
+ summary: [],
527
+ };
528
+ const riIndex = outputItems.length;
529
+ outputItems.push(reasoningItem);
530
+ writeSSEEvent(res, 'response.output_item.added', { output_index: riIndex, item: reasoningItem });
531
+ }
532
+ reasoningText += deltaText;
533
+ writeSSEEvent(res, 'response.reasoning_summary_text.delta', {
534
+ item_id: reasoningItemId,
535
+ output_index: outputItems.findIndex((i) => i.id === reasoningItemId),
536
+ summary_index: 0,
537
+ delta: deltaText,
538
+ });
539
+ }
540
+ else {
541
+ // Text delta with tool_call tag buffering
542
+ const { safeText, tagFound, cleanPrefix } = tagBuffer.push(event.text);
543
+ if (tagFound) {
544
+ // Emit any clean text before the tag.
545
+ // Trim whitespace-only prefixes: whitespace immediately before <tool_call>
546
+ // is always markup-related (e.g. "\n<tool_call>"), not user-visible content.
547
+ // Emitting it would create a dangling message item that needs special-casing
548
+ // at finalization when skipMessageItem is true.
549
+ if (cleanPrefix.trim()) {
550
+ if (!hasEmittedMessage) {
551
+ hasEmittedMessage = true;
552
+ messageItemId = genId('msg_');
553
+ const messageItem = {
554
+ id: messageItemId,
555
+ type: 'message',
556
+ role: 'assistant',
557
+ status: 'in_progress',
558
+ content: [],
559
+ };
560
+ const miIndex = outputItems.length;
561
+ outputItems.push(messageItem);
562
+ outputIndex = miIndex;
563
+ writeSSEEvent(res, 'response.output_item.added', { output_index: miIndex, item: messageItem });
564
+ const textPart = { type: 'output_text', text: '', annotations: [] };
565
+ writeSSEEvent(res, 'response.content_part.added', {
566
+ item_id: messageItemId,
567
+ output_index: miIndex,
568
+ content_index: 0,
569
+ part: textPart,
570
+ });
571
+ }
572
+ messageText += cleanPrefix;
573
+ writeSSEEvent(res, 'response.output_text.delta', {
574
+ item_id: messageItemId,
575
+ output_index: outputItems.findIndex((i) => i.id === messageItemId),
576
+ content_index: 0,
577
+ delta: cleanPrefix,
578
+ });
579
+ }
580
+ }
581
+ else if (safeText) {
582
+ if (!hasEmittedMessage) {
583
+ hasEmittedMessage = true;
584
+ messageItemId = genId('msg_');
585
+ const messageItem = {
586
+ id: messageItemId,
587
+ type: 'message',
588
+ role: 'assistant',
589
+ status: 'in_progress',
590
+ content: [],
591
+ };
592
+ const miIndex = outputItems.length;
593
+ outputItems.push(messageItem);
594
+ outputIndex = miIndex;
595
+ writeSSEEvent(res, 'response.output_item.added', { output_index: miIndex, item: messageItem });
596
+ const textPart = { type: 'output_text', text: '', annotations: [] };
597
+ writeSSEEvent(res, 'response.content_part.added', {
598
+ item_id: messageItemId,
599
+ output_index: miIndex,
600
+ content_index: 0,
601
+ part: textPart,
602
+ });
603
+ }
604
+ messageText += safeText;
605
+ writeSSEEvent(res, 'response.output_text.delta', {
606
+ item_id: messageItemId,
607
+ output_index: outputItems.findIndex((i) => i.id === messageItemId),
608
+ content_index: 0,
609
+ delta: safeText,
610
+ });
611
+ }
612
+ }
613
+ }
614
+ }
615
+ catch (err) {
616
+ // Capture mid-decode throws so the post-loop block routes to the
617
+ // failure epilogue and emits `response.failed` — otherwise the
618
+ // error would escape into the outer JSON error path with SSE
619
+ // headers already on the wire.
620
+ thrownError = err instanceof Error ? err : new Error(String(err));
621
+ }
622
+ finally {
623
+ if (httpReq) {
624
+ httpReq.off('close', onClientClose);
625
+ httpReq.off('error', onClientError);
626
+ }
627
+ res.off('close', onResClose);
628
+ res.off('error', onResError);
629
+ if (resSocketForAbort != null) {
630
+ resSocketForAbort.off('close', onResClose);
631
+ }
632
+ }
633
+ // Post-loop terminal emission. The producer's finally has run so
634
+ // `wasCommitted()` reads an authoritative baseline. On success emit
635
+ // `response.completed`; otherwise route through the failure epilogue
636
+ // with one of `finish_reason_error` / `error` / `client_abort` /
637
+ // `stream_exhausted`. `response.failed` is emitted even on
638
+ // `client_abort` so a tee/proxy that stays connected sees a terminal.
639
+ const committed = wasCommitted();
640
+ const successful = sawDone && committed && thrownError == null && !clientAborted;
641
+ if (successful) {
642
+ const terminal = completedResponse;
643
+ // Emit deferred function_call events now that the commit gate
644
+ // passed — held until here so clients never see completed tool
645
+ // calls from an uncommitted turn.
646
+ for (const item of terminal.output) {
647
+ if (item.type === 'function_call') {
648
+ const fcIndex = outputItems.indexOf(item);
649
+ writeSSEEvent(res, 'response.output_item.added', { output_index: fcIndex, item });
650
+ const argsStr = item.arguments;
651
+ writeSSEEvent(res, 'response.function_call_arguments.delta', {
652
+ item_id: item.id,
653
+ output_index: fcIndex,
654
+ delta: argsStr,
655
+ });
656
+ writeSSEEvent(res, 'response.function_call_arguments.done', {
657
+ item_id: item.id,
658
+ output_index: fcIndex,
659
+ arguments: argsStr,
660
+ });
661
+ writeSSEEvent(res, 'response.output_item.done', { output_index: fcIndex, item });
662
+ }
663
+ }
664
+ // The terminal SSE flushes inside the per-model mutex (client
665
+ // expects it ordered against prior deltas); the `ResponseStore`
666
+ // write is deferred to the outer handler so a slow SQLite write
667
+ // does not pin the next waiter. `flushTerminalSSE` flips
668
+ // `terminalEmitted` only once the kernel acks the frame — a
669
+ // callback-reported error rejects so the outer catch refuses to
670
+ // adopt under an unseen responseId.
671
+ await flushTerminalSSE(res, 'response.completed', { response: terminal }, visibility);
672
+ endSSE(res);
673
+ return { terminalToPersist: terminal, failureMode: null };
674
+ }
675
+ // Failure epilogue. Close any dangling message items BEFORE the
676
+ // terminal so clients tracking `output_index` see matching closes.
677
+ // Function_call items are never emitted on failure (their SSE is
678
+ // deferred to the success path); reasoning items have no `status`.
679
+ const reason = thrownError
680
+ ? 'error'
681
+ : clientAborted
682
+ ? 'client_abort'
683
+ : sawDone
684
+ ? 'finish_reason_error'
685
+ : 'stream_exhausted';
686
+ // Prefer captured usage on a finish_reason_error path so clients
687
+ // still see what was spent; synthesize zero-usage only when no done
688
+ // event was ever observed.
689
+ const usage = completedResponse?.usage ?? {
690
+ input_tokens: 0,
691
+ output_tokens: 0,
692
+ output_tokens_details: { reasoning_tokens: 0 },
693
+ total_tokens: 0,
694
+ };
695
+ const finalOutput = outputItems.filter((_, idx) => idx !== suppressedMessageIndex);
696
+ // Flush still-open message items before the terminal. Only on the
697
+ // non-sawDone path — the done branch emits its own closes before
698
+ // breaking out.
699
+ if (!sawDone && hasEmittedMessage && messageItemId != null) {
700
+ const miIndex = outputItems.findIndex((i) => i.id === messageItemId);
701
+ writeSSEEvent(res, 'response.output_text.done', {
702
+ item_id: messageItemId,
703
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
704
+ content_index: 0,
705
+ text: messageText,
706
+ });
707
+ const textPart = { type: 'output_text', text: messageText, annotations: [] };
708
+ writeSSEEvent(res, 'response.content_part.done', {
709
+ item_id: messageItemId,
710
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
711
+ content_index: 0,
712
+ part: textPart,
713
+ });
714
+ const closedMessageItem = {
715
+ id: messageItemId,
716
+ type: 'message',
717
+ role: 'assistant',
718
+ status: 'incomplete',
719
+ content: messageText ? [textPart] : [],
720
+ };
721
+ if (miIndex >= 0) {
722
+ outputItems[miIndex] = closedMessageItem;
723
+ finalOutput[miIndex] = closedMessageItem;
724
+ }
725
+ writeSSEEvent(res, 'response.output_item.done', {
726
+ output_index: miIndex >= 0 ? miIndex : outputIndex,
727
+ item: closedMessageItem,
728
+ });
729
+ }
730
+ if (!sawDone && hasEmittedReasoning && reasoningItemId != null) {
731
+ // No `status` field on reasoning items — just emit closes so
732
+ // client-side output_index bookkeeping stays consistent.
733
+ writeSSEEvent(res, 'response.reasoning_summary_text.done', {
734
+ item_id: reasoningItemId,
735
+ output_index: outputItems.findIndex((i) => i.id === reasoningItemId),
736
+ summary_index: 0,
737
+ text: reasoningText,
738
+ });
739
+ const riIndex = outputItems.findIndex((i) => i.id === reasoningItemId);
740
+ if (riIndex >= 0) {
741
+ const reasoningItem = {
742
+ id: reasoningItemId,
743
+ type: 'reasoning',
744
+ summary: [{ type: 'summary_text', text: reasoningText }],
745
+ };
746
+ outputItems[riIndex] = reasoningItem;
747
+ finalOutput[riIndex] = reasoningItem;
748
+ writeSSEEvent(res, 'response.output_item.done', { output_index: riIndex, item: reasoningItem });
749
+ }
750
+ }
751
+ const failedTerminal = buildFailedTerminal(partial, finalOutput, reason, usage);
752
+ await flushTerminalSSE(res, 'response.failed', { response: failedTerminal }, visibility);
753
+ endSSE(res);
754
+ // No terminalToPersist on an uncommitted turn: a later continuation
755
+ // that cold-replayed this record would silently resurrect failed
756
+ // output as authoritative history.
757
+ return { terminalToPersist: null, failureMode: reason };
758
+ }
759
+ // ---------------------------------------------------------------------------
760
+ // Session routing
761
+ // ---------------------------------------------------------------------------
762
+ /**
763
+ * Return the ordered sibling call ids for the trailing assistant
764
+ * fan-out (if any calls remain unresolved), else `null`. MUST be
765
+ * invoked on the STORED prior chain, never on the augmented `messages`
766
+ * list — otherwise an echoed `function_call` could overwrite the
767
+ * trailing assistant with a forged single-call turn.
768
+ */
769
+ function extractOutstandingToolCallIds(messages) {
770
+ let lastAssistantWithCallsIdx = -1;
771
+ for (let i = messages.length - 1; i >= 0; i--) {
772
+ const msg = messages[i];
773
+ if (msg?.role === 'assistant') {
774
+ const tcs = msg.toolCalls ?? [];
775
+ if (tcs.length > 0) {
776
+ lastAssistantWithCallsIdx = i;
777
+ }
778
+ break;
779
+ }
780
+ }
781
+ if (lastAssistantWithCallsIdx === -1) {
782
+ return null;
783
+ }
784
+ const trailingAssistant = messages[lastAssistantWithCallsIdx];
785
+ const orderedIds = [];
786
+ for (const tc of trailingAssistant.toolCalls ?? []) {
787
+ if (typeof tc.id === 'string' && tc.id.length > 0) {
788
+ orderedIds.push(tc.id);
789
+ }
790
+ }
791
+ if (orderedIds.length === 0) {
792
+ return null;
793
+ }
794
+ const outstanding = new Set(orderedIds);
795
+ for (let j = lastAssistantWithCallsIdx + 1; j < messages.length; j++) {
796
+ const m = messages[j];
797
+ if (m?.role === 'tool' && typeof m.toolCallId === 'string' && m.toolCallId.length > 0) {
798
+ outstanding.delete(m.toolCallId);
799
+ }
800
+ }
801
+ if (outstanding.size === 0) {
802
+ return null;
803
+ }
804
+ return orderedIds.filter((id) => outstanding.has(id));
805
+ }
806
+ /**
807
+ * Set of `call_id`s owned by the trailing assistant turn, used to
808
+ * authenticate echoed `function_call` items in a `previous_response_id`
809
+ * continuation. Ownership check only — `name` / `arguments` are not
810
+ * compared against the stored payload (clients commonly reserialize
811
+ * their own arguments with different whitespace). Returns `null` when
812
+ * the trailing message is not an assistant fan-out.
813
+ */
814
+ function buildTrailingAssistantToolCallIds(messages) {
815
+ for (let i = messages.length - 1; i >= 0; i--) {
816
+ const msg = messages[i];
817
+ if (msg?.role === 'assistant') {
818
+ const ids = new Set();
819
+ for (const tc of msg.toolCalls ?? []) {
820
+ if (typeof tc.id === 'string' && tc.id.length > 0) {
821
+ ids.add(tc.id);
822
+ }
823
+ }
824
+ return ids.size > 0 ? ids : null;
825
+ }
826
+ }
827
+ return null;
828
+ }
829
+ /**
830
+ * Reorder tool messages in `messages[startOffset, blockEnd)` to match
831
+ * `expectedOrder`. Replay correctness for a multi-call fan-out depends
832
+ * on POSITION — several native backends drop the id on the wire and
833
+ * pair results to calls by sibling index, so a reordered submission
834
+ * would silently bind results to the wrong calls even after the
835
+ * id-set gate passes.
836
+ *
837
+ * `blockEnd` MUST be sized to a single contiguous tool block; the
838
+ * full-history walker computes one per fan-out. No-op when any
839
+ * precondition fails.
840
+ */
841
+ function canonicalizeToolMessageOrder(messages, startOffset, blockEnd, expectedOrder) {
842
+ const toolPositions = [];
843
+ const byId = new Map();
844
+ for (let i = startOffset; i < blockEnd; i++) {
845
+ const m = messages[i];
846
+ if (m.role === 'tool' && typeof m.toolCallId === 'string' && m.toolCallId.length > 0) {
847
+ toolPositions.push(i);
848
+ byId.set(m.toolCallId, m);
849
+ }
850
+ }
851
+ if (toolPositions.length !== expectedOrder.length)
852
+ return;
853
+ for (const id of expectedOrder) {
854
+ if (!byId.has(id))
855
+ return;
856
+ }
857
+ let alreadyOrdered = true;
858
+ for (let k = 0; k < toolPositions.length; k++) {
859
+ if (messages[toolPositions[k]].toolCallId !== expectedOrder[k]) {
860
+ alreadyOrdered = false;
861
+ break;
862
+ }
863
+ }
864
+ if (alreadyOrdered)
865
+ return;
866
+ for (let k = 0; k < toolPositions.length; k++) {
867
+ messages[toolPositions[k]] = byId.get(expectedOrder[k]);
868
+ }
869
+ }
870
+ /**
871
+ * Walk the full `messages` history, validate each assistant fan-out's
872
+ * tool-result block, and canonicalize each block to sibling order in
873
+ * place. Invoked on stateless cold-start histories and on the
874
+ * Anthropic `/v1/messages` endpoint (both feed caller-supplied tool
875
+ * order straight into `primeHistory()` without the continuation gate).
876
+ *
877
+ * Validation rejects: orphan tool messages, unknown `toolCallId`s,
878
+ * missing/duplicate resolutions, and a trailing unresolved fan-out in
879
+ * a stateless history. Returns `null` on success or a human-readable
880
+ * error string (sent as 400 `invalid_request_error`).
881
+ *
882
+ * @param apiSurface controls error-string vocabulary (`openai` default
883
+ * uses `function_call_output` / `call_id`; `anthropic` uses
884
+ * `tool_result` / `tool_use_id`). Validation logic is identical.
885
+ */
886
+ export function validateAndCanonicalizeHistoryToolOrder(messages, apiSurface = 'openai') {
887
+ const vocab = apiSurface === 'anthropic'
888
+ ? {
889
+ toolResult: 'tool_result',
890
+ toolCallId: 'tool_use_id',
891
+ fanOut: 'assistant turn with tool_use blocks',
892
+ }
893
+ : {
894
+ toolResult: 'function_call_output',
895
+ toolCallId: 'call_id',
896
+ fanOut: 'assistant fan-out',
897
+ };
898
+ let i = 0;
899
+ while (i < messages.length) {
900
+ const m = messages[i];
901
+ if (m.role === 'tool') {
902
+ return (`tool message at index ${i} (${vocab.toolCallId} "${m.toolCallId ?? ''}") is not preceded by an ` +
903
+ `${vocab.fanOut}. Every ${vocab.toolResult} must immediately follow the assistant turn whose ` +
904
+ `tool calls include its ${vocab.toolCallId}.`);
905
+ }
906
+ if (m.role !== 'assistant' || !m.toolCalls || m.toolCalls.length === 0) {
907
+ i++;
908
+ continue;
909
+ }
910
+ // Assistant fan-out. Collect declared sibling ids.
911
+ const declaredIds = [];
912
+ const declaredSet = new Set();
913
+ for (const tc of m.toolCalls) {
914
+ const id = typeof tc.id === 'string' ? tc.id : null;
915
+ if (id === null || id.length === 0) {
916
+ return (`${vocab.fanOut} at index ${i} declares a tool call with no id, which cannot be paired ` +
917
+ `with its ${vocab.toolResult} positionally.`);
918
+ }
919
+ if (declaredSet.has(id)) {
920
+ return (`${vocab.fanOut} at index ${i} declares duplicate ${vocab.toolCallId} "${id}". Each sibling ` +
921
+ `call must have a unique ${vocab.toolCallId}.`);
922
+ }
923
+ declaredIds.push(id);
924
+ declaredSet.add(id);
925
+ }
926
+ // Read the contiguous tool block following the fan-out.
927
+ const blockStart = i + 1;
928
+ let blockEnd = blockStart;
929
+ const seenInBlock = new Set();
930
+ while (blockEnd < messages.length && messages[blockEnd].role === 'tool') {
931
+ const tool = messages[blockEnd];
932
+ const id = typeof tool.toolCallId === 'string' ? tool.toolCallId : null;
933
+ if (id === null || id.length === 0) {
934
+ return (`tool message at index ${blockEnd} is missing ${vocab.toolCallId}. Every ${vocab.toolResult} ` +
935
+ `in an ${vocab.fanOut}'s resolution block must carry the ${vocab.toolCallId} it resolves.`);
936
+ }
937
+ if (!declaredSet.has(id)) {
938
+ return (`tool message at index ${blockEnd} references ${vocab.toolCallId} "${id}", which is not ` +
939
+ `declared by the preceding ${vocab.fanOut} at index ${i}. Submitting a ${vocab.toolResult} ` +
940
+ `for an undeclared ${vocab.toolCallId} would silently bind output to the wrong sibling.`);
941
+ }
942
+ if (seenInBlock.has(id)) {
943
+ return (`duplicate tool message for ${vocab.toolCallId} "${id}" inside the ${vocab.fanOut}'s ` +
944
+ `resolution block (index ${blockEnd}). Each outstanding sibling must be resolved exactly once.`);
945
+ }
946
+ seenInBlock.add(id);
947
+ blockEnd++;
948
+ }
949
+ const blockLength = blockEnd - blockStart;
950
+ if (blockLength === 0) {
951
+ // Trailing unresolved fan-out is rejected — a stateless history
952
+ // has nothing for the model to continue from. Mid-history the
953
+ // next non-tool turn orphans the fan-out.
954
+ if (blockEnd === messages.length) {
955
+ return (`${vocab.fanOut} at index ${i} is the trailing turn of the history but has no ` +
956
+ `${vocab.toolResult} resolutions. A stateless cold-start history cannot end on an ` +
957
+ `unresolved tool-call fan-out because there is nothing for the model to continue from.`);
958
+ }
959
+ return (`${vocab.fanOut} at index ${i} declares ${declaredIds.length} tool call${declaredIds.length === 1 ? '' : 's'} ` +
960
+ `but the next message at index ${blockEnd} is a ${messages[blockEnd].role} turn. Every fan-out ` +
961
+ `must be fully resolved by ${vocab.toolResult} messages before the next assistant/user/system turn.`);
962
+ }
963
+ if (blockLength < declaredIds.length) {
964
+ const missing = declaredIds.filter((id) => !seenInBlock.has(id));
965
+ return (`${vocab.fanOut} at index ${i} has unresolved sibling tool calls: ${missing.join(', ')}. ` +
966
+ `Every declared tool call must be answered by a ${vocab.toolResult} before the next turn.`);
967
+ }
968
+ // blockLength > declaredIds.length is impossible (every id is in
969
+ // declaredSet and seenInBlock dedupes).
970
+ canonicalizeToolMessageOrder(messages, blockStart, blockEnd, declaredIds);
971
+ i = blockEnd;
972
+ }
973
+ return null;
974
+ }
975
+ /**
976
+ * Route a non-streaming request through `ChatSession`. Cold path
977
+ * (fresh session) runs `primeHistory` + `startFromHistory`; hot path
978
+ * uses `send` / `sendToolResult` for a single new message, or falls
979
+ * back to reset + cold re-prime on multi-message input. The caller
980
+ * is responsible for rejecting partial tool-result submissions
981
+ * against a fan-out (`handleCreateResponse` fan-out gate).
982
+ */
983
+ async function runSessionNonStreaming(session, messages, newInputMessages, config) {
984
+ if (session.turns === 0) {
985
+ session.primeHistory(messages);
986
+ const initialTurns = session.turns;
987
+ const result = await session.startFromHistory(config);
988
+ return { result, committed: session.turns > initialTurns };
989
+ }
990
+ // Hot path — session's KV cache is already warmed for this chain.
991
+ // Single-message continuations whose role is `user` or `tool` take
992
+ // the cheap delta paths (`send` / `sendToolResult`). Any other single
993
+ // role (`assistant`, `system`) is still accepted by `mapRequest` —
994
+ // `reconstructMessagesFromChain` + `primeHistory` tolerate a tail of
995
+ // either — but the chat-session delta API has no entry point for
996
+ // them, so fall through to reset + cold re-prime against the fully
997
+ // rebuilt history. Returning 500 here would regress the pre-session-
998
+ // API full-history path, making valid continuation payloads fail
999
+ // nondeterministically based on cache state.
1000
+ if (newInputMessages.length === 1) {
1001
+ const last = newInputMessages[0];
1002
+ if (last.role === 'user') {
1003
+ const initialTurns = session.turns;
1004
+ const images = last.images ?? undefined;
1005
+ const result = await session.send(last.content, images ? { images, config } : { config });
1006
+ return { result, committed: session.turns > initialTurns };
1007
+ }
1008
+ if (last.role === 'tool') {
1009
+ if (!last.toolCallId) {
1010
+ throw new Error('tool message missing toolCallId');
1011
+ }
1012
+ const initialTurns = session.turns;
1013
+ const result = await session.sendToolResult(last.toolCallId, last.content, { config });
1014
+ return { result, committed: session.turns > initialTurns };
1015
+ }
1016
+ // Non-user / non-tool single-message continuation (assistant /
1017
+ // system) falls through to the multi-message reset + cold re-prime
1018
+ // branch below.
1019
+ }
1020
+ // Multi-message (or single non-user/non-tool) hot path: reset + cold
1021
+ // re-prime. `initialTurns` MUST be captured AFTER `session.reset()`
1022
+ // zeroes `turns`, otherwise the committed check reads stale.
1023
+ // Amortized: the caller re-keys this session under the new
1024
+ // responseId on success.
1025
+ await session.reset();
1026
+ session.primeHistory(messages);
1027
+ const initialTurns = session.turns;
1028
+ const result = await session.startFromHistory(config);
1029
+ return { result, committed: session.turns > initialTurns };
1030
+ }
1031
+ /** Streaming counterpart to {@link runSessionNonStreaming}. */
1032
+ async function runSessionStreaming(session, messages, newInputMessages, config, signal) {
1033
+ if (session.turns === 0) {
1034
+ session.primeHistory(messages);
1035
+ const initialTurns = session.turns;
1036
+ return {
1037
+ stream: session.startFromHistoryStream(config, signal),
1038
+ wasCommitted: () => session.turns > initialTurns,
1039
+ };
1040
+ }
1041
+ // See {@link runSessionNonStreaming} for the routing contract. A
1042
+ // single assistant/system continuation falls through to the
1043
+ // multi-message reset + cold re-prime branch below rather than
1044
+ // crashing with 500.
1045
+ if (newInputMessages.length === 1) {
1046
+ const last = newInputMessages[0];
1047
+ if (last.role === 'user') {
1048
+ const initialTurns = session.turns;
1049
+ const images = last.images ?? undefined;
1050
+ return {
1051
+ stream: session.sendStream(last.content, images ? { images, config, signal } : { config, signal }),
1052
+ wasCommitted: () => session.turns > initialTurns,
1053
+ };
1054
+ }
1055
+ if (last.role === 'tool') {
1056
+ if (!last.toolCallId) {
1057
+ throw new Error('tool message missing toolCallId');
1058
+ }
1059
+ const initialTurns = session.turns;
1060
+ return {
1061
+ stream: session.sendToolResultStream(last.toolCallId, last.content, { config, signal }),
1062
+ wasCommitted: () => session.turns > initialTurns,
1063
+ };
1064
+ }
1065
+ // Non-user / non-tool single-message continuation falls through to
1066
+ // the reset + cold re-prime branch below.
1067
+ }
1068
+ // Multi-message (or single non-user/non-tool) hot path: same reset +
1069
+ // cold re-prime as the non-streaming variant. `initialTurns` must be
1070
+ // captured AFTER reset.
1071
+ await session.reset();
1072
+ session.primeHistory(messages);
1073
+ const initialTurns = session.turns;
1074
+ return {
1075
+ stream: session.startFromHistoryStream(config, signal),
1076
+ wasCommitted: () => session.turns > initialTurns,
1077
+ };
1078
+ }
1079
+ // ---------------------------------------------------------------------------
1080
+ // Storage helper
1081
+ // ---------------------------------------------------------------------------
1082
+ /**
1083
+ * Build the `StoredResponseRecord` for a committed response. Pure
1084
+ * function, split out from `initiatePersist` so the caller can build
1085
+ * the record synchronously inside `withExclusive`, register the
1086
+ * in-flight write in the tracker before the mutex releases, and await
1087
+ * off-lock purely for error logging. See `pending-writes.ts` for the
1088
+ * tracker contract.
1089
+ *
1090
+ * Only NEW input messages are stored — chain reconstruction re-derives
1091
+ * full history via `previous_response_id` links. `modelInstanceId` is
1092
+ * stashed in `configJson` (leaving the Rust-side schema untouched)
1093
+ * alongside `serverBootId` so `readStoredModelIdentity` can distinguish
1094
+ * a live in-process hot-swap (strict instance-id guard) from a cross-
1095
+ * restart resume (skip the instance-id guard, fall back to name-based
1096
+ * resume against whatever model is currently bound).
1097
+ */
1098
+ function buildResponseRecord(response, newInputMessages, previousResponseId, modelInstanceId, retentionSec) {
1099
+ // Retention is decoupled from the warm `SessionRegistry` TTL (30 min
1100
+ // KV cache) — the row must outlive the session so a later cold
1101
+ // replay can rebuild from SQLite. Default 7 days via `createServer`.
1102
+ const effectiveRetention = retentionSec != null && Number.isFinite(retentionSec) && retentionSec > 0 ? retentionSec : RESPONSE_TTL_SECONDS;
1103
+ return {
1104
+ id: response.id,
1105
+ createdAt: response.created_at,
1106
+ model: response.model,
1107
+ status: response.status,
1108
+ instructions: response.instructions ?? undefined,
1109
+ inputJson: JSON.stringify(newInputMessages),
1110
+ outputJson: JSON.stringify(response.output),
1111
+ outputText: response.output_text,
1112
+ usageJson: JSON.stringify(response.usage),
1113
+ previousResponseId: previousResponseId ?? undefined,
1114
+ configJson: JSON.stringify({
1115
+ temperature: response.temperature,
1116
+ top_p: response.top_p,
1117
+ max_output_tokens: response.max_output_tokens,
1118
+ tools: response.tools,
1119
+ reasoning: response.reasoning,
1120
+ modelInstanceId,
1121
+ serverBootId: getServerBootId(),
1122
+ }),
1123
+ expiresAt: Math.floor(Date.now() / 1000) + effectiveRetention,
1124
+ };
1125
+ }
1126
+ /**
1127
+ * Kick off an off-lock `store.store(record)` write and register it in
1128
+ * the per-store pending-write tracker. MUST be called synchronously
1129
+ * inside `withExclusive` so the tracker registration happens before
1130
+ * the mutex releases — a back-to-back continuation that slips in
1131
+ * observes the in-flight write via `awaitPending(previous_response_id)`
1132
+ * and retries `getChain` rather than 404-ing on a fresh responseId.
1133
+ *
1134
+ * `absoluteExpiresAtMs` = min(record expiry, chain earliest expiry) —
1135
+ * once crossed, the `awaitPending` path can short-circuit to 404
1136
+ * rather than keep emitting retryable 503 for an unrecoverable chain.
1137
+ *
1138
+ * Caller awaits the returned promise off-lock purely for error logging.
1139
+ */
1140
+ function initiatePersist(store, record, absoluteExpiresAtMs) {
1141
+ const writePromise = store.store(record);
1142
+ getPendingWritesFor(store).track(record.id, writePromise, absoluteExpiresAtMs);
1143
+ return writePromise;
1144
+ }
1145
+ function readStoredModelIdentity(record) {
1146
+ if (record.configJson == null)
1147
+ return { kind: 'absent' };
1148
+ let parsed;
1149
+ try {
1150
+ parsed = JSON.parse(record.configJson);
1151
+ }
1152
+ catch {
1153
+ return { kind: 'malformed' };
1154
+ }
1155
+ const bootId = typeof parsed.serverBootId === 'string' && parsed.serverBootId.length > 0 ? parsed.serverBootId : undefined;
1156
+ if (typeof parsed.modelInstanceId === 'number' && Number.isFinite(parsed.modelInstanceId)) {
1157
+ return { kind: 'present', instanceId: parsed.modelInstanceId, bootId };
1158
+ }
1159
+ return { kind: 'absent' };
1160
+ }
1161
+ // ---------------------------------------------------------------------------
1162
+ // Public handler
1163
+ // ---------------------------------------------------------------------------
1164
+ export async function handleCreateResponse(res, body, registry, store, httpReq, responseRetentionSec) {
1165
+ // Validate required fields
1166
+ if (body == null || typeof body !== 'object') {
1167
+ sendBadRequest(res, 'Request body must be a JSON object', 'body');
1168
+ return;
1169
+ }
1170
+ if (!body.model) {
1171
+ sendBadRequest(res, 'Missing required field: model', 'model');
1172
+ return;
1173
+ }
1174
+ if (body.input == null) {
1175
+ sendBadRequest(res, 'Missing required field: input', 'input');
1176
+ return;
1177
+ }
1178
+ if (typeof body.input !== 'string' && !Array.isArray(body.input)) {
1179
+ sendBadRequest(res, 'Field "input" must be a string or an array', 'input');
1180
+ return;
1181
+ }
1182
+ // Per-request retention override: `metadata.retention_seconds` lets a
1183
+ // client pin a single row to a longer (VIP / onboarding) or shorter
1184
+ // (one-shot PII) lifetime than the server-wide default. Bounds
1185
+ // `[60, 90 * 86400]` cap runaway retention and bound operator disk
1186
+ // use; `null` / `undefined` / missing → fall through to
1187
+ // `responseRetentionSec`. The error message is exact — clients parse
1188
+ // on it.
1189
+ let requestedRetentionSec;
1190
+ if (body.metadata != null && typeof body.metadata === 'object') {
1191
+ const raw = body.metadata.retention_seconds;
1192
+ if (raw != null) {
1193
+ const RETENTION_MIN = 60;
1194
+ const RETENTION_MAX = 90 * 86400; // 7_776_000
1195
+ if (typeof raw !== 'number' ||
1196
+ !Number.isFinite(raw) ||
1197
+ !Number.isInteger(raw) ||
1198
+ raw < RETENTION_MIN ||
1199
+ raw > RETENTION_MAX) {
1200
+ sendBadRequest(res, 'metadata.retention_seconds must be an integer in [60, 7776000]', 'metadata.retention_seconds');
1201
+ return;
1202
+ }
1203
+ requestedRetentionSec = raw;
1204
+ }
1205
+ }
1206
+ const effectiveRetentionSec = requestedRetentionSec ?? responseRetentionSec;
1207
+ // Look up model
1208
+ const model = registry.get(body.model);
1209
+ if (!model) {
1210
+ sendNotFound(res, `Model "${body.model}" not found. Available models: ${registry
1211
+ .list()
1212
+ .map((m) => m.id)
1213
+ .join(', ')}`);
1214
+ return;
1215
+ }
1216
+ // Dispatch lease keeps the binding (and its FIFO `execLock` chain)
1217
+ // alive across every await in this handler — required because a
1218
+ // concurrent `unregister()` + `register(sameModel)` would otherwise
1219
+ // allocate a fresh `SessionRegistry` and race two independent mutex
1220
+ // chains against one native model. Released in `finally` below.
1221
+ const lease = registry.acquireDispatchLease(body.model);
1222
+ if (!lease) {
1223
+ sendInternalError(res, 'session registry missing for registered model');
1224
+ return;
1225
+ }
1226
+ const leaseModel = lease.model;
1227
+ // AbortController wired to disconnect events, declared at handler
1228
+ // scope so the outer `finally` can always detach even on early
1229
+ // return. Listeners attach only after the pre-lock validation gates
1230
+ // pass; `abortListenersAttached` guards the detach.
1231
+ const abortController = new AbortController();
1232
+ const abortSocket = res.socket;
1233
+ const onAbortClose = () => {
1234
+ abortController.abort();
1235
+ };
1236
+ const onAbortError = (_err) => {
1237
+ abortController.abort();
1238
+ };
1239
+ let abortListenersAttached = false;
1240
+ // `runPostDispatchCleanup` runs eagerly after `withExclusive` returns
1241
+ // (so a wedged post-commit persist does not pin abort listeners or
1242
+ // the lease) and also idempotently from the outer `finally` for the
1243
+ // early-return path. These flags keep it a no-op when already run.
1244
+ let cleanupPerformed = false;
1245
+ let leaseReleased = false;
1246
+ try {
1247
+ // Initial snapshot of the live binding. On a continuation we
1248
+ // re-read after `await store.getChain()` and reject if the
1249
+ // binding moved (hot-swap race guard below). Stateless requests
1250
+ // keep the snapshot unchanged.
1251
+ const initialSessionReg = lease.registry;
1252
+ const initialInstanceId = lease.instanceId;
1253
+ let sessionReg = initialSessionReg;
1254
+ let currentInstanceId = initialInstanceId;
1255
+ const responseId = genId('resp_');
1256
+ let priorMessages;
1257
+ let previousResponseId;
1258
+ // Trailing-record inherited instructions, applied when the caller
1259
+ // omits `body.instructions` (empty string still counts as an
1260
+ // explicit override). Keeps `instructions: "You are a pirate"`
1261
+ // alive across cold replays.
1262
+ let inheritedInstructions = null;
1263
+ // Precomputed scalar = earliest wall-clock expiry across the
1264
+ // resolved chain (epoch-ms). `ResponseStore.getChain()` aborts on
1265
+ // the first expired ancestor (see
1266
+ // `crates/mlx-db/src/response_store/reader.rs:44-59`), so once
1267
+ // this bound is crossed the chain is unrecoverable and we can
1268
+ // short-circuit the retryable-503 path to permanent 404. Threading
1269
+ // only the scalar (not the record array) keeps background
1270
+ // hard-timeout closures O(1) per pending continuation.
1271
+ let chainEarliestExpiresAtMs = undefined;
1272
+ if (body.previous_response_id && store) {
1273
+ try {
1274
+ // Persist-before-getChain race: a client firing back-to-back
1275
+ // `previous_response_id: A` can reach `getChain(A)` before
1276
+ // the producer's off-lock `store.store(A)` has landed. The
1277
+ // pending-writes tracker is registered synchronously inside
1278
+ // `withExclusive` (see `initiatePersist`) so we can observe
1279
+ // the in-flight write and retry.
1280
+ //
1281
+ // Native mlx-db throws `"Response not found: <id>"` on miss
1282
+ // (`crates/mlx-db/src/response_store/reader.rs`); in-memory
1283
+ // mocks return `[]`. Handle both — the lenient /not found/
1284
+ // match routes both into the retry path while letting real
1285
+ // infrastructure errors bubble to the outer catch.
1286
+ let chain;
1287
+ let firstAttemptError = null;
1288
+ try {
1289
+ chain = await store.getChain(body.previous_response_id);
1290
+ }
1291
+ catch (err) {
1292
+ const msg = err instanceof Error ? err.message : String(err);
1293
+ if (!/not found/i.test(msg)) {
1294
+ throw err;
1295
+ }
1296
+ firstAttemptError = err;
1297
+ chain = [];
1298
+ }
1299
+ if (chain.length === 0) {
1300
+ const pending = getPendingWritesFor(store).awaitPending(body.previous_response_id);
1301
+ if (pending !== undefined) {
1302
+ const chainWriteWaitTimeoutMs = getChainWriteWaitTimeoutMs();
1303
+ let timeoutHandle;
1304
+ const timeoutPromise = new Promise((resolve) => {
1305
+ timeoutHandle = setTimeout(() => {
1306
+ resolve('timeout');
1307
+ }, chainWriteWaitTimeoutMs);
1308
+ });
1309
+ const pendingOutcome = pending.then(() => 'landed');
1310
+ let timedOut = false;
1311
+ try {
1312
+ const outcome = await Promise.race([pendingOutcome, timeoutPromise]);
1313
+ timedOut = outcome === 'timeout';
1314
+ }
1315
+ catch {
1316
+ // Write rejection is the producer's problem; the
1317
+ // tracker's .finally() already cleared the entry so
1318
+ // the retry below sees the true post-failure state.
1319
+ }
1320
+ finally {
1321
+ if (timeoutHandle !== undefined) {
1322
+ clearTimeout(timeoutHandle);
1323
+ }
1324
+ }
1325
+ if (timedOut) {
1326
+ // Last-probe race closer: a write landing at
1327
+ // (timeout + epsilon) would have succeeded but 404-ing
1328
+ // here is non-retryable and permanently poisons the
1329
+ // client's chain. If the probe misses too, surface 503
1330
+ // storage_timeout (retryable) instead of 404.
1331
+ let probed = null;
1332
+ try {
1333
+ probed = await store.getChain(body.previous_response_id);
1334
+ }
1335
+ catch (err) {
1336
+ const msg = err instanceof Error ? err.message : String(err);
1337
+ if (!/not found/i.test(msg)) {
1338
+ throw err;
1339
+ }
1340
+ probed = null;
1341
+ }
1342
+ if (probed !== null && probed.length > 0) {
1343
+ // Log the wedged-writer condition even on a
1344
+ // successful probe so operators see the slow path
1345
+ // fired.
1346
+ console.warn(`[responses] pending store write for previous_response_id "${body.previous_response_id}" did ` +
1347
+ `not settle within ${chainWriteWaitTimeoutMs}ms, but a last-probe getChain found the record. ` +
1348
+ `Continuing with the probed chain — likely a slow SQLite writer that landed just after the ` +
1349
+ `timeout fired.`);
1350
+ chain = probed;
1351
+ }
1352
+ else {
1353
+ // Once the chain's earliest-recoverable expiry has
1354
+ // passed, `getChain()` can never succeed (the reader
1355
+ // aborts on the first expired ancestor, see
1356
+ // `reader.rs:44-59`). Short-circuit to permanent 404
1357
+ // rather than loop the client on retryable 503 for an
1358
+ // unrecoverable chain.
1359
+ const earliestMs = getPendingWritesFor(store).getEarliestExpiresAtMs(body.previous_response_id);
1360
+ if (earliestMs !== undefined && Date.now() >= earliestMs) {
1361
+ console.warn(`[responses] timed out after ${chainWriteWaitTimeoutMs}ms waiting for pending store write ` +
1362
+ `for previous_response_id "${body.previous_response_id}"; last-probe getChain still missed. ` +
1363
+ `Earliest recoverable expiry (${earliestMs}ms) already crossed — returning 404 NotFound ` +
1364
+ `rather than retryable 503 because getChain() can no longer succeed for this chain.`);
1365
+ sendNotFound(res, `Previous response "${body.previous_response_id}" not found`);
1366
+ return;
1367
+ }
1368
+ console.warn(`[responses] timed out after ${chainWriteWaitTimeoutMs}ms waiting for pending store write ` +
1369
+ `for previous_response_id "${body.previous_response_id}"; last-probe getChain still missed. ` +
1370
+ `Returning 503 storage_timeout — the underlying store.store(...) promise did not settle in time, ` +
1371
+ `likely a wedged SQLite writer or stuck native backend. The client may retry with the same ` +
1372
+ `previous_response_id.`);
1373
+ sendStorageTimeout(res, `Storage write for "${body.previous_response_id}" did not settle within ${chainWriteWaitTimeoutMs}ms. ` +
1374
+ `This is a transient backend condition — retry the request with the same previous_response_id.`);
1375
+ return;
1376
+ }
1377
+ }
1378
+ else {
1379
+ try {
1380
+ chain = await store.getChain(body.previous_response_id);
1381
+ }
1382
+ catch (err) {
1383
+ const msg = err instanceof Error ? err.message : String(err);
1384
+ if (!/not found/i.test(msg)) {
1385
+ throw err;
1386
+ }
1387
+ chain = [];
1388
+ }
1389
+ }
1390
+ }
1391
+ else if (firstAttemptError !== null) {
1392
+ // Hard-timed-out marker path: the post-commit persist
1393
+ // hit the hard breaker but the raw write may still land.
1394
+ // Classify as retryable 503 (not 404) so clients keep
1395
+ // the chain alive; re-probe once first to catch a write
1396
+ // that slipped in between marker-set and now.
1397
+ if (getPendingWritesFor(store).isHardTimedOut(body.previous_response_id)) {
1398
+ let lastChance = null;
1399
+ try {
1400
+ lastChance = await store.getChain(body.previous_response_id);
1401
+ }
1402
+ catch (err) {
1403
+ const msg = err instanceof Error ? err.message : String(err);
1404
+ if (!/not found/i.test(msg)) {
1405
+ throw err;
1406
+ }
1407
+ lastChance = null;
1408
+ }
1409
+ if (lastChance !== null && lastChance.length > 0) {
1410
+ console.warn(`[responses] previous_response_id "${body.previous_response_id}" missing on first lookup and ` +
1411
+ `its post-commit persist crossed the hard-timeout breaker, but a last-probe getChain found ` +
1412
+ `the record. Continuing with the probed chain — likely a wedged SQLite writer that landed ` +
1413
+ `just after the marker was set.`);
1414
+ chain = lastChance;
1415
+ }
1416
+ else {
1417
+ console.warn(`[responses] previous_response_id "${body.previous_response_id}" missing from store, but its ` +
1418
+ `post-commit persist crossed the hard-timeout breaker and is still unresolved (last-probe ` +
1419
+ `getChain still missed). Returning 503 storage_timeout so the client retries with the same ` +
1420
+ `id rather than discarding the chain as permanently invalid.`);
1421
+ sendStorageTimeout(res, `Storage write for "${body.previous_response_id}" crossed the post-commit persist hard-timeout ` +
1422
+ `breaker and has not yet settled. This is a transient backend condition — retry the request ` +
1423
+ `with the same previous_response_id.`);
1424
+ return;
1425
+ }
1426
+ }
1427
+ else {
1428
+ // Genuine 404: first call missed, no pending write, no
1429
+ // hard-timed-out marker. Rethrow so outer catch emits 404.
1430
+ throw firstAttemptError;
1431
+ }
1432
+ }
1433
+ if (chain.length === 0) {
1434
+ // Mirror the rethrow branch: a mock-compatible store that
1435
+ // returned `[]` rather than throwing still needs the
1436
+ // hard-timed-out marker retryable-503 classification.
1437
+ if (getPendingWritesFor(store).isHardTimedOut(body.previous_response_id)) {
1438
+ let lastChance = null;
1439
+ try {
1440
+ lastChance = await store.getChain(body.previous_response_id);
1441
+ }
1442
+ catch (err) {
1443
+ const msg = err instanceof Error ? err.message : String(err);
1444
+ if (!/not found/i.test(msg)) {
1445
+ throw err;
1446
+ }
1447
+ lastChance = null;
1448
+ }
1449
+ if (lastChance !== null && lastChance.length > 0) {
1450
+ console.warn(`[responses] previous_response_id "${body.previous_response_id}" missing on first lookup and ` +
1451
+ `its post-commit persist crossed the hard-timeout breaker, but a last-probe getChain found ` +
1452
+ `the record. Continuing with the probed chain — likely a wedged SQLite writer that landed ` +
1453
+ `just after the marker was set.`);
1454
+ chain = lastChance;
1455
+ }
1456
+ else {
1457
+ console.warn(`[responses] previous_response_id "${body.previous_response_id}" missing from store, but its ` +
1458
+ `post-commit persist crossed the hard-timeout breaker and is still unresolved (last-probe ` +
1459
+ `getChain still missed). Returning 503 storage_timeout so the client retries with the same ` +
1460
+ `id rather than discarding the chain as permanently invalid.`);
1461
+ sendStorageTimeout(res, `Storage write for "${body.previous_response_id}" crossed the post-commit persist hard-timeout ` +
1462
+ `breaker and has not yet settled. This is a transient backend condition — retry the request ` +
1463
+ `with the same previous_response_id.`);
1464
+ return;
1465
+ }
1466
+ }
1467
+ else {
1468
+ sendNotFound(res, `Previous response "${body.previous_response_id}" not found`);
1469
+ return;
1470
+ }
1471
+ }
1472
+ }
1473
+ // Hot-swap race guard (getChain await window): re-read the
1474
+ // binding and reject if `registry.register(body.model, …)`
1475
+ // re-pointed the name while we awaited. The in-lock guard
1476
+ // below covers the mutex-wait window; this one covers the
1477
+ // getChain window.
1478
+ const refreshedSessionReg = registry.getSessionRegistry(body.model);
1479
+ const refreshedInstanceId = registry.getInstanceId(body.model);
1480
+ if (refreshedSessionReg === undefined ||
1481
+ refreshedInstanceId === undefined ||
1482
+ refreshedSessionReg !== initialSessionReg ||
1483
+ refreshedInstanceId !== initialInstanceId) {
1484
+ sendBadRequest(res, `Model "${body.model}" binding changed while the request was resolving its previous_response_id ` +
1485
+ `chain. A concurrent register() re-pointed the name at a different model instance (or released ` +
1486
+ `it entirely) during the store lookup, so the session registry and instance id captured before ` +
1487
+ `the await no longer match the live binding. Dispatching anyway would replay the stored chain ` +
1488
+ `through the wrong model. Retry the request — if the swap was intentional, the new binding will ` +
1489
+ `service the retry cleanly.`, 'model');
1490
+ return;
1491
+ }
1492
+ sessionReg = refreshedSessionReg;
1493
+ currentInstanceId = refreshedInstanceId;
1494
+ // Cross-model continuation guard keyed on MODEL-INSTANCE
1495
+ // IDENTITY (not friendly name): friendly-name equality would
1496
+ // accept a chain produced by the pre-hot-swap instance and
1497
+ // silently replay through a different tokenizer / chat
1498
+ // template / KV layout. Aliases to the same instance are
1499
+ // handled transparently by shared `SessionRegistry` routing.
1500
+ //
1501
+ // Restart safety: the instance-id comparison is only
1502
+ // meaningful WITHIN a single process lifetime. The stored
1503
+ // row's `serverBootId` gates the comparison — when it does
1504
+ // NOT match the live boot id (or is absent on a row written
1505
+ // before this field was added) the stored `modelInstanceId`
1506
+ // belongs to a dead process and is meaningless, so the
1507
+ // strict guard is skipped and the continuation falls back
1508
+ // to name-based resume against whatever model is currently
1509
+ // bound to `body.model`. Truly legacy rows that carry
1510
+ // NEITHER `modelInstanceId` NOR `serverBootId` are still
1511
+ // rejected outright (cannot verify anything).
1512
+ const trailingRecord = chain[chain.length - 1];
1513
+ const storedIdentity = readStoredModelIdentity(trailingRecord);
1514
+ if (storedIdentity.kind === 'malformed') {
1515
+ sendBadRequest(res, `previous_response_id "${body.previous_response_id}" points at a stored record whose ` +
1516
+ `configJson blob failed to parse — the server cannot verify the model identity or prior ` +
1517
+ `config state it was produced under, so continuing the chain through any model would ` +
1518
+ `silently replay against an unreadable prior turn. Start a new chain without ` +
1519
+ `previous_response_id.`, 'previous_response_id');
1520
+ return;
1521
+ }
1522
+ if (storedIdentity.kind === 'absent') {
1523
+ sendBadRequest(res, `previous_response_id "${body.previous_response_id}" points at a legacy stored record ` +
1524
+ `that does not carry a modelInstanceId — the server cannot verify which model instance ` +
1525
+ `produced the chain, so continuing it through any model risks silently replaying ` +
1526
+ `under the wrong tokenizer, chat template, or KV layout. Start a new chain without ` +
1527
+ `previous_response_id.`, 'previous_response_id');
1528
+ return;
1529
+ }
1530
+ // kind === 'present': apply the strict instance-id guard ONLY when
1531
+ // the stored row carries a boot id that matches the live process.
1532
+ // A missing or non-matching stored boot id means the row was
1533
+ // produced by a prior process (or pre-bootId rollout), so the
1534
+ // stored instance id cannot be compared against anything live.
1535
+ const liveBootId = getServerBootId();
1536
+ const sameProcess = storedIdentity.bootId !== undefined && storedIdentity.bootId === liveBootId;
1537
+ if (sameProcess && (currentInstanceId === undefined || storedIdentity.instanceId !== currentInstanceId)) {
1538
+ sendBadRequest(res, `previous_response_id "${body.previous_response_id}" belongs to a chain produced by a different ` +
1539
+ `model instance than the one currently bound to "${body.model}". This happens when the named ` +
1540
+ `model has been hot-swapped to a different underlying object since the chain was stored or ` +
1541
+ `when the original binding has been released entirely. Continuations cannot cross model ` +
1542
+ `boundaries — a stored chain is tied to the tokenizer, chat template, and KV layout of the ` +
1543
+ `exact model object that produced it, and replaying it through a different model would ` +
1544
+ `silently corrupt the conversation. Start a new chain without previous_response_id.`, 'model');
1545
+ return;
1546
+ }
1547
+ priorMessages = reconstructMessagesFromChain(chain);
1548
+ previousResponseId = body.previous_response_id;
1549
+ // Fold chain expiries (epoch-seconds → ms) into a single
1550
+ // scalar. The full chain is NOT retained on outer scope, so
1551
+ // the hard-timeout closure below does not capture ancestor
1552
+ // JSON payloads. Rows with missing/malformed `expiresAt` are
1553
+ // skipped; all-missing chains leave the scalar `undefined`.
1554
+ const chainExpirySeconds = chain.length > 0
1555
+ ? Math.min(...chain.map((r) => r.expiresAt).filter((v) => v != null && Number.isFinite(v)))
1556
+ : Number.POSITIVE_INFINITY;
1557
+ chainEarliestExpiresAtMs = Number.isFinite(chainExpirySeconds) ? chainExpirySeconds * 1000 : undefined;
1558
+ // Inherit the trailing record's `instructions` when the
1559
+ // request omits `body.instructions` (empty string still counts
1560
+ // as explicit override). The trailing record carries the
1561
+ // effective instructions in force for that turn, so no
1562
+ // full-chain walk is required. The effective value is also
1563
+ // threaded into the `SessionRegistry` cache key so a hot hit
1564
+ // under stale system context forces a cold replay.
1565
+ //
1566
+ // Empty-string stored instructions MUST be inherited as `""`,
1567
+ // not dropped to `null`: a chain that intentionally cleared
1568
+ // instructions with an explicit empty string was adopted
1569
+ // against the registry under `requestedInstructions = ""`, so
1570
+ // resolving a later no-instructions turn to `null` would
1571
+ // silently flip the byte-for-byte comparison in
1572
+ // `SessionRegistry.getOrCreate` and force a cold replay on
1573
+ // every follow-up. Gate on `typeof === 'string'` so only a
1574
+ // genuinely absent stored value (legacy rows, or rows whose
1575
+ // turn had no instructions in force) short-circuits inheritance.
1576
+ if (typeof body.instructions !== 'string') {
1577
+ const storedInstructions = chain[chain.length - 1].instructions;
1578
+ if (typeof storedInstructions === 'string') {
1579
+ inheritedInstructions = storedInstructions;
1580
+ }
1581
+ }
1582
+ }
1583
+ catch (err) {
1584
+ const msg = err instanceof Error ? err.message : '';
1585
+ if (/not found/i.test(msg)) {
1586
+ sendNotFound(res, `Previous response "${body.previous_response_id}" not found or expired`);
1587
+ }
1588
+ else {
1589
+ sendInternalError(res, `Failed to retrieve previous response: ${msg || 'unknown error'}`);
1590
+ }
1591
+ return;
1592
+ }
1593
+ }
1594
+ else if (body.previous_response_id && !store) {
1595
+ sendBadRequest(res, 'previous_response_id requires a response store to be configured');
1596
+ return;
1597
+ }
1598
+ // Echoed `function_call` items on a continuation are validated
1599
+ // for ownership (call_id in stored trailing assistant turn) then
1600
+ // stripped. `mapRequest` would otherwise rebuild each echo into a
1601
+ // synthetic assistant message at the tail of `messages`, letting
1602
+ // a forged echo rewrite the trailing assistant turn and bypass
1603
+ // the fan-out gate. `priorMessages` is the authoritative copy.
1604
+ let effectiveInput = body.input;
1605
+ if (previousResponseId && priorMessages && Array.isArray(body.input)) {
1606
+ const storedCallIds = buildTrailingAssistantToolCallIds(priorMessages);
1607
+ const filtered = [];
1608
+ for (const item of body.input) {
1609
+ if (item != null && typeof item === 'object' && item.type === 'function_call') {
1610
+ const fc = item;
1611
+ const callId = typeof fc.call_id === 'string' ? fc.call_id : null;
1612
+ if (!callId || !storedCallIds || !storedCallIds.has(callId)) {
1613
+ sendBadRequest(res, `echoed function_call item references an unknown call_id "${callId ?? ''}" — the stored ` +
1614
+ `trailing assistant turn is the authoritative copy, and any echoed function_call must ` +
1615
+ `reference one of its outstanding tool calls. Drop the echoed item or resolve the ` +
1616
+ `continuation against the correct previous_response_id.`, 'input');
1617
+ return;
1618
+ }
1619
+ // Stored state is authoritative — drop the echo regardless
1620
+ // of whether `name`/`arguments` match byte-for-byte.
1621
+ continue;
1622
+ }
1623
+ filtered.push(item);
1624
+ }
1625
+ effectiveInput = filtered;
1626
+ }
1627
+ // Effective instructions = caller's explicit `body.instructions`
1628
+ // or the trailing record's inherited value. Threaded through
1629
+ // `mapRequest` (prepends system msg), the registry cache key,
1630
+ // `buildResponseObject`, and persistence. Applied via a fresh
1631
+ // mapped body rather than mutating `body`.
1632
+ const effectiveInstructions = typeof body.instructions === 'string' ? body.instructions : inheritedInstructions;
1633
+ let messages;
1634
+ let config;
1635
+ const mappedBody = effectiveInput === body.input && effectiveInstructions === (body.instructions ?? null)
1636
+ ? body
1637
+ : {
1638
+ ...body,
1639
+ input: effectiveInput,
1640
+ instructions: effectiveInstructions ?? undefined,
1641
+ };
1642
+ try {
1643
+ ({ messages, config } = mapRequest(mappedBody, priorMessages));
1644
+ }
1645
+ catch (err) {
1646
+ sendBadRequest(res, err instanceof Error ? err.message : 'Invalid request input', 'input');
1647
+ return;
1648
+ }
1649
+ // New-only messages (what this request added). Instructions are
1650
+ // stored separately — persisting them as input messages would
1651
+ // replay stale system messages on cold chain. Mirror `mapRequest`'s
1652
+ // truthy check (empty string contributes zero offset).
1653
+ const instructionsOffset = mappedBody.instructions ? 1 : 0;
1654
+ const priorOffset = instructionsOffset + (priorMessages?.length ?? 0);
1655
+ let newInputMessages = messages.slice(priorOffset);
1656
+ // Every tool message in the continuation delta must carry a
1657
+ // non-empty `tool_call_id`. Correctness-critical: the id-set gate
1658
+ // below silently ignores anonymous tool messages, and native
1659
+ // backends that pair results positionally would bind the
1660
+ // anonymous entry to the wrong call.
1661
+ for (const m of newInputMessages) {
1662
+ if (m.role === 'tool' && (typeof m.toolCallId !== 'string' || m.toolCallId.length === 0)) {
1663
+ sendBadRequest(res, 'tool message missing tool_call_id', 'input');
1664
+ return;
1665
+ }
1666
+ }
1667
+ // `SessionRegistry` cache key — passing the effective value lets
1668
+ // the registry force a cold replay on instructions mismatch.
1669
+ const requestedInstructions = effectiveInstructions;
1670
+ // The native model is a single mutable resource (one
1671
+ // `cached_token_history`, one `caches` vector) so every dispatch
1672
+ // through `/v1/responses` and `/v1/messages` for the same binding
1673
+ // serializes through `sessionReg.withExclusive`. The mutex spans
1674
+ // `getOrCreate → dispatch → adopt/drop`.
1675
+ const preLockSessionReg = sessionReg;
1676
+ const preLockInstanceId = currentInstanceId;
1677
+ // Arm the abort listeners. `@mlx-node/lm`'s streaming wrappers
1678
+ // plumb the signal into `_runChatStream`, which calls
1679
+ // `handle.cancel()` on the native stream handle AND pushes a
1680
+ // synthetic marker to unblock the next `waitForItem()`. Attached
1681
+ // here (not at function entry) so early-return validation gates
1682
+ // above don't need paired detach calls.
1683
+ res.once('close', onAbortClose);
1684
+ res.once('error', onAbortError);
1685
+ if (abortSocket != null) {
1686
+ abortSocket.once('close', onAbortClose);
1687
+ }
1688
+ if (httpReq) {
1689
+ httpReq.once('close', onAbortClose);
1690
+ httpReq.once('error', onAbortError);
1691
+ }
1692
+ abortListenersAttached = true;
1693
+ const streamSignal = abortController.signal;
1694
+ // Persistence is a two-step dance.
1695
+ //
1696
+ // (1) INSIDE the per-model mutex (on the happy path only):
1697
+ // synchronously kick off `store.store(record)` via
1698
+ // `initiatePersist` — which registers the in-flight
1699
+ // promise in a per-store pending-write tracker keyed on
1700
+ // the response id. The mutex releases BEFORE the write
1701
+ // lands in SQLite.
1702
+ //
1703
+ // (2) AFTER the mutex releases: await the in-flight promise
1704
+ // just to surface errors to the log. The write is
1705
+ // already on its way; the caller waits purely for
1706
+ // logging completeness.
1707
+ //
1708
+ // A back-to-back `previous_response_id` continuation that fires
1709
+ // between mutex release and SQLite land observes the pending
1710
+ // write through the tracker (see the `getChain`-empty retry at
1711
+ // the top of this handler) and awaits it before falling
1712
+ // through to the 404 epilogue. This closes the race where a
1713
+ // fresh response id on the wire could transiently 404 under
1714
+ // `getChain`.
1715
+ //
1716
+ // `pendingPersistOuter` is the in-flight promise captured
1717
+ // inside the lock; the out-of-lock awaiter just catches errors
1718
+ // and logs them. `persistMode` is populated alongside so the
1719
+ // log line keeps the streaming / non-streaming discrimination.
1720
+ let pendingPersistOuter = null;
1721
+ let persistMode = null;
1722
+ // Structural scaffolding for the binding retain paired with the
1723
+ // in-flight persist. The persist's `.finally(...)` calls this
1724
+ // closure on settlement to balance the `retainBinding` taken at
1725
+ // dispatch time — the closure's idempotency flag matters only
1726
+ // to that one call site today.
1727
+ //
1728
+ // The box shape is kept deliberately so a future iteration can
1729
+ // reintroduce a surgical "split teardown" (e.g. release heavy
1730
+ // resources on timeout while keeping identity pinned until
1731
+ // settlement) without rewiring the retain wrappers in both
1732
+ // dispatch branches. Do NOT force-release on post-commit
1733
+ // timeout: a slow-but-eventual persist can still land after
1734
+ // the timer fires, and releasing the retain before the write
1735
+ // settles lets an intervening same-object `unregister()` +
1736
+ // `register()` finalise the old binding and mint a fresh
1737
+ // instance id, causing the late write to record a stale id and
1738
+ // break the next `previous_response_id` continuation.
1739
+ //
1740
+ // Held in a box because TypeScript's control-flow analysis
1741
+ // otherwise narrows the in-closure assignment to `never`
1742
+ // across the intervening `await` / try-catch boundaries.
1743
+ const persistRetainBox = { release: null };
1744
+ // `failureMode` carries the streaming failure-epilogue reason
1745
+ // from `handleStreamingNative` out to the outer adopt gate.
1746
+ // A final-chunk commit followed by a post-terminal `res.close`
1747
+ // takes the `client_abort` branch and flushes `response.failed`
1748
+ // successfully, which would otherwise flip `safeToSuppress =
1749
+ // true` and let the adopt gate cache a session under a response
1750
+ // id the client will never chain off of. The gate refuses to
1751
+ // adopt when `failureMode === 'client_abort'` regardless of how
1752
+ // `committed` / `safeToSuppress` landed.
1753
+ let streamFailureMode = null;
1754
+ try {
1755
+ await sessionReg.withExclusive(async () => {
1756
+ // Hot-swap race guard inside the mutex.
1757
+ //
1758
+ // `withExclusive` can park this waiter behind a long-running
1759
+ // dispatch on the same model, and `ModelRegistry.register()` is
1760
+ // NOT coordinated with that lock — a concurrent
1761
+ // `registry.register(body.model, newModel)` can re-point the
1762
+ // friendly name while we are parked. Without this in-lock re-read
1763
+ // the closure would still lease a session out of the already-
1764
+ // captured `preLockSessionReg`, adopt under the dead
1765
+ // `preLockInstanceId`, and persist the new chain under a binding
1766
+ // that `body.model` no longer resolves to. The pre-lock
1767
+ // re-read only covered the `store.getChain()` await window; the
1768
+ // mutex-wait window is strictly later and equally unsafe.
1769
+ //
1770
+ // Compare the live binding to the pre-lock snapshot (captured
1771
+ // just before entering the mutex — already refreshed on the
1772
+ // continuation path, identical to the handler-top snapshot
1773
+ // on the stateless path). Any drift — nullable or value — is
1774
+ // fatal and rejected with the same 400 envelope the pre-lock
1775
+ // guard uses, so clients see a consistent "binding changed"
1776
+ // error regardless of which await window caught the race.
1777
+ const lockedSessionReg = registry.getSessionRegistry(body.model);
1778
+ const lockedInstanceId = registry.getInstanceId(body.model);
1779
+ if (lockedSessionReg === undefined ||
1780
+ lockedInstanceId === undefined ||
1781
+ lockedSessionReg !== preLockSessionReg ||
1782
+ lockedInstanceId !== preLockInstanceId) {
1783
+ sendBadRequest(res, `Model "${body.model}" binding changed while the request was queued behind the per-model ` +
1784
+ `execution mutex. A concurrent register() re-pointed the name at a different model instance ` +
1785
+ `(or released it entirely) while this waiter was parked, so the session registry and instance ` +
1786
+ `id captured before the mutex wait no longer match the live binding. Dispatching anyway would ` +
1787
+ `route the request through the wrong model — priming, decoding, and persisting under a dead ` +
1788
+ `binding. Retry the request — if the swap was intentional, the new binding will service the ` +
1789
+ `retry cleanly.`, 'model');
1790
+ return;
1791
+ }
1792
+ // Route the request through a `ChatSession` looked up by the prior
1793
+ // response id. A miss (null id, unknown id, expired entry, or
1794
+ // prefix-state mismatch) returns a fresh session; a hit leases the
1795
+ // cached session out of the registry (single-use — the entry is
1796
+ // removed on hit so overlapping requests against the same prior id
1797
+ // cannot race on the same single-flight ChatSession).
1798
+ //
1799
+ // Hot-path eligibility gate: the chat-session delta API only
1800
+ // serves a SINGLE `user` or `tool` continuation message — the
1801
+ // `send` / `sendToolResult` entry points cover exactly that
1802
+ // shape. A single `assistant` / `system` continuation cannot
1803
+ // be advanced incrementally against the warm KV cache and
1804
+ // must be handled via reset + cold re-prime. Consuming a warm
1805
+ // lease only to immediately `session.reset()` would destroy
1806
+ // the cached prefix for no benefit (and mislabel the turn as
1807
+ // `hit` when the client actually paid a full cold-replay
1808
+ // prefill), so detect the case up front and force a fresh
1809
+ // session lookup by passing `null` into `getOrCreate`. The
1810
+ // subsequent `ResponseStore` reconstruction + `primeHistory` +
1811
+ // `startFromHistory*` path below handles the rebuild. Multi-
1812
+ // message continuations are left to the existing reset + cold
1813
+ // re-prime fall-through inside `runSession*` (see comments
1814
+ // there).
1815
+ const hotPathIneligible = previousResponseId != null &&
1816
+ newInputMessages.length === 1 &&
1817
+ newInputMessages[0].role !== 'user' &&
1818
+ newInputMessages[0].role !== 'tool';
1819
+ const lookup = hotPathIneligible
1820
+ ? sessionReg.getOrCreate(null, requestedInstructions)
1821
+ : sessionReg.getOrCreate(previousResponseId ?? null, requestedInstructions);
1822
+ const session = lookup.session;
1823
+ // `X-Session-Cache` observability header: classify this turn as
1824
+ // `fresh` (no `previous_response_id` on the request), `hit`
1825
+ // (warm-cache lease consumed), or `cold_replay` (request carried
1826
+ // `previous_response_id` but the warm entry was missing / expired
1827
+ // / instructions-mismatched / already leased, OR the request
1828
+ // shape is ineligible for the hot path — the endpoint will
1829
+ // rebuild the session from the `ResponseStore` below). Set
1830
+ // before any `writeHead` / SSE `beginSSE` so both JSON and SSE
1831
+ // responses carry it. See `endpoints/messages.ts` for the
1832
+ // matching always-`fresh` emission on `/v1/messages`.
1833
+ const sessionCacheStatus = previousResponseId == null ? 'fresh' : lookup.hit && !hotPathIneligible ? 'hit' : 'cold_replay';
1834
+ res.setHeader('X-Session-Cache', sessionCacheStatus);
1835
+ // Multi-tool-call fan-out gate.
1836
+ //
1837
+ // The chat-session API cannot interleave tool results for a
1838
+ // multi-call fan-out turn (each `sendToolResult` dispatch re-opens
1839
+ // the assistant turn, so responding to the siblings would weave new
1840
+ // assistant replies between the results — see
1841
+ // `ChatSession.pendingUnresolvedToolCallCount`). The only valid forward
1842
+ // progress from such a turn is an atomic replay that resolves every
1843
+ // sibling call in one cold-restart, so we reject any continuation
1844
+ // whose submitted `function_call_output` set does not exactly match
1845
+ // the outstanding call ids.
1846
+ //
1847
+ // The gate only runs for `previous_response_id` continuations, where
1848
+ // the STORED prior chain (`priorMessages`, reconstructed via
1849
+ // `reconstructMessagesFromChain`) is the authoritative view of the
1850
+ // trailing assistant turn and `newInputMessages` contains only the
1851
+ // caller's continuation delta. Stateless requests (no
1852
+ // `previous_response_id`) carry a full self-contained history in
1853
+ // `input`, and historical tool outputs for prior resolved turns
1854
+ // would otherwise be misclassified against the latest assistant's
1855
+ // outstanding id set — leave cold-start histories to the jinja
1856
+ // template / chat-session prefill to handle as-is.
1857
+ const expectedOutstandingIds = priorMessages ? extractOutstandingToolCallIds(priorMessages) : null;
1858
+ // Forged-tool-output guard. A `previous_response_id` continuation that
1859
+ // submits any `function_call_output` when the stored prior chain has
1860
+ // ZERO outstanding tool calls is structurally invalid: there is no
1861
+ // assistant tool call for the result to resolve, so dispatching it
1862
+ // would inject a synthetic `<tool_response>` delta into a thread the
1863
+ // model never asked to call. Native backends do not authenticate
1864
+ // `tool_call_id` against prior state — several just append the
1865
+ // delta verbatim — so the gate must live here. Stateless requests
1866
+ // (no `previous_response_id`) carry a full self-contained history
1867
+ // and are left to the jinja template / chat-session prefill.
1868
+ if (previousResponseId && expectedOutstandingIds === null) {
1869
+ for (const m of newInputMessages) {
1870
+ if (m.role === 'tool') {
1871
+ sendBadRequest(res, `function_call_output submitted against a thread with no outstanding tool call. ` +
1872
+ `The prior assistant turn either never emitted a tool call or every sibling call has ` +
1873
+ `already been resolved, so there is nothing for this function_call_output to answer. ` +
1874
+ `Dispatching it anyway would synthesize a tool-response delta for a call the model ` +
1875
+ `never made and corrupt the conversation structure. Drop the function_call_output, ` +
1876
+ `or start a new chain without previous_response_id.`, 'input');
1877
+ return;
1878
+ }
1879
+ }
1880
+ }
1881
+ if (expectedOutstandingIds !== null) {
1882
+ // Contiguous-prefix guard: function_call_output items must appear
1883
+ // as an unbroken prefix of the continuation delta, before any
1884
+ // user/assistant/system message. A shape like
1885
+ // `[tool(call_a), user(hi), tool(call_b)]` would otherwise pass
1886
+ // every id-set check below (both outstanding ids present, no
1887
+ // duplicates, no stale ids) while still orphaning the fan-out,
1888
+ // because the interleaved user turn re-opens the assistant turn
1889
+ // between the two tool results. Reject early so the caller cannot
1890
+ // smuggle a user turn into the middle of a resolved fan-out.
1891
+ let seenNonTool = false;
1892
+ for (const m of newInputMessages) {
1893
+ if (m.role === 'tool') {
1894
+ if (seenNonTool) {
1895
+ sendBadRequest(res, `function_call_output items must appear as a contiguous prefix of the continuation ` +
1896
+ `before any user, assistant, or system message. Interleaving a non-tool message ` +
1897
+ `between sibling function_call_output items orphans the fan-out by weaving a new ` +
1898
+ `assistant turn between the tool results. Reorder the submission so every ` +
1899
+ `function_call_output precedes any subsequent message, or start a new chain ` +
1900
+ `without previous_response_id.`, 'input');
1901
+ return;
1902
+ }
1903
+ }
1904
+ else {
1905
+ seenNonTool = true;
1906
+ }
1907
+ }
1908
+ const submittedIds = [];
1909
+ for (const m of newInputMessages) {
1910
+ if (m.role === 'tool' && typeof m.toolCallId === 'string' && m.toolCallId.length > 0) {
1911
+ submittedIds.push(m.toolCallId);
1912
+ }
1913
+ }
1914
+ // Short-circuit: a plain user continuation (zero tool results)
1915
+ // would orphan the outstanding call(s) just as surely as a
1916
+ // partial tool-result submission. Reject both paths with the
1917
+ // same 400.
1918
+ const plural = expectedOutstandingIds.length > 1;
1919
+ if (submittedIds.length === 0) {
1920
+ sendBadRequest(res, `Previous assistant turn has ${expectedOutstandingIds.length} unresolved tool call${plural ? 's' : ''} ` +
1921
+ `(${expectedOutstandingIds.join(', ')}); the chat-session API requires every outstanding ` +
1922
+ `function_call_output to be submitted before the thread can advance. A plain user turn ` +
1923
+ `would orphan the unresolved call${plural ? 's' : ''}. Submit function_call_output items for ` +
1924
+ `every outstanding id, or start a new chain without previous_response_id.`, 'input');
1925
+ return;
1926
+ }
1927
+ const expectedSet = new Set(expectedOutstandingIds);
1928
+ const seen = new Set();
1929
+ for (const id of submittedIds) {
1930
+ if (seen.has(id)) {
1931
+ sendBadRequest(res, `Duplicate function_call_output call_id "${id}" — each outstanding tool call must be answered exactly once.`, 'input');
1932
+ return;
1933
+ }
1934
+ seen.add(id);
1935
+ if (!expectedSet.has(id)) {
1936
+ sendBadRequest(res, `Unexpected function_call_output call_id "${id}"; the outstanding multi-tool-call set is ` +
1937
+ `${expectedOutstandingIds.join(', ')}. Submitting an unrelated or stale call_id would advance ` +
1938
+ `the chain past an unresolved turn.`, 'input');
1939
+ return;
1940
+ }
1941
+ }
1942
+ if (seen.size !== expectedSet.size) {
1943
+ const missing = [];
1944
+ for (const id of expectedOutstandingIds) {
1945
+ if (!seen.has(id))
1946
+ missing.push(id);
1947
+ }
1948
+ sendBadRequest(res, `Missing function_call_output items for outstanding tool calls: ${missing.join(', ')}. ` +
1949
+ `Partial submissions would orphan the sibling tool calls and advance the chain past an ` +
1950
+ `unresolved turn. Resubmit with every sibling output, or start a new chain without ` +
1951
+ `previous_response_id.`, 'input');
1952
+ return;
1953
+ }
1954
+ // All outstanding ids are accounted for. Canonicalize the submitted
1955
+ // tool-message order to the stored sibling order before the replay
1956
+ // runs — both `messages` (primed into the fresh session on the cold
1957
+ // path) and `newInputMessages` (persisted verbatim into the store
1958
+ // for future chain reconstruction) must reflect the canonical
1959
+ // order, otherwise a caller can swap outputs and silently poison
1960
+ // replay even after the id-set gate passes.
1961
+ //
1962
+ // Compute the tool block's end as the contiguous-prefix run of
1963
+ // `role === 'tool'` messages starting at `priorOffset`. The
1964
+ // contiguous-prefix guard above already rejected any shape that
1965
+ // interleaves a non-tool message inside the delta's tool block,
1966
+ // so this simple forward scan matches the exact block the gate
1967
+ // just authenticated. Passing an explicit `blockEnd` keeps the
1968
+ // helper from accidentally walking into any later turn that
1969
+ // `mapRequest` may have appended to `messages`.
1970
+ let deltaBlockEnd = priorOffset;
1971
+ while (deltaBlockEnd < messages.length && messages[deltaBlockEnd].role === 'tool') {
1972
+ deltaBlockEnd++;
1973
+ }
1974
+ canonicalizeToolMessageOrder(messages, priorOffset, deltaBlockEnd, expectedOutstandingIds);
1975
+ newInputMessages = messages.slice(priorOffset);
1976
+ }
1977
+ // Walk the full merged history and canonicalize every assistant
1978
+ // fan-out's trailing tool block against its declared sibling order.
1979
+ //
1980
+ // The multi-tool-call gate above only fires on `previous_response_id`
1981
+ // continuations, and even there it only handles the caller's delta
1982
+ // block against the STORED prior chain's trailing assistant. That
1983
+ // leaves two cases uncovered:
1984
+ //
1985
+ // 1. Stateless cold-start histories (no `previous_response_id`).
1986
+ // The caller ships a full self-contained conversation through
1987
+ // `input`; the gate is skipped entirely and the caller-supplied
1988
+ // tool-message order flows straight into `primeHistory()`. A
1989
+ // caller can reverse two sibling tool outputs, and since
1990
+ // several native session backends pair tool results to
1991
+ // fan-out calls POSITIONALLY (not by id), each result binds
1992
+ // to the wrong sibling call.
1993
+ // 2. Earlier fan-outs embedded inside the stored prior history
1994
+ // on a continuation. Those came from the server's own store
1995
+ // so they should already be canonical, but defense in depth
1996
+ // is cheap — a single full-history walk covers every shape.
1997
+ //
1998
+ // Malformed histories (missing/duplicate/unknown ids, orphan tool
1999
+ // messages, unresolved trailing fan-out in a stateless request)
2000
+ // are rejected with a clear 400 instead of silently rewritten.
2001
+ const historyError = validateAndCanonicalizeHistoryToolOrder(messages);
2002
+ if (historyError !== null) {
2003
+ sendBadRequest(res, historyError, 'input');
2004
+ return;
2005
+ }
2006
+ // Canonicalization may have reordered tool messages inside the
2007
+ // continuation delta (on the stateless-history walk over the
2008
+ // post-priorOffset portion), so recompute `newInputMessages` from
2009
+ // the now-canonical `messages`.
2010
+ newInputMessages = messages.slice(priorOffset);
2011
+ // Visibility / wire-format tracker shared between the handler
2012
+ // body and the outer catch. Declared outside the `try` so the
2013
+ // catch can branch on `responseMode` (JSON vs SSE) and know
2014
+ // whether a terminal artefact already landed — both signals
2015
+ // are authoritative, unlike `res.headersSent`.
2016
+ const visibility = createVisibility();
2017
+ try {
2018
+ // `runSession*` plumbs an honest commit signal out of the helper:
2019
+ // `ChatSession` only advances `turns` on a successful non-error
2020
+ // final chunk (streaming) or a resolved native promise
2021
+ // (non-streaming). The streaming safety-net path (generator
2022
+ // exhausts without a `done` event, see `handleStreamingNative`
2023
+ // fallback) and the `finishReason === 'error'` final chunk both
2024
+ // leave `turns` unchanged. The helper captures its baseline
2025
+ // AFTER any internal `session.reset()` on the multi-message
2026
+ // reset-and-cold-restart branch, so the signal is honest there
2027
+ // too — a pre-helper snapshot would be stale.
2028
+ let committed;
2029
+ // Pass `mappedBody` (not the raw `body`) so the response
2030
+ // object and the persisted record carry the EFFECTIVE
2031
+ // instructions, including any value inherited from the
2032
+ // trailing stored record via instruction inheritance.
2033
+ // Using `body` here
2034
+ // would re-drop the inherited value on the wire — the
2035
+ // client's response would report `instructions: null` even
2036
+ // though the turn was run against the inherited system
2037
+ // context, and the next cold replay would have nothing to
2038
+ // re-inherit from.
2039
+ // Wrap the handler call in its own try/catch so that a
2040
+ // post-commit persistence failure does not prevent adopt.
2041
+ // Post-commit store failures are caught inside the handlers
2042
+ // themselves (handleNonStreaming / handleStreamingNative) and
2043
+ // demoted to log-only. A handlerError at this level therefore
2044
+ // comes from non-persistence failures (response construction,
2045
+ // SSE write, res.writeHead/end crash).
2046
+ //
2047
+ // `res.headersSent` is NOT a reliable proxy for "the client
2048
+ // received the response": Node's `writeHead` flips
2049
+ // `headersSent = true` synchronously before any body bytes
2050
+ // leave the buffer, and the sync return of `res.end()` /
2051
+ // `writeSSEEvent` only proves the bytes were queued — an
2052
+ // async socket failure after the queue could still leave
2053
+ // the client with no terminal. Picking JSON-vs-SSE fallback
2054
+ // from `res.headersSent` is also unsafe because a
2055
+ // `writeHead(200, 'application/json')` → `res.end()` crash
2056
+ // would otherwise emit SSE frames into a JSON-declared
2057
+ // response.
2058
+ //
2059
+ // The `TransportVisibility` record instead tracks both the
2060
+ // wire format the handler committed to (`responseMode`)
2061
+ // AND whether the client observed a terminal artefact
2062
+ // (`responseBodyWritten` / `terminalEmitted`). Both flags
2063
+ // are flipped only from the kernel-ack callback of the
2064
+ // underlying `res.end` / `res.write` — synchronous return
2065
+ // is NOT treated as proof of visibility. The outer catch
2066
+ // branches on `responseMode` to choose the clean-up shape
2067
+ // (JSON error, SSE `error` frame, or socket destroy).
2068
+ let handlerError = null;
2069
+ if (mappedBody.stream) {
2070
+ const outcome = await runSessionStreaming(session, messages, newInputMessages, config, streamSignal);
2071
+ const streamingWasCommitted = () => outcome.wasCommitted();
2072
+ try {
2073
+ const handlerOutcome = await handleStreamingNative(res, outcome.stream, mappedBody, responseId, previousResponseId, streamingWasCommitted, httpReq, visibility);
2074
+ streamFailureMode = handlerOutcome.failureMode;
2075
+ if (handlerOutcome.terminalToPersist != null && store && body.store !== false) {
2076
+ // Initiate the write SYNCHRONOUSLY inside the mutex so
2077
+ // the pending-write tracker observes it before the
2078
+ // mutex releases. The promise is awaited off-lock in
2079
+ // the outer finally block.
2080
+ const record = buildResponseRecord(handlerOutcome.terminalToPersist, newInputMessages, previousResponseId, currentInstanceId, effectiveRetentionSec);
2081
+ // Pair a `retainBinding` against the persist promise
2082
+ // so the binding's `modelInstanceId` survives a
2083
+ // concurrent same-model unregister + re-register that
2084
+ // races the post-commit write. `releaseBinding` runs
2085
+ // in the persist's `.finally(...)` regardless of
2086
+ // outcome, so the retention counter stays balanced
2087
+ // whether the write fulfils or rejects.
2088
+ //
2089
+ // Leaving the retain pinned forever on a wedged write
2090
+ // would make the binding unreclaimable until process
2091
+ // restart, so an INDEPENDENT hard-timeout timer is
2092
+ // armed alongside the persist (see
2093
+ // `getPostCommitPersistHardTimeoutMs` for the default).
2094
+ // If the persist settles naturally the timer is
2095
+ // cancelled via `clearTimeout` inside the same
2096
+ // `.finally(...)` — slow-but-eventual writes are
2097
+ // unaffected. If the persist is still wedged past the
2098
+ // hard bound, the timer fires and force-releases the
2099
+ // retain via the idempotent `persistRetainBox`. The
2100
+ // hard timer is armed off the handler's await path, so
2101
+ // the response is never delayed by it.
2102
+ //
2103
+ // Before the hard timeout force-releases the retain
2104
+ // (which unblocks binding teardown), it calls
2105
+ // `registry.retireInstanceIdForForceRelease(leaseModel)`
2106
+ // to tombstone the binding's current instance id on
2107
+ // the model object. A subsequent `register()` of the
2108
+ // SAME model object inherits that retired id rather
2109
+ // than minting fresh — so the late-landing persist's
2110
+ // record (stamped with the retired id) still matches
2111
+ // the live binding and stays chainable through
2112
+ // `previous_response_id`. Only a true hot-swap
2113
+ // (re-register with a DIFFERENT model object) mints a
2114
+ // fresh id, and the 400 instance-mismatch that results
2115
+ // is the correct semantic outcome because the new
2116
+ // model is semantically different from the one that
2117
+ // produced the stored record. Retirement MUST happen
2118
+ // BEFORE release so `instanceIds.get(model)` still
2119
+ // returns the live id the record carries.
2120
+ //
2121
+ // The tombstone's lifetime is scoped to the pending
2122
+ // persists that installed it — the `.finally(...)`
2123
+ // calls `registry.releaseTombstone(leaseModel)` so
2124
+ // that when the late write eventually settles
2125
+ // (fulfills or rejects), the shared refcount drops
2126
+ // and, once every outstanding persist has released,
2127
+ // any subsequent re-registration correctly mints a
2128
+ // fresh id. Without this scoping, a past hard-timeout
2129
+ // event would permanently re-enable id inheritance
2130
+ // across unrelated later lifecycles — reopening
2131
+ // stale-chain replay across what should be logically
2132
+ // dead bindings. The refcounted single-entry layout
2133
+ // handles OVERLAPPING hard-timeouts on the same live
2134
+ // instance id in bounded space: every breaker targets
2135
+ // the SAME retired id (the register-inherit path
2136
+ // keeps using it while the tombstone is alive) so one
2137
+ // shared refcount safely collapses every in-flight
2138
+ // retire, and memory stays O(1) per model even under
2139
+ // a truly wedged store that never settles.
2140
+ registry.retainBinding(leaseModel);
2141
+ let persistRetainReleased = false;
2142
+ persistRetainBox.release = () => {
2143
+ if (persistRetainReleased)
2144
+ return;
2145
+ persistRetainReleased = true;
2146
+ registry.releaseBinding(leaseModel);
2147
+ };
2148
+ const streamingPersistMode = 'streaming';
2149
+ const streamingHardTimeoutMs = getPostCommitPersistHardTimeoutMs();
2150
+ let retiredTombstone;
2151
+ // Compute the scalar `absoluteExpiresAtMs` ONCE up
2152
+ // front — the MINIMUM of the newly produced record's
2153
+ // own row expiry and the earliest expiry across any
2154
+ // resolved ancestor chain. This value is threaded
2155
+ // into both the pending-write tracker at
2156
+ // `initiatePersist()` time (so the pre-breaker
2157
+ // `awaitPending` path can short-circuit to 404 once
2158
+ // the bound is crossed) AND the hard-timeout marker
2159
+ // at breaker-fire time (absolute cap). The
2160
+ // hard-timeout closure captures ONLY this scalar —
2161
+ // NOT the full resolved chain — so the closure's
2162
+ // retained heap stays O(1) under sustained pending
2163
+ // continuations against a degraded backend.
2164
+ //
2165
+ // `record.expiresAt` is epoch-seconds (see
2166
+ // `buildResponseRecord` — it adds
2167
+ // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now() /
2168
+ // 1000)`); convert to ms at this boundary. If both
2169
+ // the record and the chain lack a finite expiry
2170
+ // (legacy rows), fall back to
2171
+ // `Number.POSITIVE_INFINITY` at the marker call site
2172
+ // so TTL-only bounding still holds.
2173
+ const recordExpiresAtMs = record.expiresAt != null && Number.isFinite(record.expiresAt) ? record.expiresAt * 1000 : undefined;
2174
+ const absoluteExpiresAtMs = recordExpiresAtMs !== undefined && chainEarliestExpiresAtMs !== undefined
2175
+ ? Math.min(recordExpiresAtMs, chainEarliestExpiresAtMs)
2176
+ : (recordExpiresAtMs ?? chainEarliestExpiresAtMs);
2177
+ const streamingHardTimeoutHandle = streamingHardTimeoutMs > 0
2178
+ ? setTimeout(() => {
2179
+ if (persistRetainReleased)
2180
+ return;
2181
+ console.error(`[responses] post-commit persist HARD timeout (${streamingHardTimeoutMs}ms, ` +
2182
+ `${streamingPersistMode}): underlying store.store(...) has not settled; assuming ` +
2183
+ `wedged backend, force-releasing the binding retain so the binding can be torn ` +
2184
+ `down. Retiring the current instance id via tombstone so a same-object ` +
2185
+ `re-registration inherits it and a late-landing persist remains chainable; a ` +
2186
+ `hot-swap to a DIFFERENT model object will mint a fresh id and the stale chain ` +
2187
+ `will correctly fail with 400 instance-mismatch.`);
2188
+ // Move the pending-write tracker entry into
2189
+ // the hard-timed-out marker state for this
2190
+ // response id. The pending entry is dropped
2191
+ // so a wedged store.store(...) does not pin
2192
+ // one promise closure + tracker entry per
2193
+ // hard-timed-out request, AND the id is added
2194
+ // to the `hardTimedOut` marker so a concurrent
2195
+ // `previous_response_id` continuation can
2196
+ // tell the difference between a permanent
2197
+ // 404 and a slow-but-eventual persist that
2198
+ // crossed the hard timeout. The continuation
2199
+ // path consults `isHardTimedOut(id)` before
2200
+ // falling through to `sendNotFound(...)` and
2201
+ // returns retryable 503 `storage_timeout`
2202
+ // instead, so clients keep retrying rather
2203
+ // than discarding the chain. The marker has
2204
+ // two cleanup paths: (1) fast — the underlying
2205
+ // store promise's `.finally(...)` inside
2206
+ // `track()` fires when the wedged store
2207
+ // unwedges; (2) slow — an independent TTL
2208
+ // (`MLX_HARD_TIMEOUT_MARKER_TTL_MS`, default
2209
+ // 300s) bounds memory at O(requestRate × TTL)
2210
+ // even against a truly wedged store that
2211
+ // NEVER settles. Marker lifetime =
2212
+ // min(settlement, TTL expiry).
2213
+ //
2214
+ // Pass the record's absolute row expiry as a
2215
+ // hard cap on the marker. The record's
2216
+ // `expiresAt` field is epoch-seconds (see
2217
+ // `buildResponseRecord` — it adds
2218
+ // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now()
2219
+ // / 1000)`), so convert to ms for the marker
2220
+ // map. Once the absolute bound passes,
2221
+ // `ResponseStore.getChain()` hides the row and
2222
+ // the retryable-503 classification is factually
2223
+ // wrong — the marker must flip to 404 regardless
2224
+ // of ongoing client retries.
2225
+ //
2226
+ // Capture ONLY the precomputed scalar
2227
+ // `absoluteExpiresAtMs` in this closure — NOT
2228
+ // the full resolved chain. The scalar is
2229
+ // `min(record.expiresAt * 1000,
2230
+ // chainEarliestExpiresAtMs)`, computed once
2231
+ // when the hard-timeout handle was armed
2232
+ // above. `ResponseStore.getChain()` walks
2233
+ // ancestors and aborts on the first expired
2234
+ // link (see
2235
+ // `crates/mlx-db/src/response_store/reader.rs:44-59`),
2236
+ // so clamping the marker at whichever link
2237
+ // would disappear from `getChain()` first is
2238
+ // the authoritative bound. Capturing only
2239
+ // the scalar means background pending
2240
+ // continuations under a degraded store do
2241
+ // not retain ancestor transcripts —
2242
+ // heap growth stays O(1) per hard-timed-out
2243
+ // persist regardless of chain length.
2244
+ getPendingWritesFor(store).markHardTimedOut(record.id, getHardTimedOutMarkerTtlMs(), absoluteExpiresAtMs ?? Number.POSITIVE_INFINITY);
2245
+ // Retire the id FIRST (binding is still alive
2246
+ // here — retirement reads the live id) then
2247
+ // drop the retain, which may trigger the
2248
+ // deferred teardown. Capture the retired id so
2249
+ // the persist's `.finally(...)` can release
2250
+ // the tombstone once the late write eventually
2251
+ // settles. The registry stores one refcounted
2252
+ // tombstone per model regardless of how many
2253
+ // hard-timeouts overlap — each retire
2254
+ // increments the shared counter and each
2255
+ // release decrements it — so the returned
2256
+ // `{ instanceId }` is captured as a presence
2257
+ // flag and `releaseTombstone(leaseModel)` is
2258
+ // called in the persist's `.finally(...)`.
2259
+ retiredTombstone = registry.retireInstanceIdForForceRelease(leaseModel);
2260
+ persistRetainBox.release?.();
2261
+ }, streamingHardTimeoutMs)
2262
+ : null;
2263
+ pendingPersistOuter = initiatePersist(store, record, absoluteExpiresAtMs).finally(() => {
2264
+ if (streamingHardTimeoutHandle !== null) {
2265
+ clearTimeout(streamingHardTimeoutHandle);
2266
+ }
2267
+ // If the hard-timeout breaker fired and installed a
2268
+ // tombstone on `leaseModel`, decrement the shared
2269
+ // refcount now that this persist has settled. The
2270
+ // single-entry refcount layout means overlapping
2271
+ // breakers share one slot — releasing one balances
2272
+ // one retire, and the entry survives until the
2273
+ // last outstanding persist releases.
2274
+ if (retiredTombstone !== undefined) {
2275
+ registry.releaseTombstone(leaseModel);
2276
+ }
2277
+ persistRetainBox.release?.();
2278
+ });
2279
+ persistMode = streamingPersistMode;
2280
+ }
2281
+ }
2282
+ catch (err) {
2283
+ handlerError = err instanceof Error ? err : new Error(String(err));
2284
+ }
2285
+ committed = streamingWasCommitted();
2286
+ }
2287
+ else {
2288
+ // The non-streaming native path has NO AbortSignal surface
2289
+ // (plain `chatSession*` returns a Promise, no cancel), so a
2290
+ // client that disconnects mid-generation still burns the
2291
+ // full decode budget under this mutex. TODO: native
2292
+ // cancellation for `chatSession*` — until then the best we
2293
+ // can do is the disconnect-aware skip inside
2294
+ // `handleNonStreaming` (short-circuits `endJson` and
2295
+ // signals the outer persist gate) plus this documented
2296
+ // limitation.
2297
+ const outcome = await runSessionNonStreaming(session, messages, newInputMessages, config);
2298
+ try {
2299
+ const handlerOutcome = await handleNonStreaming(res, outcome.result, mappedBody, responseId, previousResponseId, visibility);
2300
+ if (store && body.store !== false) {
2301
+ // Same in-lock-initiate / off-lock-await split as the
2302
+ // streaming branch. The non-streaming handler only
2303
+ // returns when the JSON body's `res.end()` callback
2304
+ // has fired, so reaching this point means the client
2305
+ // observed the turn — the pending-write tracker
2306
+ // protects a back-to-back continuation from a
2307
+ // transient 404.
2308
+ const record = buildResponseRecord(handlerOutcome.response, newInputMessages, previousResponseId, currentInstanceId, effectiveRetentionSec);
2309
+ // See the streaming branch for the retain/release
2310
+ // rationale — a same-model unregister + re-register
2311
+ // during the slow persist must not mint a fresh
2312
+ // `modelInstanceId` that invalidates the row this
2313
+ // write is about to land. The idempotent-release
2314
+ // scaffolding is a structural hook for a future split
2315
+ // teardown; the post-commit SOFT timeout arm does not
2316
+ // force-fire it.
2317
+ //
2318
+ // A wedged persist would otherwise leak the binding
2319
+ // retain for the lifetime of the process, so the
2320
+ // hard-timeout timer is armed here in the same shape
2321
+ // as the streaming branch, cancelled from the
2322
+ // persist's own `.finally(...)` when the write settles
2323
+ // naturally, and fires a force-release through the
2324
+ // idempotent `persistRetainBox` otherwise. Default
2325
+ // 60s, override via
2326
+ // `MLX_POST_COMMIT_PERSIST_HARD_TIMEOUT_MS`, `'0'`
2327
+ // disables. Empty string is treated as unset (falls
2328
+ // back to the 60000ms default) so a config-templating
2329
+ // typo cannot silently disable the breaker.
2330
+ //
2331
+ // The force-release path also calls
2332
+ // `registry.retireInstanceIdForForceRelease(leaseModel)`
2333
+ // BEFORE releasing the retain so a same-object
2334
+ // re-registration AFTER teardown inherits the retired
2335
+ // instance id from the tombstone — a late-landing
2336
+ // persist against the retired id stays chainable. A
2337
+ // hot-swap to a DIFFERENT model object mints a fresh
2338
+ // id and the 400 instance-mismatch is correct.
2339
+ //
2340
+ // The tombstone's lifetime is scoped to the pending
2341
+ // persists that installed it — the `.finally(...)`
2342
+ // calls `registry.releaseTombstone(leaseModel)` so
2343
+ // that when the late write eventually settles, the
2344
+ // shared refcount drops and, once every outstanding
2345
+ // persist has released, any subsequent
2346
+ // re-registration correctly mints a fresh id. Without
2347
+ // this scoping, a past hard-timeout event would
2348
+ // permanently re-enable id inheritance across
2349
+ // unrelated later lifecycles — reopening stale-chain
2350
+ // replay across what should be logically dead
2351
+ // bindings. The refcounted single-entry layout
2352
+ // handles OVERLAPPING hard-timeouts on the same live
2353
+ // instance id in bounded space: every breaker targets
2354
+ // the SAME retired id (the register-inherit path
2355
+ // keeps using it while the tombstone is alive) so one
2356
+ // shared refcount safely collapses every in-flight
2357
+ // retire, and memory stays O(1) per model even under
2358
+ // a truly wedged store that never settles.
2359
+ registry.retainBinding(leaseModel);
2360
+ let persistRetainReleased = false;
2361
+ persistRetainBox.release = () => {
2362
+ if (persistRetainReleased)
2363
+ return;
2364
+ persistRetainReleased = true;
2365
+ registry.releaseBinding(leaseModel);
2366
+ };
2367
+ const nonStreamingPersistMode = 'non-streaming';
2368
+ const nonStreamingHardTimeoutMs = getPostCommitPersistHardTimeoutMs();
2369
+ let retiredTombstone;
2370
+ // See the matching streaming-path comment above —
2371
+ // precompute the scalar `absoluteExpiresAtMs`
2372
+ // (`min(record.expiresAt * 1000,
2373
+ // chainEarliestExpiresAtMs)`) ONCE, thread it into
2374
+ // the tracker at `initiatePersist()` time, and capture
2375
+ // ONLY this scalar in the hard-timeout closure.
2376
+ const recordExpiresAtMs = record.expiresAt != null && Number.isFinite(record.expiresAt) ? record.expiresAt * 1000 : undefined;
2377
+ const absoluteExpiresAtMs = recordExpiresAtMs !== undefined && chainEarliestExpiresAtMs !== undefined
2378
+ ? Math.min(recordExpiresAtMs, chainEarliestExpiresAtMs)
2379
+ : (recordExpiresAtMs ?? chainEarliestExpiresAtMs);
2380
+ const nonStreamingHardTimeoutHandle = nonStreamingHardTimeoutMs > 0
2381
+ ? setTimeout(() => {
2382
+ if (persistRetainReleased)
2383
+ return;
2384
+ console.error(`[responses] post-commit persist HARD timeout (${nonStreamingHardTimeoutMs}ms, ` +
2385
+ `${nonStreamingPersistMode}): underlying store.store(...) has not settled; ` +
2386
+ `assuming wedged backend, force-releasing the binding retain so the binding can ` +
2387
+ `be torn down. Retiring the current instance id via tombstone so a same-object ` +
2388
+ `re-registration inherits it and a late-landing persist remains chainable; a ` +
2389
+ `hot-swap to a DIFFERENT model object will mint a fresh id and the stale chain ` +
2390
+ `will correctly fail with 400 instance-mismatch.`);
2391
+ // Move the pending-write tracker entry into
2392
+ // the hard-timed-out marker state for this
2393
+ // response id. The pending entry is dropped
2394
+ // so a wedged store.store(...) does not pin
2395
+ // one promise closure + tracker entry per
2396
+ // hard-timed-out request, AND the id is added
2397
+ // to the `hardTimedOut` marker so a concurrent
2398
+ // `previous_response_id` continuation can
2399
+ // tell the difference between a permanent
2400
+ // 404 and a slow-but-eventual persist that
2401
+ // crossed the hard timeout. The continuation
2402
+ // path consults `isHardTimedOut(id)` before
2403
+ // falling through to `sendNotFound(...)` and
2404
+ // returns retryable 503 `storage_timeout`
2405
+ // instead, so clients keep retrying rather
2406
+ // than discarding the chain. The marker has
2407
+ // two cleanup paths: (1) fast — the
2408
+ // underlying store promise's `.finally(...)`
2409
+ // inside `track()` fires when the wedged
2410
+ // store unwedges; (2) slow — an independent
2411
+ // TTL (`MLX_HARD_TIMEOUT_MARKER_TTL_MS`,
2412
+ // default 300s) bounds memory at
2413
+ // O(requestRate × TTL) even against a truly
2414
+ // wedged store that NEVER settles. Marker
2415
+ // lifetime = min(settlement, TTL expiry).
2416
+ //
2417
+ // Pass the record's absolute row expiry as a
2418
+ // hard cap on the marker. The record's
2419
+ // `expiresAt` field is epoch-seconds (see
2420
+ // `buildResponseRecord` — it adds
2421
+ // `RESPONSE_TTL_SECONDS` to `Math.floor(Date.now()
2422
+ // / 1000)`), so convert to ms for the marker
2423
+ // map. Once the absolute bound passes,
2424
+ // `ResponseStore.getChain()` hides the row and
2425
+ // the retryable-503 classification is factually
2426
+ // wrong — the marker must flip to 404 regardless
2427
+ // of ongoing client retries.
2428
+ //
2429
+ // Capture ONLY the precomputed scalar
2430
+ // `absoluteExpiresAtMs` in this closure — see
2431
+ // the matching streaming-path comment for the
2432
+ // full rationale. The scalar was computed
2433
+ // above when the hard-timeout handle was
2434
+ // armed.
2435
+ getPendingWritesFor(store).markHardTimedOut(record.id, getHardTimedOutMarkerTtlMs(), absoluteExpiresAtMs ?? Number.POSITIVE_INFINITY);
2436
+ // Retire the id FIRST (binding is still alive
2437
+ // here — retirement reads the live id) then
2438
+ // drop the retain, which may trigger the
2439
+ // deferred teardown. Capture the retired id so
2440
+ // the persist's `.finally(...)` can release
2441
+ // the tombstone once the late write eventually
2442
+ // settles. The registry stores one refcounted
2443
+ // tombstone per model regardless of how many
2444
+ // hard-timeouts overlap — each retire
2445
+ // increments the shared counter and each
2446
+ // release decrements it — so the returned
2447
+ // `{ instanceId }` is captured as a presence
2448
+ // flag and `releaseTombstone(leaseModel)` is
2449
+ // called in the persist's `.finally(...)`.
2450
+ retiredTombstone = registry.retireInstanceIdForForceRelease(leaseModel);
2451
+ persistRetainBox.release?.();
2452
+ }, nonStreamingHardTimeoutMs)
2453
+ : null;
2454
+ pendingPersistOuter = initiatePersist(store, record, absoluteExpiresAtMs).finally(() => {
2455
+ if (nonStreamingHardTimeoutHandle !== null) {
2456
+ clearTimeout(nonStreamingHardTimeoutHandle);
2457
+ }
2458
+ // If the hard-timeout breaker fired and installed a
2459
+ // tombstone on `leaseModel`, decrement the shared
2460
+ // refcount now that this persist has settled. The
2461
+ // single-entry refcount layout means overlapping
2462
+ // breakers share one slot — releasing one balances
2463
+ // one retire, and the entry survives until the
2464
+ // last outstanding persist releases.
2465
+ if (retiredTombstone !== undefined) {
2466
+ registry.releaseTombstone(leaseModel);
2467
+ }
2468
+ persistRetainBox.release?.();
2469
+ });
2470
+ persistMode = nonStreamingPersistMode;
2471
+ }
2472
+ }
2473
+ catch (err) {
2474
+ handlerError = err instanceof Error ? err : new Error(String(err));
2475
+ }
2476
+ committed = outcome.committed;
2477
+ }
2478
+ // "Safe to suppress" collapses to: did the client observe a
2479
+ // terminal artefact for this responseId? On the non-
2480
+ // streaming path that is the JSON body landing cleanly on
2481
+ // the wire; on the streaming path it is a terminal SSE
2482
+ // event (`response.completed` or `response.failed`) landing
2483
+ // cleanly on the wire. In either case the client can see
2484
+ // the responseId and knows the turn is over, so adopting
2485
+ // the committed session under that id is safe and
2486
+ // swallowing the (already-surfaced-via-failed-event)
2487
+ // handler error is the only option that does not produce a
2488
+ // malformed double-response.
2489
+ const safeToSuppress = visibility.responseBodyWritten || visibility.terminalEmitted;
2490
+ if (previousResponseId) {
2491
+ sessionReg.drop(previousResponseId);
2492
+ }
2493
+ // Only adopt if the turn committed AND either the handler
2494
+ // succeeded or a terminal artefact is already on the wire.
2495
+ // A committed turn whose handler threw before the client
2496
+ // saw anything it can chain off of must NOT be adopted —
2497
+ // the responseId is unreachable from the client, so caching
2498
+ // the session under it creates a permanently dangling warm
2499
+ // session.
2500
+ //
2501
+ // Refuse to adopt whenever the streaming handler took ANY
2502
+ // failure epilogue, not just `client_abort`. The streaming
2503
+ // handler writes `failureMode` for every path that does
2504
+ // not produce a clean `response.completed`:
2505
+ //
2506
+ // * `'client_abort'` — client dropped the socket after
2507
+ // the decode loop committed but before the success
2508
+ // terminal was flushed; `response.failed` goes on the
2509
+ // wire under a responseId the client has abandoned.
2510
+ //
2511
+ // * `'error'` — post-final teardown threw in
2512
+ // the stream adapter's `finally` after the decode
2513
+ // loop had already committed; `terminalToPersist` is
2514
+ // null and the client saw `response.failed`, so the
2515
+ // responseId is not a chainable artefact from the
2516
+ // client's perspective.
2517
+ //
2518
+ // * `'finish_reason_error'` / `'stream_exhausted'` —
2519
+ // terminal derived from a non-clean end of stream.
2520
+ // Same reasoning: `response.failed` on the wire, no
2521
+ // chainable success terminal.
2522
+ //
2523
+ // In every non-null `failureMode` case the session
2524
+ // committed at the native level but the observable wire
2525
+ // state is a failure, so adopting the session under the
2526
+ // responseId would evict the last good hot session for
2527
+ // this model under the single-warm invariant even
2528
+ // though the adopted slot is unreachable.
2529
+ //
2530
+ // `failureMode === null` is the sole signal that the
2531
+ // stream path completed cleanly and the adopted session
2532
+ // is genuinely reachable via the responseId.
2533
+ if (committed && (handlerError == null || safeToSuppress) && streamFailureMode === null) {
2534
+ sessionReg.adopt(responseId, session, requestedInstructions);
2535
+ }
2536
+ // Rethrow handler errors when the client hasn't seen a
2537
+ // terminal yet, regardless of commit state. The outer
2538
+ // catch will send a proper 500 (non-streaming) or a last-
2539
+ // ditch SSE `error` event (streaming, after `beginSSE` but
2540
+ // before any terminal). Without this the request would
2541
+ // hang from the client's perspective.
2542
+ if (handlerError && !safeToSuppress) {
2543
+ throw handlerError;
2544
+ }
2545
+ // If a terminal is on the wire but the handler still
2546
+ // threw: log only. Rethrowing would produce a malformed
2547
+ // double-response; the client already has a terminal event
2548
+ // it can parse.
2549
+ if (handlerError) {
2550
+ console.error('[responses] handler error after terminal response already delivered:', handlerError);
2551
+ }
2552
+ }
2553
+ catch (err) {
2554
+ const message = err instanceof Error ? err.message : 'Unknown error during inference';
2555
+ // Branch on `responseMode` (the wire format the handler
2556
+ // committed to), NOT `res.headersSent`
2557
+ // (which flips synchronously in `writeHead` and lies about
2558
+ // which format the client is consuming). Each branch
2559
+ // produces output that matches the Content-Type the client
2560
+ // already received — or no output at all if the terminal
2561
+ // already landed.
2562
+ if (visibility.responseMode === null) {
2563
+ // Headers never went out. Safe to emit a clean 500 JSON
2564
+ // error.
2565
+ sendInternalError(res, message);
2566
+ }
2567
+ else if (visibility.responseMode === 'json') {
2568
+ // We already wrote `Content-Type: application/json` and
2569
+ // possibly some body bytes; emitting an SSE frame here
2570
+ // would corrupt the response. Best we can do is destroy
2571
+ // the socket so the client sees a truncated JSON
2572
+ // response instead of a malformed document with an
2573
+ // unexpected MIME type. If the body was fully written
2574
+ // (`responseBodyWritten === true`) the outcome gate
2575
+ // above already returned without rethrowing, so reaching
2576
+ // this branch means the JSON never fully landed.
2577
+ try {
2578
+ res.destroy(err instanceof Error ? err : new Error(message));
2579
+ }
2580
+ catch {
2581
+ // Socket may already be gone; nothing more we can do.
2582
+ }
2583
+ }
2584
+ else {
2585
+ // `responseMode === 'sse'`: headers advertise SSE and
2586
+ // some (or all) of the stream already went out. If a
2587
+ // terminal event already landed, emitting another frame
2588
+ // is a no-op from the client's perspective but we still
2589
+ // close the stream cleanly. If no terminal landed (early
2590
+ // `writeSSEEvent` crash before `response.created`), emit
2591
+ // a best-effort streaming `error` frame so the client
2592
+ // sees SOMETHING it can parse.
2593
+ if (!visibility.terminalEmitted) {
2594
+ writeFallbackErrorSSE(res, 'error', { error_type: 'server_error', message });
2595
+ }
2596
+ try {
2597
+ endSSE(res);
2598
+ }
2599
+ catch {
2600
+ // Already closed / destroyed.
2601
+ }
2602
+ }
2603
+ }
2604
+ });
2605
+ }
2606
+ catch (err) {
2607
+ // Admission-control rejection from the per-model queue cap
2608
+ // (`SessionRegistry.withExclusive` threw before chaining into
2609
+ // the FIFO). Emit HTTP 429 so clients back off instead of
2610
+ // silently piling up more waiters. Post-dispatch cleanup below
2611
+ // still runs via the idempotent `finally` — abort listeners
2612
+ // were never fully armed for a never-dispatched request, and
2613
+ // the dispatch lease MUST be released exactly once against the
2614
+ // originally captured `leaseModel`.
2615
+ //
2616
+ // Any other error continues to propagate up to the handler's
2617
+ // outer try/catch so existing failure-epilogue behaviour is
2618
+ // preserved untouched.
2619
+ if (err instanceof QueueFullError) {
2620
+ if (!res.headersSent) {
2621
+ sendRateLimit(res, `Model queue full: ${err.queuedCount} waiting (limit ${err.limit}). Retry after 1s.`);
2622
+ }
2623
+ }
2624
+ else {
2625
+ throw err;
2626
+ }
2627
+ }
2628
+ // RELEASE the dispatch lease and DETACH the abort listeners
2629
+ // IMMEDIATELY now that `withExclusive` returned
2630
+ // and the terminal bytes have either been flushed or the outer
2631
+ // catch has emitted its error frame. The post-commit persist
2632
+ // wait that follows must NOT pin the request's lifecycle — a
2633
+ // wedged `store.store(...)` would otherwise leak socket/abort
2634
+ // listeners, keep the binding's `inFlight` counter elevated,
2635
+ // and block teardown after a hot-swap for the lifetime of the
2636
+ // wedged write.
2637
+ //
2638
+ // The binding's `modelInstanceId` still needs to survive until
2639
+ // the post-commit write has actually landed — otherwise a
2640
+ // same-model unregister + re-register sequence during a slow
2641
+ // persist would mint a fresh id, and the row (when it finally
2642
+ // lands) would reference a dead id that the very next
2643
+ // `previous_response_id` continuation would reject. That
2644
+ // lifetime is covered by the ORTHOGONAL `retainBinding` /
2645
+ // `releaseBinding` retention counter paired around
2646
+ // `initiatePersist` below, so the eager dispatch-lease release
2647
+ // here stays lossless.
2648
+ //
2649
+ // The outer `finally` below re-runs both cleanups idempotently
2650
+ // so an early-return validation failure (before the
2651
+ // `withExclusive` site) still cleans up; `cleanupPerformed` is
2652
+ // the guard.
2653
+ cleanupPerformed = runPostDispatchCleanup();
2654
+ // The persist write was INITIATED synchronously inside
2655
+ // `withExclusive` via `initiatePersist` — which registers the
2656
+ // in-flight promise in the per-store pending-write tracker
2657
+ // BEFORE the mutex releases. The SQLite flush is already on
2658
+ // its way; a back-to-back continuation observing the tracker
2659
+ // will block on the same promise instead of spuriously
2660
+ // returning 404 under `getChain` (see the `getChain`-empty
2661
+ // retry at the top of this handler).
2662
+ //
2663
+ // BOUND the wait on the persist promise with
2664
+ // `POST_COMMIT_PERSIST_TIMEOUT_MS`. A wedged native backend
2665
+ // can return a promise that never settles, and an
2666
+ // unconditional `await` would pin this handler forever —
2667
+ // leaking abort listeners and the dispatch lease (handled
2668
+ // above by running cleanup before this wait). On timeout we
2669
+ // leave the promise running in the background: the
2670
+ // pending-writes tracker still holds its reference so chained
2671
+ // continuations can still observe it, and its `.finally(...)`
2672
+ // handler will clear the tracker entry whenever the write
2673
+ // eventually settles (or stays wedged until the process exits).
2674
+ //
2675
+ // Persistence is best-effort — a failed write demotes to a
2676
+ // log line. The pending-write tracker's `.finally(...)`
2677
+ // handler removes the entry regardless of fulfill / reject,
2678
+ // so a rejected write correctly leaves the store empty AND
2679
+ // clears the tracker, and a subsequent `getChain()` then
2680
+ // returns empty legitimately. A `.catch(...)` is attached
2681
+ // synchronously so an eventual rejection from the
2682
+ // backgrounded promise does not trigger an
2683
+ // unhandled-rejection diagnostic after this handler returns.
2684
+ if (pendingPersistOuter != null) {
2685
+ // The local narrowed reference convinces the type-aware
2686
+ // lint that we're awaiting a real Promise; assigning
2687
+ // through `let` loses that narrowing because the closure
2688
+ // above could (in principle) reassign it.
2689
+ const promise = pendingPersistOuter;
2690
+ // Attach terminal error handling FIRST. The tracker's own
2691
+ // `.finally(...)` is already attached and surfaces nothing
2692
+ // to Node's unhandled-rejection detector; this catch arm
2693
+ // logs the rejection and suppresses it locally so the
2694
+ // raced-against `Promise.race` sees a plain fulfillment
2695
+ // (`'settled' | 'timeout'`) rather than a rejection that
2696
+ // would otherwise require per-branch handling below.
2697
+ const capturedMode = persistMode;
2698
+ const settled = promise
2699
+ .then(() => 'settled')
2700
+ .catch((err) => {
2701
+ console.error(`[responses] post-commit persistence failed (${capturedMode ?? 'unknown'}, off-lock):`, err);
2702
+ return 'settled';
2703
+ });
2704
+ const postCommitPersistTimeoutMs = getPostCommitPersistTimeoutMs();
2705
+ let timeoutHandle;
2706
+ const timeoutPromise = new Promise((resolve) => {
2707
+ timeoutHandle = setTimeout(() => {
2708
+ resolve('timeout');
2709
+ }, postCommitPersistTimeoutMs);
2710
+ });
2711
+ try {
2712
+ const outcome = await Promise.race([settled, timeoutPromise]);
2713
+ if (outcome === 'timeout') {
2714
+ console.warn(`[responses] post-commit persistence did not settle within ${postCommitPersistTimeoutMs}ms ` +
2715
+ `(${capturedMode ?? 'unknown'}, off-lock); detaching the handler and leaving the write in the ` +
2716
+ `background. The pending-writes tracker still holds a reference so chained continuations can ` +
2717
+ `observe the in-flight write, and the binding retain stays live until the write truly ` +
2718
+ `settles so the binding's modelInstanceId cannot be recycled under the late write. This ` +
2719
+ `condition usually signals a wedged SQLite writer or stuck native backend.`);
2720
+ // Do NOT force-release the `retainBinding` here on the
2721
+ // soft timeout. `Promise.race` treats any write that
2722
+ // EXCEEDS the timeout as "safe to unpin", but most
2723
+ // timeouts in practice are slow-but-eventual writes —
2724
+ // the promise still fulfils later, and the retain
2725
+ // invariant has to hold for the entire interval until
2726
+ // it does. If a same-object unregister + re-register
2727
+ // happens in the window between timeout and actual
2728
+ // settlement, force-releasing the retain lets
2729
+ // `pendingPersists` drop to 0, the binding fully tears
2730
+ // down, the re-register mints a fresh
2731
+ // `modelInstanceId`, and the late write lands with the
2732
+ // stale id that `buildResponseRecord` stamped into
2733
+ // `configJson` — exactly the chain-break the retain
2734
+ // was introduced to prevent.
2735
+ //
2736
+ // We accept the bounded cost of a TRULY wedged persist
2737
+ // leaking one binding (counters + registry reference)
2738
+ // until process exit. A wedged SQLite writer already
2739
+ // means the server is compromised, and one lingering
2740
+ // binding is much smaller than a user-visible 400
2741
+ // instance-mismatch on the next continuation. The
2742
+ // idempotent `release` stays wired from the persist's
2743
+ // own `.finally(...)`, so the moment the slow write
2744
+ // actually settles — even minutes later — the retain
2745
+ // drops and teardown proceeds normally. The
2746
+ // independent hard-timeout breaker (armed at
2747
+ // `initiatePersist` time) bounds the truly-wedged case
2748
+ // via tombstoned id retirement.
2749
+ //
2750
+ // The pending-writes tracker keeps its own reference
2751
+ // to the detached promise, so chained continuations
2752
+ // can still observe the in-flight write via the
2753
+ // cold-replay path.
2754
+ }
2755
+ }
2756
+ finally {
2757
+ if (timeoutHandle !== undefined) {
2758
+ clearTimeout(timeoutHandle);
2759
+ }
2760
+ }
2761
+ }
2762
+ }
2763
+ finally {
2764
+ // Idempotent fallback: if the post-dispatch cleanup above
2765
+ // never ran (early-return validation failure, or an exception
2766
+ // raised inside the outer `try` block between lease
2767
+ // acquisition and the `withExclusive` call), make sure the
2768
+ // abort listeners are detached and the dispatch lease is
2769
+ // released here. `runPostDispatchCleanup` is safe to re-invoke
2770
+ // — the `abortListenersAttached` check and
2771
+ // `releaseDispatchLease`'s `inFlight < 0` floor make it a
2772
+ // no-op when the happy-path already fired it.
2773
+ if (!cleanupPerformed) {
2774
+ runPostDispatchCleanup();
2775
+ }
2776
+ }
2777
+ function runPostDispatchCleanup() {
2778
+ // Drop the AbortController's socket/request listeners so they
2779
+ // do not keep the request object alive past
2780
+ // the handler's return. Only detach when listeners were actually
2781
+ // installed — early-return validation failures exit the outer
2782
+ // try before the installation site, so an unconditional detach
2783
+ // would pull listeners that were never attached.
2784
+ if (abortListenersAttached) {
2785
+ res.removeListener('close', onAbortClose);
2786
+ res.removeListener('error', onAbortError);
2787
+ if (abortSocket != null) {
2788
+ abortSocket.removeListener('close', onAbortClose);
2789
+ }
2790
+ if (httpReq) {
2791
+ httpReq.removeListener('close', onAbortClose);
2792
+ httpReq.removeListener('error', onAbortError);
2793
+ }
2794
+ abortListenersAttached = false;
2795
+ }
2796
+ // Release the dispatch lease on the ORIGINAL model object the
2797
+ // lease was acquired against (not a re-read of `body.model`,
2798
+ // which may have been hot-swapped while we held the mutex). A
2799
+ // pending teardown — `unregister()` called concurrently while
2800
+ // this dispatch held the lease — finalises here once the
2801
+ // in-flight counter drops to zero AND the post-commit persist
2802
+ // retention has also released (see `retainBinding` below).
2803
+ //
2804
+ // This runs BEFORE the post-commit persist wait, not after, so
2805
+ // a wedged `store.store(...)` no longer pins the lease.
2806
+ // Teardown of a same-model unregister is still deferred by the
2807
+ // `retainBinding` counter so the binding's `modelInstanceId`
2808
+ // survives until the pending write has stamped its row
2809
+ // durably — see `initiatePersist`.
2810
+ if (!leaseReleased) {
2811
+ leaseReleased = true;
2812
+ registry.releaseDispatchLease(leaseModel);
2813
+ }
2814
+ return true;
2815
+ }
2816
+ }