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