@mlx-node/server 0.0.7 → 0.0.9
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/auth.d.ts +56 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +106 -0
- package/dist/chat-session-warm-reuse.d.ts +51 -0
- package/dist/chat-session-warm-reuse.d.ts.map +1 -0
- package/dist/chat-session-warm-reuse.js +72 -0
- package/dist/endpoints/messages-count-tokens.d.ts +8 -0
- package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
- package/dist/endpoints/messages-count-tokens.js +121 -0
- package/dist/endpoints/messages.d.ts +57 -5
- package/dist/endpoints/messages.d.ts.map +1 -1
- package/dist/endpoints/messages.js +1043 -147
- package/dist/endpoints/models.d.ts +2 -1
- package/dist/endpoints/models.d.ts.map +1 -1
- package/dist/endpoints/models.js +2 -2
- package/dist/endpoints/responses.d.ts +22 -7
- package/dist/endpoints/responses.d.ts.map +1 -1
- package/dist/endpoints/responses.js +608 -85
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/handler.d.ts +69 -1
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +70 -16
- package/dist/health.d.ts +146 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +107 -0
- package/dist/host/discover.d.ts +19 -0
- package/dist/host/discover.d.ts.map +1 -0
- package/dist/host/discover.js +50 -0
- package/dist/host/env-policy.d.ts +62 -0
- package/dist/host/env-policy.d.ts.map +1 -0
- package/dist/host/env-policy.js +69 -0
- package/dist/host/index.d.ts +202 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/host/index.js +325 -0
- package/dist/host/logger.d.ts +36 -0
- package/dist/host/logger.d.ts.map +1 -0
- package/dist/host/logger.js +376 -0
- package/dist/host/net.d.ts +65 -0
- package/dist/host/net.d.ts.map +1 -0
- package/dist/host/net.js +97 -0
- package/dist/host/paths.d.ts +28 -0
- package/dist/host/paths.d.ts.map +1 -0
- package/dist/host/paths.js +71 -0
- package/dist/host/swap.d.ts +27 -0
- package/dist/host/swap.d.ts.map +1 -0
- package/dist/host/swap.js +178 -0
- package/dist/host/temp-root.d.ts +57 -0
- package/dist/host/temp-root.d.ts.map +1 -0
- package/dist/host/temp-root.js +99 -0
- package/dist/idle-sweeper.d.ts +245 -0
- package/dist/idle-sweeper.d.ts.map +1 -0
- package/dist/idle-sweeper.js +408 -0
- package/dist/index.d.ts +19 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/load-model.d.ts +69 -0
- package/dist/load-model.d.ts.map +1 -0
- package/dist/load-model.js +63 -0
- package/dist/mappers/anthropic-request.d.ts +24 -2
- package/dist/mappers/anthropic-request.d.ts.map +1 -1
- package/dist/mappers/anthropic-request.js +222 -24
- package/dist/mappers/anthropic-response.d.ts +29 -4
- package/dist/mappers/anthropic-response.d.ts.map +1 -1
- package/dist/mappers/anthropic-response.js +143 -21
- package/dist/mappers/request.d.ts +48 -0
- package/dist/mappers/request.d.ts.map +1 -1
- package/dist/mappers/request.js +211 -35
- package/dist/mappers/response.d.ts.map +1 -1
- package/dist/mappers/response.js +13 -1
- package/dist/model-work-coordinator.d.ts +95 -0
- package/dist/model-work-coordinator.d.ts.map +1 -0
- package/dist/model-work-coordinator.js +201 -0
- package/dist/pending-writes.d.ts.map +1 -1
- package/dist/presets.d.ts +82 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +98 -0
- package/dist/registry.d.ts +31 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +33 -5
- package/dist/router.d.ts +37 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +79 -7
- package/dist/server.d.ts +193 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +173 -10
- package/dist/session-registry.d.ts +279 -18
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +518 -37
- package/dist/stop-sequence-buffer.d.ts +58 -0
- package/dist/stop-sequence-buffer.d.ts.map +1 -0
- package/dist/stop-sequence-buffer.js +148 -0
- package/dist/streaming.d.ts +14 -0
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +45 -0
- package/dist/text-recovery.d.ts +35 -0
- package/dist/text-recovery.d.ts.map +1 -0
- package/dist/text-recovery.js +41 -0
- package/dist/timing.d.ts +80 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +121 -0
- package/dist/tool-call-buffer.d.ts +5 -5
- package/dist/tool-call-buffer.d.ts.map +1 -1
- package/dist/tool-call-buffer.js +28 -8
- package/dist/types-anthropic.d.ts +161 -1
- package/dist/types-anthropic.d.ts.map +1 -1
- package/dist/types.d.ts +172 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -3
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
* `primeHistory` + `startFromHistory[Stream]`.
|
|
9
9
|
*/
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { isContextCapacityError } from '@mlx-node/lm';
|
|
12
|
+
import { resetPreservingNativeCacheForWarmReuse } from '../chat-session-warm-reuse.js';
|
|
11
13
|
import { sendBadRequest, sendInternalError, sendNotFound, sendRateLimit, sendStorageTimeout } from '../errors.js';
|
|
12
|
-
import { mapRequest, reconstructMessagesFromChain } from '../mappers/request.js';
|
|
14
|
+
import { mapRequest, reconstructMessagesFromChain, stringifyStoredInputMessages } from '../mappers/request.js';
|
|
13
15
|
import { buildPartialResponse, buildResponseObject, computeOutputText, genId, mapFinishReasonToStatus, } from '../mappers/response.js';
|
|
14
16
|
import { getPendingWritesFor } from '../pending-writes.js';
|
|
15
|
-
import { QueueFullError } from '../session-registry.js';
|
|
17
|
+
import { maybeWarnPromptCacheKeyIneligible, QueueFullError } from '../session-registry.js';
|
|
16
18
|
import { beginSSE, endSSE, writeSSEEvent } from '../streaming.js';
|
|
19
|
+
import { longestSuffixPrefixOverlap } from '../text-recovery.js';
|
|
20
|
+
import { mergeTimingUsageExtensions, resolveServerTuningForUsage } from '../timing.js';
|
|
17
21
|
import { ToolCallTagBuffer } from '../tool-call-buffer.js';
|
|
18
22
|
import { createVisibility, endJson, flushTerminalSSE, markSSEMode, writeFallbackErrorSSE, } from '../transport-visibility.js';
|
|
19
23
|
/**
|
|
@@ -23,6 +27,19 @@ import { createVisibility, endJson, flushTerminalSSE, markSSEMode, writeFallback
|
|
|
23
27
|
* this 30-minute fallback is only used by legacy direct-invocation callers.
|
|
24
28
|
*/
|
|
25
29
|
const RESPONSE_TTL_SECONDS = 1800;
|
|
30
|
+
/**
|
|
31
|
+
* Upper bound for a client-supplied output-token budget. The native
|
|
32
|
+
* `ChatConfig.max_new_tokens` is `Option<i32>`, and NAPI's
|
|
33
|
+
* `napi_get_value_int32` silently truncates a JS integer above `i32::MAX`
|
|
34
|
+
* to a NEGATIVE value — which the core clamp then turns into 0 (a silent
|
|
35
|
+
* empty completion). Reject anything above this bound at the edge so an
|
|
36
|
+
* over-large budget 400s instead of producing nothing. Shared with
|
|
37
|
+
* `/v1/messages` (`messages.ts`).
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_OUTPUT_TOKENS = 2147483647; // i32::MAX — native ChatConfig.max_new_tokens is i32
|
|
40
|
+
function withAdmissionControlledInference(sessionReg, modelWorkCoordinator, fn) {
|
|
41
|
+
return sessionReg.withExclusive(() => (modelWorkCoordinator ? modelWorkCoordinator.withInference(fn) : fn()));
|
|
42
|
+
}
|
|
26
43
|
/**
|
|
27
44
|
* Upper bound (ms) on how long the recovery path waits for an in-flight
|
|
28
45
|
* `store.store(...)` to land. On timeout we re-probe `getChain` once to
|
|
@@ -136,8 +153,9 @@ export function getServerBootId() {
|
|
|
136
153
|
export function __setServerBootIdForTesting(id) {
|
|
137
154
|
serverBootId = id;
|
|
138
155
|
}
|
|
139
|
-
async function handleNonStreaming(res, result, req, responseId, previousResponseId, visibility) {
|
|
156
|
+
async function handleNonStreaming(res, result, req, responseId, previousResponseId, visibility, serverTiming) {
|
|
140
157
|
const response = buildResponseObject(result, req, responseId, previousResponseId);
|
|
158
|
+
mergeTimingUsageExtensions(response.usage, result.performance, result.promptTokens, result.numTokens, result.cachedTokens, serverTiming);
|
|
141
159
|
// `chatSession*` has no AbortSignal surface yet, so a mid-decode
|
|
142
160
|
// client disconnect still burns the full decode budget — peer loss
|
|
143
161
|
// is only observable when native decode resolves. Disconnect
|
|
@@ -161,7 +179,7 @@ async function handleNonStreaming(res, result, req, responseId, previousResponse
|
|
|
161
179
|
* on a failed envelope cannot see success-shaped items inside it.
|
|
162
180
|
* `ReasoningOutputItem` has no `status` field and is left alone.
|
|
163
181
|
*/
|
|
164
|
-
function buildFailedTerminal(partial, outputItems, reason, usage) {
|
|
182
|
+
function buildFailedTerminal(partial, outputItems, reason, usage, errorMessage) {
|
|
165
183
|
const normalized = outputItems.map((item) => {
|
|
166
184
|
if (item.type === 'message') {
|
|
167
185
|
const prev = item.status;
|
|
@@ -178,16 +196,25 @@ function buildFailedTerminal(partial, outputItems, reason, usage) {
|
|
|
178
196
|
}
|
|
179
197
|
return item;
|
|
180
198
|
});
|
|
199
|
+
// Only the `reason: 'error'` path carries a diagnostic message —
|
|
200
|
+
// client_abort / stream_exhausted / finish_reason_error are caller
|
|
201
|
+
// or finite-state conditions, not server faults worth surfacing.
|
|
202
|
+
const error = errorMessage ? { type: 'server_error', message: errorMessage, code: null, param: null } : null;
|
|
181
203
|
return {
|
|
182
204
|
...partial,
|
|
183
205
|
status: 'failed',
|
|
184
206
|
output: normalized,
|
|
185
207
|
output_text: computeOutputText(normalized),
|
|
208
|
+
error,
|
|
186
209
|
incomplete_details: { reason },
|
|
187
210
|
usage,
|
|
188
211
|
};
|
|
189
212
|
}
|
|
190
|
-
async function handleStreamingNative(res, chatStream, req, responseId, previousResponseId, wasCommitted, httpReq, visibility) {
|
|
213
|
+
async function handleStreamingNative(res, chatStream, req, responseId, previousResponseId, wasCommitted, httpReq, visibility, serverTiming) {
|
|
214
|
+
// `runSessionStreaming` completed the exact token/capacity preflight before
|
|
215
|
+
// handing us this iterator. Commit SSE immediately instead of entering the
|
|
216
|
+
// generator here: its first `next()` also starts image processing/prefill and
|
|
217
|
+
// may not resolve until the first generated token.
|
|
191
218
|
beginSSE(res);
|
|
192
219
|
// Commit to SSE wire format synchronously so the outer catch
|
|
193
220
|
// branches on `responseMode` (not `headersSent`) and routes an
|
|
@@ -206,6 +233,13 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
206
233
|
let messageText = '';
|
|
207
234
|
let hasEmittedMessage = false;
|
|
208
235
|
let hasEmittedReasoning = false;
|
|
236
|
+
// Tracks whether the reasoning output item's `response.output_item.done`
|
|
237
|
+
// has already been emitted. We close it eagerly on the reasoning→text
|
|
238
|
+
// transition (before opening the message item) so OpenAI Responses
|
|
239
|
+
// clients can populate their `thinkingSignature` via the `done`
|
|
240
|
+
// event's `currentBlock?.type === 'thinking'` guard. The terminal and
|
|
241
|
+
// failure paths check this flag to avoid double-emitting.
|
|
242
|
+
let hasClosedReasoning = false;
|
|
209
243
|
let suppressedMessageIndex = -1;
|
|
210
244
|
const tagBuffer = new ToolCallTagBuffer();
|
|
211
245
|
// Terminal response is captured in the done branch but emitted AFTER
|
|
@@ -213,6 +247,14 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
213
247
|
// `session.turns` once the producer's finally has run.
|
|
214
248
|
let completedResponse = null;
|
|
215
249
|
let sawDone = false;
|
|
250
|
+
// Lifted from the final stream event so the outer handler can set
|
|
251
|
+
// `X-Cached-Tokens` and promote the `X-Session-Cache` header to
|
|
252
|
+
// `prefix_hit` when tier-2 reuse actually happened. See
|
|
253
|
+
// `StreamingHandlerOutcome.cachedTokens` for the full rationale.
|
|
254
|
+
// Starts `undefined` — the field is only populated if the native
|
|
255
|
+
// terminal chunk carries `cachedTokens`. Today it never does; a
|
|
256
|
+
// future native plumbing change can lift it through.
|
|
257
|
+
let cachedTokens;
|
|
216
258
|
// Fault state. `thrownError` sticks on a generator throw;
|
|
217
259
|
// `clientAborted` sticks on any `close`/`error` from `httpReq`, `res`,
|
|
218
260
|
// or `res.socket`. Either flips the post-loop block to the failure
|
|
@@ -286,18 +328,22 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
286
328
|
delta: remainingText,
|
|
287
329
|
});
|
|
288
330
|
}
|
|
289
|
-
// Close reasoning item if open
|
|
290
|
-
|
|
331
|
+
// Close reasoning item if still open (already closed eagerly on
|
|
332
|
+
// reasoning→text transition for most turns — this branch covers
|
|
333
|
+
// the reasoning-only shape where no text deltas ever arrived).
|
|
334
|
+
if (hasEmittedReasoning && !hasClosedReasoning && reasoningItemId) {
|
|
335
|
+
hasClosedReasoning = true;
|
|
336
|
+
const finalReasoningText = event.thinking ?? reasoningText;
|
|
291
337
|
writeSSEEvent(res, 'response.reasoning_summary_text.done', {
|
|
292
338
|
item_id: reasoningItemId,
|
|
293
339
|
output_index: outputItems.length - (hasEmittedMessage ? 1 : 0) - 1,
|
|
294
340
|
summary_index: 0,
|
|
295
|
-
text:
|
|
341
|
+
text: finalReasoningText,
|
|
296
342
|
});
|
|
297
343
|
const reasoningItem = {
|
|
298
344
|
id: reasoningItemId,
|
|
299
345
|
type: 'reasoning',
|
|
300
|
-
summary: [{ type: 'summary_text', text:
|
|
346
|
+
summary: [{ type: 'summary_text', text: finalReasoningText }],
|
|
301
347
|
};
|
|
302
348
|
const riIndex = outputItems.findIndex((i) => i.id === reasoningItemId);
|
|
303
349
|
if (riIndex >= 0) {
|
|
@@ -347,10 +393,38 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
347
393
|
delta: finalText,
|
|
348
394
|
});
|
|
349
395
|
}
|
|
350
|
-
else if (tagBuffer.suppressed &&
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
396
|
+
else if (tagBuffer.suppressed &&
|
|
397
|
+
!hasToolCalls &&
|
|
398
|
+
finalText &&
|
|
399
|
+
hasEmittedMessage &&
|
|
400
|
+
!messageText.includes(finalText)) {
|
|
401
|
+
// Recovery: streaming text was cut off by a false-alarm `<tool_call>` tag.
|
|
402
|
+
//
|
|
403
|
+
// The previous `finalText.slice(messageText.length)` is wrong: when the
|
|
404
|
+
// streamed text contains post-</think> whitespace (or any prefix the
|
|
405
|
+
// native side trimmed via `split_at_think_end` / `parse_tool_calls`),
|
|
406
|
+
// `messageText.length` indexes into the streamed buffer while
|
|
407
|
+
// `finalText` starts at the post-trim cleaned position — the two
|
|
408
|
+
// prefixes diverge (e.g. messageText=`"\n\n"`, finalText=`"<tool_call>..."`)
|
|
409
|
+
// and a length-based slice chops `<t` off `<tool_call>`, emitting
|
|
410
|
+
// `"ool_call>\n<function=..."` as visible text.
|
|
411
|
+
//
|
|
412
|
+
// Find the longest streamed-suffix == finalText-prefix overlap and emit
|
|
413
|
+
// whatever finalText has BEYOND that overlap.
|
|
414
|
+
//
|
|
415
|
+
// The `!messageText.includes(finalText)` guard distinguishes:
|
|
416
|
+
// (a) duplicate-trim case: streamed "Let me check. " + closed
|
|
417
|
+
// non-ok tool_call → finalText="Let me check." (trimmed). The
|
|
418
|
+
// trimmed text IS a substring of the streamed text → skip
|
|
419
|
+
// (otherwise we'd duplicate "Let me check.").
|
|
420
|
+
// (b) unclosed-tool case: streamed `\n\n` + unclosed
|
|
421
|
+
// `<tool_call>...` → finalText=`<tool_call>...`. The malformed
|
|
422
|
+
// tag is NOT a substring of the streamed whitespace → emit
|
|
423
|
+
// (this is the original `<t`-strip bug we're fixing).
|
|
424
|
+
// Length-based guards (`finalText.length > messageText.length`)
|
|
425
|
+
// misclassify case (b) when the streamed whitespace is long.
|
|
426
|
+
const overlap = longestSuffixPrefixOverlap(messageText, finalText);
|
|
427
|
+
const unsent = finalText.slice(overlap);
|
|
354
428
|
if (unsent) {
|
|
355
429
|
messageText += unsent;
|
|
356
430
|
writeSSEEvent(res, 'response.output_text.delta', {
|
|
@@ -361,16 +435,29 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
361
435
|
});
|
|
362
436
|
}
|
|
363
437
|
}
|
|
364
|
-
// Emit any unsent suffix when final text
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
438
|
+
// Emit any unsent suffix when final text extends past what was
|
|
439
|
+
// streamed. Same divergence concern as above (post-</think> trim
|
|
440
|
+
// can leave `messageText` longer than the matching prefix of
|
|
441
|
+
// `finalText`), so we use the same overlap-based slice instead of
|
|
442
|
+
// a length-based one. When the overlap covers all of `finalText`
|
|
443
|
+
// (i.e. nothing more to emit) `unsent` is empty and we skip.
|
|
444
|
+
//
|
|
445
|
+
// The `!messageText.includes(finalText)` guard skips the
|
|
446
|
+
// duplicate-trim case where finalText is a substring of the
|
|
447
|
+
// streamed text (e.g. native `.trim()` shrinkage). See the
|
|
448
|
+
// companion comment above for the case-distinction rationale.
|
|
449
|
+
if (hasEmittedMessage && finalText && !tagBuffer.suppressed && !messageText.includes(finalText)) {
|
|
450
|
+
const overlap = longestSuffixPrefixOverlap(messageText, finalText);
|
|
451
|
+
const unsent = finalText.slice(overlap);
|
|
452
|
+
if (unsent) {
|
|
453
|
+
messageText += unsent;
|
|
454
|
+
writeSSEEvent(res, 'response.output_text.delta', {
|
|
455
|
+
item_id: messageItemId,
|
|
456
|
+
output_index: outputItems.findIndex((i) => i.id === messageItemId),
|
|
457
|
+
content_index: 0,
|
|
458
|
+
delta: unsent,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
374
461
|
}
|
|
375
462
|
// Recovery: text was never emitted during streaming but final has text
|
|
376
463
|
// (possible if all text arrived in the final event only)
|
|
@@ -493,12 +580,27 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
493
580
|
// then the post-loop block handles emission + persistence.
|
|
494
581
|
const promptTokens = event.promptTokens ?? 0;
|
|
495
582
|
const reasoningTokens = event.reasoningTokens ?? 0;
|
|
583
|
+
cachedTokens = event.cachedTokens;
|
|
496
584
|
const usage = {
|
|
497
585
|
input_tokens: promptTokens,
|
|
498
586
|
output_tokens: event.numTokens,
|
|
499
587
|
output_tokens_details: { reasoning_tokens: reasoningTokens },
|
|
500
588
|
total_tokens: promptTokens + event.numTokens,
|
|
501
589
|
};
|
|
590
|
+
// Round 5 Fix #3: SSE headers flush before the native prefix
|
|
591
|
+
// verifier has reported cached-token counts, so streaming
|
|
592
|
+
// `X-Session-Cache` is documented as non-authoritative. The
|
|
593
|
+
// authoritative signal for streaming clients is this in-band
|
|
594
|
+
// `usage.input_tokens_details.cached_tokens` field on the
|
|
595
|
+
// terminal `response.completed` event — identical shape to
|
|
596
|
+
// the upstream OpenAI Responses API. Populated only when the
|
|
597
|
+
// native dispatch reports a non-zero reuse count so consumers
|
|
598
|
+
// cheaply distinguish "feature not active" from "active with
|
|
599
|
+
// zero reuse".
|
|
600
|
+
if (cachedTokens != null && cachedTokens > 0) {
|
|
601
|
+
usage.input_tokens_details = { cached_tokens: cachedTokens };
|
|
602
|
+
}
|
|
603
|
+
mergeTimingUsageExtensions(usage, event.performance, promptTokens, event.numTokens, cachedTokens, serverTiming);
|
|
502
604
|
const finalOutput = outputItems.filter((_, idx) => idx !== suppressedMessageIndex);
|
|
503
605
|
completedResponse = {
|
|
504
606
|
...partial,
|
|
@@ -538,6 +640,39 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
538
640
|
});
|
|
539
641
|
}
|
|
540
642
|
else {
|
|
643
|
+
// Transition from reasoning to assistant text: close the reasoning
|
|
644
|
+
// output item BEFORE emitting any `response.output_item.added` for
|
|
645
|
+
// the message. OpenAI Responses clients (e.g. pi-mono) maintain a
|
|
646
|
+
// single `currentBlock` state — opening the message item while the
|
|
647
|
+
// reasoning item is still "in progress" overwrites that state, so
|
|
648
|
+
// the later `output_item.done` for reasoning fails its
|
|
649
|
+
// `currentBlock?.type === 'thinking'` guard and never sets
|
|
650
|
+
// `thinkingSignature`. Without the signature the next turn cannot
|
|
651
|
+
// echo the reasoning item back, and any thinking-model agent loses
|
|
652
|
+
// its chain of thought on each turn. Must run before the first
|
|
653
|
+
// message write on this branch.
|
|
654
|
+
if (hasEmittedReasoning && !hasClosedReasoning && reasoningItemId) {
|
|
655
|
+
hasClosedReasoning = true;
|
|
656
|
+
const riIndex = outputItems.findIndex((i) => i.id === reasoningItemId);
|
|
657
|
+
writeSSEEvent(res, 'response.reasoning_summary_text.done', {
|
|
658
|
+
item_id: reasoningItemId,
|
|
659
|
+
output_index: riIndex,
|
|
660
|
+
summary_index: 0,
|
|
661
|
+
text: reasoningText,
|
|
662
|
+
});
|
|
663
|
+
const reasoningItem = {
|
|
664
|
+
id: reasoningItemId,
|
|
665
|
+
type: 'reasoning',
|
|
666
|
+
summary: [{ type: 'summary_text', text: reasoningText }],
|
|
667
|
+
};
|
|
668
|
+
if (riIndex >= 0) {
|
|
669
|
+
outputItems[riIndex] = reasoningItem;
|
|
670
|
+
}
|
|
671
|
+
writeSSEEvent(res, 'response.output_item.done', {
|
|
672
|
+
output_index: riIndex >= 0 ? riIndex : 0,
|
|
673
|
+
item: reasoningItem,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
541
676
|
// Text delta with tool_call tag buffering
|
|
542
677
|
const { safeText, tagFound, cleanPrefix } = tagBuffer.push(event.text);
|
|
543
678
|
if (tagFound) {
|
|
@@ -618,6 +753,12 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
618
753
|
// error would escape into the outer JSON error path with SSE
|
|
619
754
|
// headers already on the wire.
|
|
620
755
|
thrownError = err instanceof Error ? err : new Error(String(err));
|
|
756
|
+
// Surface the message to stderr even on the failure path — without
|
|
757
|
+
// this the native side (e.g. `Tokenizer encoded <turn|> to N
|
|
758
|
+
// tokens; expected 1`) is invisible to operators since the SSE
|
|
759
|
+
// `response.failed` payload only carries `incomplete_details.reason`
|
|
760
|
+
// and never the underlying exception text.
|
|
761
|
+
console.error(`[responses] native dispatch failed for ${req.model} (response ${responseId}):`, thrownError.message);
|
|
621
762
|
}
|
|
622
763
|
finally {
|
|
623
764
|
if (httpReq) {
|
|
@@ -670,7 +811,7 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
670
811
|
// adopt under an unseen responseId.
|
|
671
812
|
await flushTerminalSSE(res, 'response.completed', { response: terminal }, visibility);
|
|
672
813
|
endSSE(res);
|
|
673
|
-
return { terminalToPersist: terminal, failureMode: null };
|
|
814
|
+
return { terminalToPersist: terminal, failureMode: null, cachedTokens };
|
|
674
815
|
}
|
|
675
816
|
// Failure epilogue. Close any dangling message items BEFORE the
|
|
676
817
|
// terminal so clients tracking `output_index` see matching closes.
|
|
@@ -727,9 +868,10 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
727
868
|
item: closedMessageItem,
|
|
728
869
|
});
|
|
729
870
|
}
|
|
730
|
-
if (!sawDone && hasEmittedReasoning && reasoningItemId != null) {
|
|
871
|
+
if (!sawDone && hasEmittedReasoning && !hasClosedReasoning && reasoningItemId != null) {
|
|
731
872
|
// No `status` field on reasoning items — just emit closes so
|
|
732
873
|
// client-side output_index bookkeeping stays consistent.
|
|
874
|
+
hasClosedReasoning = true;
|
|
733
875
|
writeSSEEvent(res, 'response.reasoning_summary_text.done', {
|
|
734
876
|
item_id: reasoningItemId,
|
|
735
877
|
output_index: outputItems.findIndex((i) => i.id === reasoningItemId),
|
|
@@ -748,13 +890,16 @@ async function handleStreamingNative(res, chatStream, req, responseId, previousR
|
|
|
748
890
|
writeSSEEvent(res, 'response.output_item.done', { output_index: riIndex, item: reasoningItem });
|
|
749
891
|
}
|
|
750
892
|
}
|
|
751
|
-
const failedTerminal = buildFailedTerminal(partial, finalOutput, reason, usage);
|
|
893
|
+
const failedTerminal = buildFailedTerminal(partial, finalOutput, reason, usage, reason === 'error' && thrownError ? thrownError.message : null);
|
|
752
894
|
await flushTerminalSSE(res, 'response.failed', { response: failedTerminal }, visibility);
|
|
753
895
|
endSSE(res);
|
|
754
896
|
// No terminalToPersist on an uncommitted turn: a later continuation
|
|
755
897
|
// that cold-replayed this record would silently resurrect failed
|
|
756
|
-
// output as authoritative history.
|
|
757
|
-
|
|
898
|
+
// output as authoritative history. `cachedTokens` is meaningless on
|
|
899
|
+
// the failure path but returned verbatim to keep the type shape
|
|
900
|
+
// uniform — consumers already treat a non-null `failureMode` as the
|
|
901
|
+
// authoritative "do not use these numbers" signal.
|
|
902
|
+
return { terminalToPersist: null, failureMode: reason, cachedTokens };
|
|
758
903
|
}
|
|
759
904
|
// ---------------------------------------------------------------------------
|
|
760
905
|
// Session routing
|
|
@@ -979,9 +1124,50 @@ export function validateAndCanonicalizeHistoryToolOrder(messages, apiSurface = '
|
|
|
979
1124
|
* back to reset + cold re-prime on multi-message input. The caller
|
|
980
1125
|
* is responsible for rejecting partial tool-result submissions
|
|
981
1126
|
* against a fan-out (`handleCreateResponse` fan-out gate).
|
|
1127
|
+
*
|
|
1128
|
+
* `isFreshSession` is the MISS / HIT signal from `SessionRegistry`:
|
|
1129
|
+
* `true` when `lookup.hit === false` (a truly new session minted via
|
|
1130
|
+
* `newSession()`), `false` when a tier-1 or tier-2 warm lease was
|
|
1131
|
+
* handed out. On a MISS we must wipe the shared native model's
|
|
1132
|
+
* leftover `cached_token_history` + KV caches before re-priming, or
|
|
1133
|
+
* a previous UNRELATED request's cache could silently get reused as
|
|
1134
|
+
* a prefix (cross-request cache-affinity side channel). On a HIT we
|
|
1135
|
+
* must NOT wipe — the whole point of the warm lease is that
|
|
1136
|
+
* `verify_cache_prefix_direct` can recover the reused prefix on the
|
|
1137
|
+
* next `chat_session_start_sync`. The HIT branch calls the
|
|
1138
|
+
* server-private `resetPreservingNativeCacheForWarmReuse(session)`
|
|
1139
|
+
* helper from `../chat-session-warm-reuse.js` instead of the public
|
|
1140
|
+
* `reset()` to thread this distinction down to the JS-side state
|
|
1141
|
+
* clear. The helper lives inside `@mlx-node/server` and is never
|
|
1142
|
+
* re-exported from either `@mlx-node/lm` or `@mlx-node/server`'s
|
|
1143
|
+
* public surface, so downstream consumers cannot discover or invoke
|
|
1144
|
+
* it.
|
|
982
1145
|
*/
|
|
983
|
-
async function runSessionNonStreaming(session, messages, newInputMessages, config) {
|
|
1146
|
+
async function runSessionNonStreaming(session, messages, newInputMessages, config, isFreshSession) {
|
|
984
1147
|
if (session.turns === 0) {
|
|
1148
|
+
// Fresh JS session does NOT imply a fresh native cache — the
|
|
1149
|
+
// underlying `SessionCapableModel` is shared across every
|
|
1150
|
+
// `ChatSession` lifetime via `ModelRegistry`, and its native
|
|
1151
|
+
// `cached_token_history` + KV caches persist across requests.
|
|
1152
|
+
// After the native refactor moved the unconditional cache wipe
|
|
1153
|
+
// out of `chat_session_start_sync` into the miss branch of
|
|
1154
|
+
// `verify_cache_prefix_direct`, a MISS path that runs
|
|
1155
|
+
// `primeHistory() + startFromHistory()` on a fresh session would
|
|
1156
|
+
// inherit the PREVIOUS request's native cache and silently reuse
|
|
1157
|
+
// whatever prefix happened to overlap — a cross-request
|
|
1158
|
+
// cache-affinity side channel. Only registry HITS (tier-1 /
|
|
1159
|
+
// tier-2) are authorized for cache reuse, and a leased session
|
|
1160
|
+
// almost always has `turns > 0` so this branch is nearly always
|
|
1161
|
+
// a MISS. The `isFreshSession` flag is the authoritative signal
|
|
1162
|
+
// — on `false` we still need to clear JS-side state so
|
|
1163
|
+
// `primeHistory()` accepts the replay, but we keep the native
|
|
1164
|
+
// cache intact so the prefix verifier can recover it.
|
|
1165
|
+
if (isFreshSession) {
|
|
1166
|
+
await session.reset();
|
|
1167
|
+
}
|
|
1168
|
+
else {
|
|
1169
|
+
await resetPreservingNativeCacheForWarmReuse(session);
|
|
1170
|
+
}
|
|
985
1171
|
session.primeHistory(messages);
|
|
986
1172
|
const initialTurns = session.turns;
|
|
987
1173
|
const result = await session.startFromHistory(config);
|
|
@@ -989,10 +1175,11 @@ async function runSessionNonStreaming(session, messages, newInputMessages, confi
|
|
|
989
1175
|
}
|
|
990
1176
|
// Hot path — session's KV cache is already warmed for this chain.
|
|
991
1177
|
// Single-message continuations whose role is `user` or `tool` take
|
|
992
|
-
// the
|
|
1178
|
+
// the session paths (`send` / `sendToolResult`), which render the full
|
|
1179
|
+
// transcript and reuse KV on an exact token-prefix match. Any other single
|
|
993
1180
|
// role (`assistant`, `system`) is still accepted by `mapRequest` —
|
|
994
1181
|
// `reconstructMessagesFromChain` + `primeHistory` tolerate a tail of
|
|
995
|
-
// either — but the chat-session
|
|
1182
|
+
// either — but the high-level chat-session API has no entry point for
|
|
996
1183
|
// them, so fall through to reset + cold re-prime against the fully
|
|
997
1184
|
// rebuilt history. Returning 500 here would regress the pre-session-
|
|
998
1185
|
// API full-history path, making valid continuation payloads fail
|
|
@@ -1010,7 +1197,12 @@ async function runSessionNonStreaming(session, messages, newInputMessages, confi
|
|
|
1010
1197
|
throw new Error('tool message missing toolCallId');
|
|
1011
1198
|
}
|
|
1012
1199
|
const initialTurns = session.turns;
|
|
1013
|
-
|
|
1200
|
+
// Forward the structured `isError` field through to the native
|
|
1201
|
+
// renderer so the wire-format `[tool error]` marker stays in sync
|
|
1202
|
+
// with the Anthropic `tool_result.is_error === true` source field
|
|
1203
|
+
// (the structured channel is the authoritative signal — see
|
|
1204
|
+
// `ChatMessage.isError` rustdoc).
|
|
1205
|
+
const result = await session.sendToolResult(last.toolCallId, last.content, { config, isError: last.isError });
|
|
1014
1206
|
return { result, committed: session.turns > initialTurns };
|
|
1015
1207
|
}
|
|
1016
1208
|
// Non-user / non-tool single-message continuation (assistant /
|
|
@@ -1021,20 +1213,57 @@ async function runSessionNonStreaming(session, messages, newInputMessages, confi
|
|
|
1021
1213
|
// re-prime. `initialTurns` MUST be captured AFTER `session.reset()`
|
|
1022
1214
|
// zeroes `turns`, otherwise the committed check reads stale.
|
|
1023
1215
|
// Amortized: the caller re-keys this session under the new
|
|
1024
|
-
// responseId on success.
|
|
1025
|
-
|
|
1216
|
+
// responseId on success. On a tier-1 / tier-2 HIT that landed here
|
|
1217
|
+
// (full-history replay is NOT a single-message delta, so even a
|
|
1218
|
+
// warm session falls through to this branch), we MUST keep the
|
|
1219
|
+
// native KV cache so the native `verify_cache_prefix_direct` can
|
|
1220
|
+
// recover the reused prefix — wiping it would neutralize the
|
|
1221
|
+
// entire warm-lease feature on multi-message hits. On a MISS we
|
|
1222
|
+
// still wipe to prevent cross-request cache-affinity leakage.
|
|
1223
|
+
if (isFreshSession) {
|
|
1224
|
+
await session.reset();
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
await resetPreservingNativeCacheForWarmReuse(session);
|
|
1228
|
+
}
|
|
1026
1229
|
session.primeHistory(messages);
|
|
1027
1230
|
const initialTurns = session.turns;
|
|
1028
1231
|
const result = await session.startFromHistory(config);
|
|
1029
1232
|
return { result, committed: session.turns > initialTurns };
|
|
1030
1233
|
}
|
|
1031
1234
|
/** Streaming counterpart to {@link runSessionNonStreaming}. */
|
|
1032
|
-
async function runSessionStreaming(session, messages, newInputMessages, config, signal) {
|
|
1235
|
+
async function runSessionStreaming(session, messages, newInputMessages, config, signal, isFreshSession) {
|
|
1236
|
+
// Preserve the startFromHistoryStream precondition outside the lazy
|
|
1237
|
+
// generator. Without this guard an accepted `input: []` request would commit
|
|
1238
|
+
// SSE and only then throw when iteration begins; the former eager-first-item
|
|
1239
|
+
// path surfaced the same deterministic error before selecting wire format.
|
|
1240
|
+
if (messages.length === 0) {
|
|
1241
|
+
throw new Error('ChatSession: startFromHistoryStream() requires a primed history');
|
|
1242
|
+
}
|
|
1033
1243
|
if (session.turns === 0) {
|
|
1244
|
+
// Fresh/replay requests carry their complete canonical history in
|
|
1245
|
+
// `messages`. Validate it before reset so context overflow cannot mutate
|
|
1246
|
+
// native state and still receives a JSON 400 before SSE begins.
|
|
1247
|
+
const constrainedConfig = await session.preflightContextCapacity(messages, config);
|
|
1248
|
+
// See `runSessionNonStreaming` for the full rationale. A fresh
|
|
1249
|
+
// JS session inherits the shared native model's KV cache from
|
|
1250
|
+
// prior requests; without an explicit `reset()` here the native
|
|
1251
|
+
// prefix verifier can silently reuse a previous request's cache
|
|
1252
|
+
// on any prompt-prefix overlap (a cross-request cache-affinity
|
|
1253
|
+
// side channel). On MISS (`isFreshSession === true`) we wipe
|
|
1254
|
+
// native cache + JS state. On tier-1 / tier-2 HIT we keep the
|
|
1255
|
+
// native cache so the prefix verifier can recover the reused
|
|
1256
|
+
// prefix, while still clearing JS-side state for `primeHistory`.
|
|
1257
|
+
if (isFreshSession) {
|
|
1258
|
+
await session.reset();
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
await resetPreservingNativeCacheForWarmReuse(session);
|
|
1262
|
+
}
|
|
1034
1263
|
session.primeHistory(messages);
|
|
1035
1264
|
const initialTurns = session.turns;
|
|
1036
1265
|
return {
|
|
1037
|
-
stream: session.startFromHistoryStream(
|
|
1266
|
+
stream: session.startFromHistoryStream(constrainedConfig, signal),
|
|
1038
1267
|
wasCommitted: () => session.turns > initialTurns,
|
|
1039
1268
|
};
|
|
1040
1269
|
}
|
|
@@ -1045,10 +1274,14 @@ async function runSessionStreaming(session, messages, newInputMessages, config,
|
|
|
1045
1274
|
if (newInputMessages.length === 1) {
|
|
1046
1275
|
const last = newInputMessages[0];
|
|
1047
1276
|
if (last.role === 'user') {
|
|
1277
|
+
// Tier-2 prompt-cache hits may carry only this new message in the HTTP
|
|
1278
|
+
// request while the leased ChatSession owns the prior conversation.
|
|
1279
|
+
// Preflight against that authoritative private history, not `messages`.
|
|
1280
|
+
const constrainedConfig = await session.preflightPendingContextCapacity(last, config);
|
|
1048
1281
|
const initialTurns = session.turns;
|
|
1049
1282
|
const images = last.images ?? undefined;
|
|
1050
1283
|
return {
|
|
1051
|
-
stream: session.sendStream(last.content, images ? { images, config, signal } : { config, signal }),
|
|
1284
|
+
stream: session.sendStream(last.content, images ? { images, config: constrainedConfig, signal } : { config: constrainedConfig, signal }),
|
|
1052
1285
|
wasCommitted: () => session.turns > initialTurns,
|
|
1053
1286
|
};
|
|
1054
1287
|
}
|
|
@@ -1056,9 +1289,18 @@ async function runSessionStreaming(session, messages, newInputMessages, config,
|
|
|
1056
1289
|
if (!last.toolCallId) {
|
|
1057
1290
|
throw new Error('tool message missing toolCallId');
|
|
1058
1291
|
}
|
|
1292
|
+
const constrainedConfig = await session.preflightPendingContextCapacity(last, config);
|
|
1059
1293
|
const initialTurns = session.turns;
|
|
1060
1294
|
return {
|
|
1061
|
-
|
|
1295
|
+
// Forward the structured `isError` field through to the native
|
|
1296
|
+
// renderer so the streaming wire-format `[tool error]` marker
|
|
1297
|
+
// stays in sync with the Anthropic `tool_result.is_error === true`
|
|
1298
|
+
// source field — same contract as the non-streaming path above.
|
|
1299
|
+
stream: session.sendToolResultStream(last.toolCallId, last.content, {
|
|
1300
|
+
config: constrainedConfig,
|
|
1301
|
+
signal,
|
|
1302
|
+
isError: last.isError,
|
|
1303
|
+
}),
|
|
1062
1304
|
wasCommitted: () => session.turns > initialTurns,
|
|
1063
1305
|
};
|
|
1064
1306
|
}
|
|
@@ -1067,12 +1309,22 @@ async function runSessionStreaming(session, messages, newInputMessages, config,
|
|
|
1067
1309
|
}
|
|
1068
1310
|
// Multi-message (or single non-user/non-tool) hot path: same reset +
|
|
1069
1311
|
// cold re-prime as the non-streaming variant. `initialTurns` must be
|
|
1070
|
-
// captured AFTER reset.
|
|
1071
|
-
|
|
1312
|
+
// captured AFTER reset. On tier-1 / tier-2 HIT (warm lease that
|
|
1313
|
+
// cannot use the delta API because the input spans multiple
|
|
1314
|
+
// messages), keep the native KV cache so the prefix verifier can
|
|
1315
|
+
// reuse it on the replayed `chat_session_start_sync`; on MISS, wipe
|
|
1316
|
+
// to block cross-request cache-affinity leakage.
|
|
1317
|
+
const constrainedConfig = await session.preflightContextCapacity(messages, config);
|
|
1318
|
+
if (isFreshSession) {
|
|
1319
|
+
await session.reset();
|
|
1320
|
+
}
|
|
1321
|
+
else {
|
|
1322
|
+
await resetPreservingNativeCacheForWarmReuse(session);
|
|
1323
|
+
}
|
|
1072
1324
|
session.primeHistory(messages);
|
|
1073
1325
|
const initialTurns = session.turns;
|
|
1074
1326
|
return {
|
|
1075
|
-
stream: session.startFromHistoryStream(
|
|
1327
|
+
stream: session.startFromHistoryStream(constrainedConfig, signal),
|
|
1076
1328
|
wasCommitted: () => session.turns > initialTurns,
|
|
1077
1329
|
};
|
|
1078
1330
|
}
|
|
@@ -1106,7 +1358,7 @@ function buildResponseRecord(response, newInputMessages, previousResponseId, mod
|
|
|
1106
1358
|
model: response.model,
|
|
1107
1359
|
status: response.status,
|
|
1108
1360
|
instructions: response.instructions ?? undefined,
|
|
1109
|
-
inputJson:
|
|
1361
|
+
inputJson: stringifyStoredInputMessages(newInputMessages),
|
|
1110
1362
|
outputJson: JSON.stringify(response.output),
|
|
1111
1363
|
outputText: response.output_text,
|
|
1112
1364
|
usageJson: JSON.stringify(response.usage),
|
|
@@ -1161,7 +1413,10 @@ function readStoredModelIdentity(record) {
|
|
|
1161
1413
|
// ---------------------------------------------------------------------------
|
|
1162
1414
|
// Public handler
|
|
1163
1415
|
// ---------------------------------------------------------------------------
|
|
1164
|
-
export async function handleCreateResponse(res, body, registry, store, httpReq, responseRetentionSec
|
|
1416
|
+
export async function handleCreateResponse(res, body, registry, store, httpReq, responseRetentionSec, idleSweeper, modelWorkCoordinator,
|
|
1417
|
+
/** Lazy-load hook. See the call site below and `ServerConfig.resolveModel`. */
|
|
1418
|
+
resolveModel) {
|
|
1419
|
+
const handlerStartedAt = Date.now();
|
|
1165
1420
|
// Validate required fields
|
|
1166
1421
|
if (body == null || typeof body !== 'object') {
|
|
1167
1422
|
sendBadRequest(res, 'Request body must be a JSON object', 'body');
|
|
@@ -1179,6 +1434,22 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
1179
1434
|
sendBadRequest(res, 'Field "input" must be a string or an array', 'input');
|
|
1180
1435
|
return;
|
|
1181
1436
|
}
|
|
1437
|
+
// A present `max_output_tokens` must be an integer in `[1, i32::MAX]`.
|
|
1438
|
+
// `null` / missing means "no explicit limit" and is fine. Mirrors the
|
|
1439
|
+
// Anthropic `/v1/messages` guard on `max_tokens`. Rejecting here (rather
|
|
1440
|
+
// than forwarding through the mapper) keeps a nonpositive budget from
|
|
1441
|
+
// reaching native chat, where a negative `i32` would size a cache /
|
|
1442
|
+
// allocation as a huge `usize`; core still clamps nonpositive to 0 as
|
|
1443
|
+
// a backstop. The upper bound matters because NAPI truncates a JS
|
|
1444
|
+
// integer above `i32::MAX` to a NEGATIVE `i32` (then clamped to 0 → a
|
|
1445
|
+
// silent empty completion) — reject it as a 400 instead.
|
|
1446
|
+
if (body.max_output_tokens != null &&
|
|
1447
|
+
(!Number.isInteger(body.max_output_tokens) ||
|
|
1448
|
+
body.max_output_tokens <= 0 ||
|
|
1449
|
+
body.max_output_tokens > MAX_OUTPUT_TOKENS)) {
|
|
1450
|
+
sendBadRequest(res, `Field "max_output_tokens" must be an integer between 1 and ${MAX_OUTPUT_TOKENS}`, 'max_output_tokens');
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1182
1453
|
// Per-request retention override: `metadata.retention_seconds` lets a
|
|
1183
1454
|
// client pin a single row to a longer (VIP / onboarding) or shorter
|
|
1184
1455
|
// (one-shot PII) lifetime than the server-wide default. Bounds
|
|
@@ -1204,6 +1475,36 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
1204
1475
|
}
|
|
1205
1476
|
}
|
|
1206
1477
|
const effectiveRetentionSec = requestedRetentionSec ?? responseRetentionSec;
|
|
1478
|
+
// Lazy load, exactly as the Anthropic endpoints do. Nothing is resident at
|
|
1479
|
+
// boot — `createInferenceHost` only discovers — so without this the very
|
|
1480
|
+
// first `/v1/responses` 404s against a `/v1/models` list that advertises the
|
|
1481
|
+
// model, and a client id that exists only as an alias 404s forever.
|
|
1482
|
+
//
|
|
1483
|
+
// Placement is pinned on both sides. AFTER the pure validation above, so a
|
|
1484
|
+
// 400 cannot burn a 30 s load or evict the resident model. BEFORE
|
|
1485
|
+
// `registry.get` below, and therefore before the dispatch lease, which needs
|
|
1486
|
+
// a registered name.
|
|
1487
|
+
if (resolveModel) {
|
|
1488
|
+
// Errors serialize through the OpenAI envelope here. Letting them reach
|
|
1489
|
+
// the outer `createHandler` catch would be right for this endpoint by
|
|
1490
|
+
// accident and wrong for the Anthropic one — `messages.ts` has the mirror
|
|
1491
|
+
// of this note for the opposite reason.
|
|
1492
|
+
try {
|
|
1493
|
+
// Suspension OUTSIDE the writer lock, per `load-model.ts`: a load that
|
|
1494
|
+
// parks in `acquireWrite()` must already be covered, or the drain timer
|
|
1495
|
+
// armed by the previous request fires mid-materialization. `messages.ts`
|
|
1496
|
+
// nests these the other way round, which is safe only because
|
|
1497
|
+
// `withSuspendedDrains` is a counter rather than a mutex.
|
|
1498
|
+
const load = () => modelWorkCoordinator
|
|
1499
|
+
? modelWorkCoordinator.withModelLoad(() => resolveModel(body.model), 'responses')
|
|
1500
|
+
: resolveModel(body.model);
|
|
1501
|
+
await (idleSweeper ? idleSweeper.withSuspendedDrains(load) : load());
|
|
1502
|
+
}
|
|
1503
|
+
catch (err) {
|
|
1504
|
+
sendInternalError(res, err instanceof Error ? err.message : 'Failed to resolve model');
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1207
1508
|
// Look up model
|
|
1208
1509
|
const model = registry.get(body.model);
|
|
1209
1510
|
if (!model) {
|
|
@@ -1243,6 +1544,39 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
1243
1544
|
// early-return path. These flags keep it a no-op when already run.
|
|
1244
1545
|
let cleanupPerformed = false;
|
|
1245
1546
|
let leaseReleased = false;
|
|
1547
|
+
// Idle-sweeper bracket flags. Hoisted to outer function scope so
|
|
1548
|
+
// the `finalizeIdleRequest` helper below can see them even though
|
|
1549
|
+
// the `beginRequest()` call lives inside the inner `try`.
|
|
1550
|
+
// Pre-dispatch validation failures (early returns that never called
|
|
1551
|
+
// `beginRequest`) observe `idleRequestStarted === false` and skip
|
|
1552
|
+
// the matching `endRequest()` entirely. `idleRequestEnded` is the
|
|
1553
|
+
// `done` flag that guarantees the decrement fires exactly once
|
|
1554
|
+
// regardless of which of the several finalize paths — outer
|
|
1555
|
+
// `finally`, `res.once('finish')`, `res.once('close')`,
|
|
1556
|
+
// `res.once('error')` — wins the race.
|
|
1557
|
+
//
|
|
1558
|
+
// Listeners are attached EAGERLY at `beginRequest()` time (not
|
|
1559
|
+
// lazily from the outer `finally`) to close the round-4 leak where
|
|
1560
|
+
// a terminal socket event fired *before* the outer `finally` ran:
|
|
1561
|
+
// the lazy attach saw `writableEnded === false && writableFinished
|
|
1562
|
+
// === false` at check time, attached listeners on a socket whose
|
|
1563
|
+
// final event had already been emitted, and `endRequest()` then
|
|
1564
|
+
// never fired, leaving `inFlight` pinned above zero and the
|
|
1565
|
+
// sweeper permanently armed.
|
|
1566
|
+
let idleRequestStarted = false;
|
|
1567
|
+
let idleRequestEnded = false;
|
|
1568
|
+
let idleListenersAttached = false;
|
|
1569
|
+
const finalizeIdleRequest = () => {
|
|
1570
|
+
if (!idleRequestStarted)
|
|
1571
|
+
return;
|
|
1572
|
+
if (idleRequestEnded)
|
|
1573
|
+
return;
|
|
1574
|
+
idleRequestEnded = true;
|
|
1575
|
+
idleSweeper?.endRequest();
|
|
1576
|
+
};
|
|
1577
|
+
const onFinalizeEvent = () => {
|
|
1578
|
+
finalizeIdleRequest();
|
|
1579
|
+
};
|
|
1246
1580
|
try {
|
|
1247
1581
|
// Initial snapshot of the live binding. On a continuation we
|
|
1248
1582
|
// re-read after `await store.getChain()` and reject if the
|
|
@@ -1751,8 +2085,37 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
1751
2085
|
// adopt when `failureMode === 'client_abort'` regardless of how
|
|
1752
2086
|
// `committed` / `safeToSuppress` landed.
|
|
1753
2087
|
let streamFailureMode = null;
|
|
2088
|
+
// Bracket native-model dispatch with the idle-sweeper counter.
|
|
2089
|
+
// Must happen AFTER request validation / store lookup but BEFORE
|
|
2090
|
+
// any native prefill or decode runs — so the pending-drain timer
|
|
2091
|
+
// is cancelled in time to avoid racing the allocator with a live
|
|
2092
|
+
// decode, and the post-dispatch `endRequest` arms a fresh drain
|
|
2093
|
+
// only after every native stream byte has been emitted.
|
|
2094
|
+
// Only inference traffic participates; `/v1/models`, health, and
|
|
2095
|
+
// CORS preflights intentionally do not touch the counter.
|
|
2096
|
+
//
|
|
2097
|
+
// Attach the terminal-event listeners BEFORE any `await` — this
|
|
2098
|
+
// is the round-4 fix for a sweeper leak where a fast terminal
|
|
2099
|
+
// event (e.g. `endJson()` rejecting after the socket already
|
|
2100
|
+
// emitted `close`) fired before the outer `finally` got a chance
|
|
2101
|
+
// to attach its listeners, leaving `inFlight` pinned above zero.
|
|
2102
|
+
// `finalizeIdleRequest` is the idempotency barrier — whichever
|
|
2103
|
+
// of the listener events, the outer `finally`, or a pre-dispatch
|
|
2104
|
+
// path fires first wins and the rest are no-ops.
|
|
2105
|
+
idleSweeper?.beginRequest();
|
|
2106
|
+
idleRequestStarted = true;
|
|
2107
|
+
res.once('finish', onFinalizeEvent);
|
|
2108
|
+
res.once('close', onFinalizeEvent);
|
|
2109
|
+
res.once('error', onFinalizeEvent);
|
|
2110
|
+
idleListenersAttached = true;
|
|
1754
2111
|
try {
|
|
1755
|
-
|
|
2112
|
+
const mutexQueuedAt = Date.now();
|
|
2113
|
+
const runInference = () => withAdmissionControlledInference(sessionReg, modelWorkCoordinator, async () => {
|
|
2114
|
+
const serverTiming = {
|
|
2115
|
+
server_queue_ms: Date.now() - mutexQueuedAt,
|
|
2116
|
+
server_pre_inference_ms: Date.now() - handlerStartedAt,
|
|
2117
|
+
...resolveServerTuningForUsage(),
|
|
2118
|
+
};
|
|
1756
2119
|
// Hot-swap race guard inside the mutex.
|
|
1757
2120
|
//
|
|
1758
2121
|
// `withExclusive` can park this waiter behind a long-running
|
|
@@ -1796,41 +2159,137 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
1796
2159
|
// removed on hit so overlapping requests against the same prior id
|
|
1797
2160
|
// cannot race on the same single-flight ChatSession).
|
|
1798
2161
|
//
|
|
1799
|
-
// Hot-path eligibility gate: the chat-session
|
|
2162
|
+
// Hot-path eligibility gate: the high-level chat-session API only
|
|
1800
2163
|
// serves a SINGLE `user` or `tool` continuation message — the
|
|
1801
2164
|
// `send` / `sendToolResult` entry points cover exactly that
|
|
1802
2165
|
// shape. A single `assistant` / `system` continuation cannot
|
|
1803
2166
|
// be advanced incrementally against the warm KV cache and
|
|
1804
|
-
// must be handled via reset + cold re-prime.
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
1807
|
-
// `
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
1810
|
-
//
|
|
1811
|
-
// `
|
|
1812
|
-
//
|
|
1813
|
-
//
|
|
1814
|
-
//
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
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);
|
|
1822
2226
|
const session = lookup.session;
|
|
1823
2227
|
// `X-Session-Cache` observability header: classify this turn as
|
|
1824
|
-
// `fresh` (no `previous_response_id` on the request
|
|
1825
|
-
//
|
|
1826
|
-
//
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
1833
|
-
|
|
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';
|
|
1834
2293
|
res.setHeader('X-Session-Cache', sessionCacheStatus);
|
|
1835
2294
|
// Multi-tool-call fan-out gate.
|
|
1836
2295
|
//
|
|
@@ -2067,10 +2526,10 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
2067
2526
|
// (JSON error, SSE `error` frame, or socket destroy).
|
|
2068
2527
|
let handlerError = null;
|
|
2069
2528
|
if (mappedBody.stream) {
|
|
2070
|
-
const outcome = await runSessionStreaming(session, messages, newInputMessages, config, streamSignal);
|
|
2529
|
+
const outcome = await runSessionStreaming(session, messages, newInputMessages, config, streamSignal, !lookup.hit);
|
|
2071
2530
|
const streamingWasCommitted = () => outcome.wasCommitted();
|
|
2072
2531
|
try {
|
|
2073
|
-
const handlerOutcome = await handleStreamingNative(res, outcome.stream, mappedBody, responseId, previousResponseId, streamingWasCommitted, httpReq, visibility);
|
|
2532
|
+
const handlerOutcome = await handleStreamingNative(res, outcome.stream, mappedBody, responseId, previousResponseId, streamingWasCommitted, httpReq, visibility, serverTiming);
|
|
2074
2533
|
streamFailureMode = handlerOutcome.failureMode;
|
|
2075
2534
|
if (handlerOutcome.terminalToPersist != null && store && body.store !== false) {
|
|
2076
2535
|
// Initiate the write SYNCHRONOUSLY inside the mutex so
|
|
@@ -2294,9 +2753,30 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
2294
2753
|
// `handleNonStreaming` (short-circuits `endJson` and
|
|
2295
2754
|
// signals the outer persist gate) plus this documented
|
|
2296
2755
|
// limitation.
|
|
2297
|
-
const outcome = await runSessionNonStreaming(session, messages, newInputMessages, config);
|
|
2756
|
+
const outcome = await runSessionNonStreaming(session, messages, newInputMessages, config, !lookup.hit);
|
|
2757
|
+
// Prefix-cache observability headers for the non-streaming
|
|
2758
|
+
// path. `res.end` has not fired yet (the handler's
|
|
2759
|
+
// `endJson` call below is what flushes), so `setHeader`
|
|
2760
|
+
// still lands on the wire. We re-classify the
|
|
2761
|
+
// `X-Session-Cache` header here so a tier-2 lookup that
|
|
2762
|
+
// did NOT actually produce native prefix reuse
|
|
2763
|
+
// (`cachedTokens === 0`) gets demoted from the optimistic
|
|
2764
|
+
// `prefix_hit` back to `fresh` — matching the plan's
|
|
2765
|
+
// contract that `prefix_hit` only fires when the registry
|
|
2766
|
+
// served a match via `promptCacheKey` AND the ChatResult
|
|
2767
|
+
// reports `cachedTokens > 0`. The companion
|
|
2768
|
+
// `X-Cached-Tokens: N` header reports the exact count for
|
|
2769
|
+
// operators and downstream telemetry whenever reuse
|
|
2770
|
+
// happened.
|
|
2771
|
+
if (tier2Hit && outcome.result.cachedTokens === 0) {
|
|
2772
|
+
sessionCacheStatus = 'fresh';
|
|
2773
|
+
res.setHeader('X-Session-Cache', sessionCacheStatus);
|
|
2774
|
+
}
|
|
2775
|
+
if (outcome.result.cachedTokens > 0) {
|
|
2776
|
+
res.setHeader('X-Cached-Tokens', String(outcome.result.cachedTokens));
|
|
2777
|
+
}
|
|
2298
2778
|
try {
|
|
2299
|
-
const handlerOutcome = await handleNonStreaming(res, outcome.result, mappedBody, responseId, previousResponseId, visibility);
|
|
2779
|
+
const handlerOutcome = await handleNonStreaming(res, outcome.result, mappedBody, responseId, previousResponseId, visibility, serverTiming);
|
|
2300
2780
|
if (store && body.store !== false) {
|
|
2301
2781
|
// Same in-lock-initiate / off-lock-await split as the
|
|
2302
2782
|
// streaming branch. The non-streaming handler only
|
|
@@ -2531,7 +3011,21 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
2531
3011
|
// stream path completed cleanly and the adopted session
|
|
2532
3012
|
// is genuinely reachable via the responseId.
|
|
2533
3013
|
if (committed && (handlerError == null || safeToSuppress) && streamFailureMode === null) {
|
|
2534
|
-
|
|
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);
|
|
2535
3029
|
}
|
|
2536
3030
|
// Rethrow handler errors when the client hasn't seen a
|
|
2537
3031
|
// terminal yet, regardless of commit state. The outer
|
|
@@ -2560,9 +3054,15 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
2560
3054
|
// already received — or no output at all if the terminal
|
|
2561
3055
|
// already landed.
|
|
2562
3056
|
if (visibility.responseMode === null) {
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
2565
|
-
|
|
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);
|
|
3062
|
+
}
|
|
3063
|
+
else {
|
|
3064
|
+
sendInternalError(res, message);
|
|
3065
|
+
}
|
|
2566
3066
|
}
|
|
2567
3067
|
else if (visibility.responseMode === 'json') {
|
|
2568
3068
|
// We already wrote `Content-Type: application/json` and
|
|
@@ -2602,6 +3102,7 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
2602
3102
|
}
|
|
2603
3103
|
}
|
|
2604
3104
|
});
|
|
3105
|
+
await runInference();
|
|
2605
3106
|
}
|
|
2606
3107
|
catch (err) {
|
|
2607
3108
|
// Admission-control rejection from the per-model queue cap
|
|
@@ -2765,11 +3266,16 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
2765
3266
|
// never ran (early-return validation failure, or an exception
|
|
2766
3267
|
// raised inside the outer `try` block between lease
|
|
2767
3268
|
// acquisition and the `withExclusive` call), make sure the
|
|
2768
|
-
// abort listeners are detached
|
|
3269
|
+
// abort listeners + idle-sweeper listeners are detached, the
|
|
3270
|
+
// in-flight counter is decremented, and the dispatch lease is
|
|
2769
3271
|
// released here. `runPostDispatchCleanup` is safe to re-invoke
|
|
2770
|
-
// — the `abortListenersAttached`
|
|
2771
|
-
// `
|
|
2772
|
-
//
|
|
3272
|
+
// — the `abortListenersAttached` / `idleListenersAttached` /
|
|
3273
|
+
// `leaseReleased` flags make every sub-step idempotent on a
|
|
3274
|
+
// second pass. `finalizeIdleRequest` inside the helper is also
|
|
3275
|
+
// guarded by `idleRequestEnded`, so the decrement fires exactly
|
|
3276
|
+
// once regardless of which path wins the race between the
|
|
3277
|
+
// eagerly-attached terminal listener firing, the happy-path
|
|
3278
|
+
// cleanup on `withExclusive` return, and this outer fallback.
|
|
2773
3279
|
if (!cleanupPerformed) {
|
|
2774
3280
|
runPostDispatchCleanup();
|
|
2775
3281
|
}
|
|
@@ -2793,6 +3299,23 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
2793
3299
|
}
|
|
2794
3300
|
abortListenersAttached = false;
|
|
2795
3301
|
}
|
|
3302
|
+
// Drop the idle-sweeper's finalize listeners AND decrement the
|
|
3303
|
+
// in-flight counter. Post-dispatch cleanup runs AFTER
|
|
3304
|
+
// `handleStreamingNative` / `handleNonStreaming` have awaited
|
|
3305
|
+
// their terminal `res.end()` — the native dispatch is done at
|
|
3306
|
+
// this point, so the sweeper can safely arm a new pending
|
|
3307
|
+
// drain. Firing here (rather than only in the outer `finally`)
|
|
3308
|
+
// means the post-commit persist wait does not keep `inFlight`
|
|
3309
|
+
// pinned above zero on a wedged store. `finalizeIdleRequest`
|
|
3310
|
+
// is idempotent via the `done` flag so a subsequent fire from
|
|
3311
|
+
// the outer finally / a stray listener is a no-op.
|
|
3312
|
+
if (idleListenersAttached) {
|
|
3313
|
+
res.removeListener('finish', onFinalizeEvent);
|
|
3314
|
+
res.removeListener('close', onFinalizeEvent);
|
|
3315
|
+
res.removeListener('error', onFinalizeEvent);
|
|
3316
|
+
idleListenersAttached = false;
|
|
3317
|
+
}
|
|
3318
|
+
finalizeIdleRequest();
|
|
2796
3319
|
// Release the dispatch lease on the ORIGINAL model object the
|
|
2797
3320
|
// lease was acquired against (not a re-read of `body.model`,
|
|
2798
3321
|
// which may have been hot-swapped while we held the mutex). A
|