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