@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
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* * **Non-paged path** (Qwen3.5 dense + MoE — default-off pending a
|
|
32
32
|
* perf decision; the Qianfan-OCR VLM — no adapter wired). Each
|
|
33
33
|
* request looks up the warm slot via
|
|
34
|
-
* `SessionRegistry.getOrCreateWarmAny(requestedSystem)`. On a
|
|
34
|
+
* `SessionRegistry.getOrCreateWarmAny(requestedSystem, cacheSalt)`. On a
|
|
35
35
|
* HIT we keep the underlying native KV cache alive
|
|
36
36
|
* (`resetPreservingNativeCacheForWarmReuse` wipes only JS-side
|
|
37
37
|
* session state) so the native `verify_cache_prefix_direct` can
|
|
@@ -62,9 +62,10 @@ import { sendAnthropicBadRequest, sendAnthropicInternalError, sendAnthropicNotFo
|
|
|
62
62
|
import { canonicalizeSystemForCacheKey, mapAnthropicRequest } from '../mappers/anthropic-request.js';
|
|
63
63
|
import { buildAnthropicResponse, buildContentBlockDelta, buildContentBlockStart, buildContentBlockStop, buildMessageDelta, buildMessageStartEvent, buildMessageStop, containsToolCallMarkup, internalToolCallIdToAnthropic, recoverSuppressedToolCallText, mapStopReason, } from '../mappers/anthropic-response.js';
|
|
64
64
|
import { genId } from '../mappers/response.js';
|
|
65
|
+
import { ModelLoadQueueFullError, } from '../model-work-coordinator.js';
|
|
65
66
|
import { QueueFullError } from '../session-registry.js';
|
|
66
67
|
import { StopSequenceBuffer } from '../stop-sequence-buffer.js';
|
|
67
|
-
import { beginSSE, endSSE, writeSSEEvent } from '../streaming.js';
|
|
68
|
+
import { awaitDrainOrClose, beginSSE, endSSE, trackSSEClientAbort, writeSSEEvent as writeRawSSEEvent, } from '../streaming.js';
|
|
68
69
|
import { longestSuffixPrefixOverlap } from '../text-recovery.js';
|
|
69
70
|
import { resolveServerTuningForUsage } from '../timing.js';
|
|
70
71
|
import { ToolCallTagBuffer } from '../tool-call-buffer.js';
|
|
@@ -82,8 +83,16 @@ import { MAX_OUTPUT_TOKENS, validateAndCanonicalizeHistoryToolOrder } from './re
|
|
|
82
83
|
*/
|
|
83
84
|
const MESSAGES_WARM_SLOT_ID = '__msg_warm__';
|
|
84
85
|
const CLAUDE_CODE_TITLE_MAX_TOKENS = 128;
|
|
85
|
-
function withAdmissionControlledInference(sessionReg, modelWorkCoordinator,
|
|
86
|
-
|
|
86
|
+
function withAdmissionControlledInference(sessionReg, modelWorkCoordinator,
|
|
87
|
+
// Pre-dispatch permit handed off ATOMICALLY as this call's admission
|
|
88
|
+
// (the selected admission lane consumes it instead of charging
|
|
89
|
+
// `queuedCount` a second time). See `beginPreDispatchAdmission`. Placed BEFORE `fn`
|
|
90
|
+
// so call sites keep the trailing-closure layout.
|
|
91
|
+
permit, fn) {
|
|
92
|
+
const run = () => (modelWorkCoordinator ? modelWorkCoordinator.withInference(fn) : fn());
|
|
93
|
+
return sessionReg.concurrentAdmissionLimit > 1
|
|
94
|
+
? sessionReg.withAdmission(run, permit)
|
|
95
|
+
: sessionReg.withExclusive(run, permit);
|
|
87
96
|
}
|
|
88
97
|
function requestAllowsToolUse(body) {
|
|
89
98
|
return Array.isArray(body.tools) && body.tools.length > 0;
|
|
@@ -197,14 +206,21 @@ async function handleNonStreaming(res, result, body, visibility, stopSequences,
|
|
|
197
206
|
// by default, matching how `cachedTokens` is treated through
|
|
198
207
|
// `buildAnthropicResponse`.
|
|
199
208
|
const response = buildAnthropicResponse(responseResult, body, messageId, result.performance, requestAllowsToolUse(body), serverTiming, matchedStopSequence);
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// pre-entry destroyed check, which rejects synchronously after `responseMode`
|
|
204
|
-
// has been committed to 'json' — the outer catch then destroys the socket.
|
|
209
|
+
// The request AbortSignal reaches the normal session method, whose wrapper
|
|
210
|
+
// maps it to native cancellation at the next model safepoint. `endJson` keeps
|
|
211
|
+
// the final pre-entry destroyed check for the transport race after decode.
|
|
205
212
|
await endJson(res, JSON.stringify(response), visibility);
|
|
206
213
|
}
|
|
207
214
|
async function handleStreamingNative(res, chatStream, body, wasCommitted, httpReq, visibility, emitReasoning, stopSequences, serverTiming) {
|
|
215
|
+
const abort = trackSSEClientAbort(res, httpReq);
|
|
216
|
+
try {
|
|
217
|
+
return await handleStreamingNativeWithAbort(res, chatStream, body, wasCommitted, abort, visibility, emitReasoning, stopSequences, serverTiming);
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
abort.dispose();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function handleStreamingNativeWithAbort(res, chatStream, body, wasCommitted, abort, visibility, emitReasoning, stopSequences, serverTiming) {
|
|
208
224
|
const messageId = genId('msg_');
|
|
209
225
|
// `runSessionStreaming` completed the exact token/capacity preflight before
|
|
210
226
|
// handing us this iterator. Commit SSE immediately instead of entering the
|
|
@@ -214,7 +230,24 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
|
|
|
214
230
|
// Commit SSE wire format now so any throw before the terminal event routes
|
|
215
231
|
// to the streaming error epilogue instead of corrupting the JSON path.
|
|
216
232
|
markSSEMode(visibility);
|
|
217
|
-
|
|
233
|
+
// A native event can expand into several SSE frames. Preserve the first
|
|
234
|
+
// false write until the loop awaits it, and install the drain/close/error
|
|
235
|
+
// listeners immediately so an intervening iterator fetch cannot miss drain.
|
|
236
|
+
let pendingDrain = null;
|
|
237
|
+
const writeSSEEvent = (response, eventType, data) => {
|
|
238
|
+
const ok = writeRawSSEEvent(response, eventType, data);
|
|
239
|
+
if (!ok && pendingDrain === null) {
|
|
240
|
+
pendingDrain = awaitDrainOrClose(response, { onTimeout: () => abort.markAborted() });
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const drainPending = async () => {
|
|
244
|
+
const drain = pendingDrain;
|
|
245
|
+
if (drain === null)
|
|
246
|
+
return;
|
|
247
|
+
await drain;
|
|
248
|
+
if (pendingDrain === drain)
|
|
249
|
+
pendingDrain = null;
|
|
250
|
+
};
|
|
218
251
|
let contentBlockIndex = 0;
|
|
219
252
|
let hasEmittedThinking = false;
|
|
220
253
|
let hasEmittedText = false;
|
|
@@ -279,38 +312,17 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
|
|
|
279
312
|
let terminalErrorMessage = null;
|
|
280
313
|
const allowToolUse = requestAllowsToolUse(body);
|
|
281
314
|
let suppressedToolCalls = false;
|
|
282
|
-
// `thrownError` sticks on a generator throw
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// AbortSignal yet, so on a client disconnect we can only stop consuming
|
|
286
|
-
// deltas — the native decode still runs to completion under the mutex.
|
|
315
|
+
// `thrownError` sticks on a generator throw. The outer abort tracker remains
|
|
316
|
+
// armed through post-loop residual writes and the terminal flush as well as
|
|
317
|
+
// the decode loop itself.
|
|
287
318
|
let thrownError = null;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
};
|
|
292
|
-
const onClientError = (_err) => {
|
|
293
|
-
clientAborted = true;
|
|
294
|
-
};
|
|
295
|
-
const onResClose = () => {
|
|
296
|
-
clientAborted = true;
|
|
297
|
-
};
|
|
298
|
-
const onResError = (_err) => {
|
|
299
|
-
clientAborted = true;
|
|
300
|
-
};
|
|
301
|
-
const resSocketForAbort = res.socket;
|
|
302
|
-
if (httpReq) {
|
|
303
|
-
httpReq.once('close', onClientClose);
|
|
304
|
-
httpReq.once('error', onClientError);
|
|
305
|
-
}
|
|
306
|
-
res.once('close', onResClose);
|
|
307
|
-
res.once('error', onResError);
|
|
308
|
-
if (resSocketForAbort != null) {
|
|
309
|
-
resSocketForAbort.once('close', onResClose);
|
|
310
|
-
}
|
|
319
|
+
// The outer wrapper's abort listeners precede the first body write so an
|
|
320
|
+
// asynchronous socket error is authoritative before the drain wait resumes.
|
|
321
|
+
writeSSEEvent(res, 'message_start', buildMessageStartEvent(body, messageId, 0));
|
|
311
322
|
try {
|
|
312
323
|
for await (const event of chatStream) {
|
|
313
|
-
|
|
324
|
+
await drainPending();
|
|
325
|
+
if (abort.aborted)
|
|
314
326
|
break;
|
|
315
327
|
if (event.done) {
|
|
316
328
|
sawDone = true;
|
|
@@ -636,15 +648,7 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
|
|
|
636
648
|
thrownError = err instanceof Error ? err : new Error(String(err));
|
|
637
649
|
}
|
|
638
650
|
finally {
|
|
639
|
-
|
|
640
|
-
httpReq.off('close', onClientClose);
|
|
641
|
-
httpReq.off('error', onClientError);
|
|
642
|
-
}
|
|
643
|
-
res.off('close', onResClose);
|
|
644
|
-
res.off('error', onResError);
|
|
645
|
-
if (resSocketForAbort != null) {
|
|
646
|
-
resSocketForAbort.off('close', onResClose);
|
|
647
|
-
}
|
|
651
|
+
await drainPending();
|
|
648
652
|
}
|
|
649
653
|
// Success requires ALL of: sawDone, wasCommitted, no terminal error, no thrown
|
|
650
654
|
// error, no client abort. `terminalErrorMessage` is set when a stream done event
|
|
@@ -653,7 +657,7 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
|
|
|
653
657
|
// withhold `message_stop`. Every failure path emits a streaming `error` and
|
|
654
658
|
// withholds `message_stop`.
|
|
655
659
|
const committed = wasCommitted();
|
|
656
|
-
const successful = sawDone && committed && terminalErrorMessage == null && thrownError == null && !
|
|
660
|
+
const successful = sawDone && committed && terminalErrorMessage == null && thrownError == null && !abort.aborted;
|
|
657
661
|
if (successful) {
|
|
658
662
|
const stopReason = terminalStopReason ?? 'end_turn';
|
|
659
663
|
writeSSEEvent(res, 'message_delta', buildMessageDelta(stopReason, terminalNumTokens, terminalPromptTokens, terminalCachedTokens, terminalPerformance, serverTiming, matchedStopSequence));
|
|
@@ -675,9 +679,14 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
|
|
|
675
679
|
// up front — non-fatal; the SSE usage field still carries the value.
|
|
676
680
|
}
|
|
677
681
|
}
|
|
678
|
-
await
|
|
679
|
-
|
|
680
|
-
|
|
682
|
+
await drainPending();
|
|
683
|
+
// The residual drain may have settled because the transport closed or
|
|
684
|
+
// errored. Never emit/adopt a success terminal from the stale snapshot.
|
|
685
|
+
if (!abort.aborted) {
|
|
686
|
+
await flushTerminalSSE(res, 'message_stop', buildMessageStop(), visibility);
|
|
687
|
+
endSSE(res);
|
|
688
|
+
return { ok: true, suppressedToolCalls };
|
|
689
|
+
}
|
|
681
690
|
}
|
|
682
691
|
// Close any dangling content block so the error frame lands at a clean state,
|
|
683
692
|
// then emit the streaming error. Never emit `message_stop` here — pairing it
|
|
@@ -688,11 +697,12 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
|
|
|
688
697
|
else if (hasEmittedText) {
|
|
689
698
|
writeSSEEvent(res, 'content_block_stop', buildContentBlockStop(contentBlockIndex));
|
|
690
699
|
}
|
|
700
|
+
await drainPending();
|
|
691
701
|
let message;
|
|
692
702
|
if (thrownError != null) {
|
|
693
703
|
message = thrownError.message;
|
|
694
704
|
}
|
|
695
|
-
else if (
|
|
705
|
+
else if (abort.aborted) {
|
|
696
706
|
message = 'client disconnected before the stream completed';
|
|
697
707
|
}
|
|
698
708
|
else if (terminalErrorMessage != null) {
|
|
@@ -710,7 +720,7 @@ async function handleStreamingNative(res, chatStream, body, wasCommitted, httpRe
|
|
|
710
720
|
return { ok: false, suppressedToolCalls };
|
|
711
721
|
}
|
|
712
722
|
/** Prime a session with the full history and run a single turn. */
|
|
713
|
-
async function runSessionNonStreaming(session, messages, config, resetNativeCache) {
|
|
723
|
+
async function runSessionNonStreaming(session, messages, config, resetNativeCache, signal) {
|
|
714
724
|
// Dual-branch reset gated by the caller's native-cache policy:
|
|
715
725
|
//
|
|
716
726
|
// * Full native reset (`resetNativeCache === true`) — run a full
|
|
@@ -742,7 +752,7 @@ async function runSessionNonStreaming(session, messages, config, resetNativeCach
|
|
|
742
752
|
}
|
|
743
753
|
session.primeHistory(messages);
|
|
744
754
|
const initialTurns = session.turns;
|
|
745
|
-
const result = await session.startFromHistory(config);
|
|
755
|
+
const result = await session.startFromHistory(config, { signal });
|
|
746
756
|
// Mirror the streaming-side dual-gate (`streamResult.ok &&
|
|
747
757
|
// outcome.wasCommitted()`) and the sibling `/v1/responses` adopt
|
|
748
758
|
// gate. `ChatSession.startFromHistory` advances `turnCount`
|
|
@@ -782,6 +792,9 @@ async function runSessionStreaming(session, messages, config, signal, resetNativ
|
|
|
782
792
|
}
|
|
783
793
|
// Public handler
|
|
784
794
|
export async function handleCreateMessage(res, body, registry, httpReq, idleSweeper, resolveModel, modelWorkCoordinator) {
|
|
795
|
+
if (modelWorkCoordinator) {
|
|
796
|
+
registry.setModelLoadAdmissionCoordinator(modelWorkCoordinator);
|
|
797
|
+
}
|
|
785
798
|
const handlerStartedAt = Date.now();
|
|
786
799
|
let serverModelResolveMs;
|
|
787
800
|
// Split observability for the resolve path: a request that arrives
|
|
@@ -850,9 +863,50 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
|
|
|
850
863
|
sendAnthropicBadRequest(res, err instanceof Error ? err.message : 'Invalid request');
|
|
851
864
|
return;
|
|
852
865
|
}
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
866
|
+
// Pre-dispatch admission gate (H3, host mode). Mirror of the
|
|
867
|
+
// `/v1/responses` gate — see the long-form rationale there. In host
|
|
868
|
+
// mode resident arrivals bypass the `ModelWorkCoordinator` writer bracket
|
|
869
|
+
// below and proceed toward the continuous-batching lane, but can still park
|
|
870
|
+
// in pre-lock work. Admit-or-429 (Anthropic envelope) against the same
|
|
871
|
+
// per-model budget so that work stays bounded too. Non-resident (cold-load)
|
|
872
|
+
// requests use the coordinator's bounded pre-resolution permit below
|
|
873
|
+
// because no `SessionRegistry` exists yet. The permit is RETAINED through every pre-lock
|
|
874
|
+
// await and handed to `withExclusive` at placement, which consumes it
|
|
875
|
+
// atomically as this request's admission (one budget, one token,
|
|
876
|
+
// never double-counted); it is released only on bail-out exits —
|
|
877
|
+
// explicitly on the early returns before the outer `try`, and by the
|
|
878
|
+
// outer `finally` for everything inside it (idempotent + no-op after
|
|
879
|
+
// handoff).
|
|
880
|
+
let preDispatchAdmission;
|
|
881
|
+
let modelLoadAdmission;
|
|
882
|
+
const preDispatchRegistry = registry.getSessionRegistry(body.model);
|
|
883
|
+
if (preDispatchRegistry) {
|
|
884
|
+
try {
|
|
885
|
+
preDispatchAdmission = preDispatchRegistry.beginPreDispatchAdmission();
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
if (err instanceof QueueFullError) {
|
|
889
|
+
sendAnthropicRateLimit(res, `${err.message}. Retry after 1s.`);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
throw err;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
else if (resolveModel && modelWorkCoordinator) {
|
|
896
|
+
try {
|
|
897
|
+
modelLoadAdmission = modelWorkCoordinator.beginRequestLoadAdmission(body.model);
|
|
898
|
+
}
|
|
899
|
+
catch (err) {
|
|
900
|
+
if (err instanceof ModelLoadQueueFullError) {
|
|
901
|
+
sendAnthropicRateLimit(res, `Model queue full: admission footprint ${err.admissionFootprint} (limit ${err.limit}). Retry after 1s.`);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
throw err;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
// Lazy-load hook for a requested name with no resident registry: give the
|
|
908
|
+
// host a chance to register it before we look it up. Resident requests skip
|
|
909
|
+
// this writer bracket so they can reach the continuous-batching lane.
|
|
856
910
|
//
|
|
857
911
|
// The load is bracketed by `idleSweeper.withSuspendedDrains` so the
|
|
858
912
|
// post-request drain timer armed by the PREVIOUS request's
|
|
@@ -866,7 +920,7 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
|
|
|
866
920
|
// The wrapper handles try/finally itself and is a pass-through on
|
|
867
921
|
// the disabled sweeper, so the bracket is unconditional whenever
|
|
868
922
|
// a sweeper is supplied.
|
|
869
|
-
if (resolveModel) {
|
|
923
|
+
if (resolveModel && !preDispatchRegistry) {
|
|
870
924
|
// A throw here (bad model path, corrupt weights, native loader failure)
|
|
871
925
|
// would otherwise bubble up to the outer `createHandler` catch which
|
|
872
926
|
// emits the OpenAI-shape `{ error: ... }` envelope via `sendInternalError`.
|
|
@@ -892,7 +946,8 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
|
|
|
892
946
|
// observability fields:
|
|
893
947
|
// - owner driving a cold load → waitMs ≈ 0, ownMs ≈ load duration
|
|
894
948
|
// - follower parked behind peer → waitMs ≈ peer load, ownMs ≈ 0
|
|
895
|
-
//
|
|
949
|
+
// Resident requests bypass this block entirely and therefore omit all
|
|
950
|
+
// three load-timing fields.
|
|
896
951
|
// This matches the documented contract in `timing.ts` where
|
|
897
952
|
// `server_model_resolve_ms` excludes peer-wait time.
|
|
898
953
|
const outcome = await modelWorkCoordinator.withModelLoadInstrumented(runResolve);
|
|
@@ -906,12 +961,19 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
|
|
|
906
961
|
}
|
|
907
962
|
}
|
|
908
963
|
catch (err) {
|
|
964
|
+
preDispatchAdmission?.release();
|
|
965
|
+
modelLoadAdmission?.release();
|
|
909
966
|
sendAnthropicInternalError(res, err instanceof Error ? err.message : 'Failed to resolve model');
|
|
910
967
|
return;
|
|
911
968
|
}
|
|
912
969
|
}
|
|
970
|
+
// NOTE: the permit is NOT released here. The request must stay counted
|
|
971
|
+
// through the remaining pre-lock work until `withExclusive` consumes
|
|
972
|
+
// the permit at placement. Only bail-out exits release.
|
|
913
973
|
const model = registry.get(body.model);
|
|
914
974
|
if (!model) {
|
|
975
|
+
preDispatchAdmission?.release();
|
|
976
|
+
modelLoadAdmission?.release();
|
|
915
977
|
sendAnthropicNotFound(res, `Model "${body.model}" not found`);
|
|
916
978
|
return;
|
|
917
979
|
}
|
|
@@ -921,10 +983,26 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
|
|
|
921
983
|
// against one shared native model. Must be released in the `finally` below.
|
|
922
984
|
const lease = registry.acquireDispatchLease(body.model);
|
|
923
985
|
if (!lease) {
|
|
986
|
+
preDispatchAdmission?.release();
|
|
987
|
+
modelLoadAdmission?.release();
|
|
924
988
|
sendAnthropicInternalError(res, 'session registry missing for registered model');
|
|
925
989
|
return;
|
|
926
990
|
}
|
|
927
991
|
const leaseModel = lease.model;
|
|
992
|
+
if (modelLoadAdmission) {
|
|
993
|
+
try {
|
|
994
|
+
preDispatchAdmission = modelLoadAdmission.transferToResident(lease.registry);
|
|
995
|
+
}
|
|
996
|
+
catch (err) {
|
|
997
|
+
registry.releaseDispatchLease(leaseModel);
|
|
998
|
+
modelLoadAdmission.release();
|
|
999
|
+
if (err instanceof QueueFullError) {
|
|
1000
|
+
sendAnthropicRateLimit(res, `${err.message}. Retry after 1s.`);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
throw err;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
928
1006
|
// AbortController wired to disconnect events. Declared at function scope
|
|
929
1007
|
// so the outer `finally` can detach listeners on early returns; the
|
|
930
1008
|
// `abortListenersAttached` flag gates the detach so pre-validation exits
|
|
@@ -1021,6 +1099,17 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
|
|
|
1021
1099
|
httpReq.once('close', onAbortClose);
|
|
1022
1100
|
httpReq.once('error', onAbortError);
|
|
1023
1101
|
}
|
|
1102
|
+
// Catch-up abort: a response torn down BEFORE the attach above has
|
|
1103
|
+
// already emitted its terminal event, so the `once('close')`
|
|
1104
|
+
// listeners will never fire. Consult the response-side socket state
|
|
1105
|
+
// directly (the REQUEST side is deliberately excluded — a fully
|
|
1106
|
+
// consumed IncomingMessage auto-destroys after 'end' on every
|
|
1107
|
+
// normal request, so `httpReq.destroyed` is not a disconnect
|
|
1108
|
+
// signal). Makes `streamSignal.aborted` authoritative for the H2
|
|
1109
|
+
// pre-dispatch disconnect check inside the mutex callback.
|
|
1110
|
+
if (res.destroyed || res.writableEnded || abortSocket?.destroyed === true) {
|
|
1111
|
+
abortController.abort();
|
|
1112
|
+
}
|
|
1024
1113
|
abortListenersAttached = true;
|
|
1025
1114
|
const streamSignal = abortController.signal;
|
|
1026
1115
|
// Bracket the native-model dispatch with the idle sweeper.
|
|
@@ -1043,309 +1132,357 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
|
|
|
1043
1132
|
idleListenersAttached = true;
|
|
1044
1133
|
try {
|
|
1045
1134
|
const mutexQueuedAt = Date.now();
|
|
1046
|
-
const runInference = () =>
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
`
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1135
|
+
const runInference = () => {
|
|
1136
|
+
return withAdmissionControlledInference(sessionReg, modelWorkCoordinator, preDispatchAdmission, async () => {
|
|
1137
|
+
const serverTiming = {
|
|
1138
|
+
server_model_resolve_ms: serverModelResolveMs,
|
|
1139
|
+
server_load_wait_ms: serverLoadWaitMs,
|
|
1140
|
+
server_load_owner: serverLoadOwner,
|
|
1141
|
+
server_queue_ms: Date.now() - mutexQueuedAt,
|
|
1142
|
+
server_pre_inference_ms: Date.now() - handlerStartedAt,
|
|
1143
|
+
...resolveServerTuningForUsage(),
|
|
1144
|
+
};
|
|
1145
|
+
// Hot-swap race guard. `ModelRegistry.register()` is not coordinated with
|
|
1146
|
+
// `withExclusive`, so a concurrent re-register of the same friendly name
|
|
1147
|
+
// could silently dispatch this request through a stale model. Any drift
|
|
1148
|
+
// from the pre-lock snapshot is fatal.
|
|
1149
|
+
const lockedSessionReg = registry.getSessionRegistry(body.model);
|
|
1150
|
+
const lockedInstanceId = registry.getInstanceId(body.model);
|
|
1151
|
+
if (lockedSessionReg === undefined ||
|
|
1152
|
+
lockedInstanceId === undefined ||
|
|
1153
|
+
lockedSessionReg !== sessionReg ||
|
|
1154
|
+
lockedInstanceId !== preLockInstanceId) {
|
|
1155
|
+
sendAnthropicBadRequest(res, `Model "${body.model}" binding changed while the request was queued behind the per-model ` +
|
|
1156
|
+
`execution mutex. A concurrent register() re-pointed the name at a different model instance ` +
|
|
1157
|
+
`(or released it entirely) while this waiter was parked, so the session registry and instance ` +
|
|
1158
|
+
`id captured before the mutex wait no longer match the live binding. Dispatching anyway would ` +
|
|
1159
|
+
`service this request through a stale model object — a silent cross-model handoff. Retry the ` +
|
|
1160
|
+
`request — if the swap was intentional, the new binding will service the retry cleanly.`);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
// Per-model session selection for `/v1/messages` reuse.
|
|
1164
|
+
//
|
|
1165
|
+
// Two paths, gated on whether the underlying native model has
|
|
1166
|
+
// the block-paged KV cache adapter active
|
|
1167
|
+
// (`hasBlockPagedCache()` — captured at load time from
|
|
1168
|
+
// `<Inner>::paged_adapter.is_some()` and surfaced by the
|
|
1169
|
+
// `SessionCapableModel` structural interface):
|
|
1170
|
+
//
|
|
1171
|
+
// * **Paged-active** (Qwen3 + LFM2 + Gemma4 today; Qwen3.5
|
|
1172
|
+
// dense + Qwen3.5 MoE once their perf trade-off is
|
|
1173
|
+
// decided). Allocate a fresh `ChatSession` per request via
|
|
1174
|
+
// `createFreshSession()`, do NOT touch the warm slot.
|
|
1175
|
+
// Cross-turn / cross-conversation prefix reuse is handled
|
|
1176
|
+
// entirely by the native `BlockAllocator`'s prefix-hash
|
|
1177
|
+
// table: SYS blocks shared across requests are refcounted
|
|
1178
|
+
// transparently, so two parallel `/v1/messages` requests
|
|
1179
|
+
// sharing a system prompt both run on distinct
|
|
1180
|
+
// `ChatSession` objects but reference the SAME physical
|
|
1181
|
+
// KV blocks. The JS-side warm slot would only serialize
|
|
1182
|
+
// them and force one into cold replay.
|
|
1183
|
+
//
|
|
1184
|
+
// * **Non-paged** (Qwen3.5 dense + MoE — default-OFF pending
|
|
1185
|
+
// a perf decision against the compiled C++ flat path;
|
|
1186
|
+
// the Qianfan-OCR VLM — no adapter wired). Fall through to
|
|
1187
|
+
// `getOrCreateWarmAny`, which is the ONLY cross-conversation
|
|
1188
|
+
// reuse mechanism these models have. The Anthropic Messages
|
|
1189
|
+
// API is stateless on the wire (no `previous_response_id`,
|
|
1190
|
+
// clients don't propagate `prompt_cache_key`), so without
|
|
1191
|
+
// the warm slot every turn is a full cold start.
|
|
1192
|
+
//
|
|
1193
|
+
// The `hasBlockPagedCache?()` getter is optional on the
|
|
1194
|
+
// structural interface so the `QianfanOCRModel` VLM (which
|
|
1195
|
+
// has no paged-adapter wiring) still satisfies the type
|
|
1196
|
+
// contract — a missing getter falls into the non-paged branch
|
|
1197
|
+
// here.
|
|
1198
|
+
//
|
|
1199
|
+
// Adoption stays keyed by the literal sentinel
|
|
1200
|
+
// `MESSAGES_WARM_SLOT_ID = '__msg_warm__'`. The Anthropic
|
|
1201
|
+
// Messages API never produces a `previous_response_id` clients
|
|
1202
|
+
// could echo back (and the OpenAI side mints `resp_*` ids),
|
|
1203
|
+
// so cross-endpoint capture via tier-1 is impossible by
|
|
1204
|
+
// construction — no `/v1/responses` request can collide with
|
|
1205
|
+
// the sentinel through the tier-1 path.
|
|
1206
|
+
//
|
|
1207
|
+
// The two endpoints DO share the single warm slot under the
|
|
1208
|
+
// registry's single-warm invariant on the non-paged path: a
|
|
1209
|
+
// `/v1/messages` turn following a `/v1/responses` turn can
|
|
1210
|
+
// evict (and vice versa). On the paged path neither side
|
|
1211
|
+
// touches the warm slot, so cross-endpoint contention
|
|
1212
|
+
// disappears.
|
|
1213
|
+
//
|
|
1214
|
+
// The `prompt_cache_key` request field is still NOT exposed
|
|
1215
|
+
// on this endpoint. Cross-conversation block-level cache
|
|
1216
|
+
// reuse on paged-active models is now driven by native
|
|
1217
|
+
// content-addressing instead of the JS warm slot, so adding
|
|
1218
|
+
// the field is no longer a prerequisite for that use case.
|
|
1219
|
+
// H2 pre-dispatch disconnect check (non-streaming only). A
|
|
1220
|
+
// client that vanished while this request was parked behind
|
|
1221
|
+
// the per-model mutex must not burn a whole prefill+decode
|
|
1222
|
+
// budget producing a JSON body nobody can receive. Checked
|
|
1223
|
+
// BEFORE the warm-slot lease so no session state is consumed
|
|
1224
|
+
// or mutated — the early return composes with the permit
|
|
1225
|
+
// lifecycle exactly like the binding-changed return above
|
|
1226
|
+
// (the pre-dispatch permit was already consumed atomically by
|
|
1227
|
+
// `withExclusive`; the outer `finally` release is an
|
|
1228
|
+
// idempotent no-op after handoff). Streaming keeps its
|
|
1229
|
+
// existing paths: the signal fast-aborts `_runChatStream` and
|
|
1230
|
+
// the SSE drain loop breaks on `clientAborted` at loop-top.
|
|
1231
|
+
if (body.stream !== true && streamSignal.aborted) {
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const pagedActive = leaseModel.hasBlockPagedCache?.() === true;
|
|
1235
|
+
const lookup = pagedActive
|
|
1236
|
+
? sessionReg.createFreshSession()
|
|
1237
|
+
: sessionReg.getOrCreateWarmAny(requestedSystem, config.cacheSalt ?? null);
|
|
1238
|
+
const session = lookup.session;
|
|
1239
|
+
await sessionReg.flushPendingDisposals();
|
|
1240
|
+
let sessionRetained = false;
|
|
1241
|
+
// `X-Session-Cache` observability header.
|
|
1242
|
+
//
|
|
1243
|
+
// Non-paged path:
|
|
1244
|
+
// * Non-streaming: set the optimistic `prefix_hit` value
|
|
1245
|
+
// BEFORE dispatch on `lookup.hit` (so the header is on the
|
|
1246
|
+
// wire even if the dispatch throws) and demote
|
|
1247
|
+
// post-dispatch to `fresh` when the warm slot was leased
|
|
1248
|
+
// but native prefix reuse did not actually happen
|
|
1249
|
+
// (`result.cachedTokens === 0`). `res.end` has not fired
|
|
1250
|
+
// yet, so the overwrite still lands on the wire.
|
|
1251
|
+
// * Streaming: emits `streaming` to signal the authoritative
|
|
1252
|
+
// post-dispatch value rides on the SSE stream
|
|
1253
|
+
// (`message_delta.usage.cache_read_input_tokens` and the
|
|
1254
|
+
// `X-Cached-Tokens` HTTP trailer, set below in
|
|
1255
|
+
// `handleStreamingNative` once `terminalCachedTokens` is
|
|
1256
|
+
// known). Reporting `fresh` here would be a lie — the
|
|
1257
|
+
// paged engine routinely returns `cachedTokens > 0` on
|
|
1258
|
+
// turn-2+ and the prior `'fresh'` default falsely advertised
|
|
1259
|
+
// a cache miss. The previous comment documented this as
|
|
1260
|
+
// intentional but it was a logging bug.
|
|
1261
|
+
//
|
|
1262
|
+
// Paged path:
|
|
1263
|
+
// * Non-streaming: `lookup.hit` is always `false` (we
|
|
1264
|
+
// `createFreshSession`); the post-dispatch promotion
|
|
1265
|
+
// branch flips `prefix_hit` when the native engine
|
|
1266
|
+
// reports `cachedTokens > 0`, which is the authoritative
|
|
1267
|
+
// signal that the block allocator's content-addressed
|
|
1268
|
+
// reuse picked up shared SYS blocks on this turn.
|
|
1269
|
+
// * Streaming: same `streaming` value as non-paged; the SSE
|
|
1270
|
+
// `usage.cache_read_input_tokens` field carries the
|
|
1271
|
+
// authoritative value.
|
|
1272
|
+
//
|
|
1273
|
+
// Header values: `'fresh' | 'prefix_hit' | 'streaming'`. The
|
|
1274
|
+
// `'streaming'` value tells operators to read the SSE
|
|
1275
|
+
// `message_delta.usage.cache_read_input_tokens` for the
|
|
1276
|
+
// resolved cache-hit count (or `X-Cached-Tokens` trailer if
|
|
1277
|
+
// the client supports HTTP trailers).
|
|
1278
|
+
let sessionCacheStatus = body.stream === true ? 'streaming' : lookup.hit ? 'prefix_hit' : 'fresh';
|
|
1279
|
+
res.setHeader('X-Session-Cache', sessionCacheStatus);
|
|
1280
|
+
// HTTP/1.1 chunked-encoding trailer announcement for streaming.
|
|
1281
|
+
// The actual value is filled in by `handleStreamingNative`
|
|
1282
|
+
// once it has captured `terminalCachedTokens` from the final
|
|
1283
|
+
// SSE chunk.
|
|
1183
1284
|
if (body.stream === true) {
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1285
|
+
res.setHeader('Trailer', 'X-Cached-Tokens');
|
|
1286
|
+
}
|
|
1287
|
+
// Outer catch branches on `responseMode` (not `res.headersSent`, which
|
|
1288
|
+
// flips in `writeHead` before the body lands) so a crash after
|
|
1289
|
+
// `writeHead(application/json)` cannot leak SSE frames into a JSON body.
|
|
1290
|
+
const visibility = createVisibility();
|
|
1291
|
+
try {
|
|
1292
|
+
if (body.stream === true) {
|
|
1293
|
+
// On the paged path the underlying native cache is the
|
|
1294
|
+
// sole reuse mechanism, so preserve it even though the JS
|
|
1295
|
+
// `ChatSession` is freshly allocated. The native paged
|
|
1296
|
+
// adapter validates reuse by token/hash before any cached
|
|
1297
|
+
// prefix is trusted, and the MoE GDN checkpoint layer now
|
|
1298
|
+
// follows the same content-checked policy. Non-paged keeps
|
|
1299
|
+
// the original `!lookup.hit` semantics so only warm-slot
|
|
1300
|
+
// hits preserve native cache.
|
|
1301
|
+
const resetNativeCache = pagedActive ? false : !lookup.hit;
|
|
1302
|
+
const outcome = await runSessionStreaming(session, messages, config, streamSignal, resetNativeCache);
|
|
1303
|
+
const streamResult = await handleStreamingNative(res, outcome.stream, body, outcome.wasCommitted, httpReq, visibility, config.includeReasoning !== false, stopSequences, serverTiming);
|
|
1304
|
+
// Warm-slot adopt/drop only applies to the non-paged
|
|
1305
|
+
// path. On the paged path the JS-side warm slot plays no
|
|
1306
|
+
// role (block reuse is content-addressed in native), so
|
|
1307
|
+
// we never touch it. The fresh `ChatSession` is explicitly
|
|
1308
|
+
// disposed in the `finally` below so its native scheduler owner
|
|
1309
|
+
// and live paged request are released before the handler leaves
|
|
1310
|
+
// the admission lane; GC alone cannot perform that native
|
|
1311
|
+
// lifecycle transition.
|
|
1312
|
+
//
|
|
1313
|
+
// Non-paged dual-gate adopt: BOTH the producer-side commit
|
|
1314
|
+
// signal (`outcome.wasCommitted()`, which reads
|
|
1315
|
+
// `session.turns` bumped in `startFromHistoryStream`'s
|
|
1316
|
+
// `finally`) AND the handler-side success signal
|
|
1317
|
+
// (`streamResult.ok`, true only when we reached the clean
|
|
1318
|
+
// `message_stop` terminal) must be true to adopt. The
|
|
1319
|
+
// producer's `finally` runs on every break — including
|
|
1320
|
+
// client abort, mid-decode throw, and
|
|
1321
|
+
// `finishReason=error` — so `wasCommitted()` alone is NOT
|
|
1322
|
+
// sufficient: it can return `true` after the SSE side
|
|
1323
|
+
// emitted an `error` terminal (not re-thrown by
|
|
1324
|
+
// `handleStreamingNative`), leaving a session whose
|
|
1325
|
+
// observable wire state is failure but whose `turns`
|
|
1326
|
+
// counter advanced. Adopting in that window would seed the
|
|
1327
|
+
// warm slot with a session the next request can lease but
|
|
1328
|
+
// whose history does not match what the client received.
|
|
1329
|
+
//
|
|
1330
|
+
// Mirrors `responses.ts` (around line 3277) where the
|
|
1331
|
+
// analogous gate combines `committed`, `handlerError`, and
|
|
1332
|
+
// `streamFailureMode === null` — the producer-side commit
|
|
1333
|
+
// and a clean handler-side terminal must both hold before
|
|
1334
|
+
// the session is reachable from a subsequent request.
|
|
1335
|
+
if (!pagedActive) {
|
|
1336
|
+
if (streamResult.ok && outcome.wasCommitted() && !streamResult.suppressedToolCalls) {
|
|
1337
|
+
sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null, config.cacheSalt ?? null);
|
|
1338
|
+
sessionRetained = true;
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
sessionReg.drop(MESSAGES_WARM_SLOT_ID);
|
|
1342
|
+
}
|
|
1227
1343
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1344
|
+
}
|
|
1345
|
+
else {
|
|
1346
|
+
// See the streaming branch above for the rationale on
|
|
1347
|
+
// preserving native cache on the paged path.
|
|
1348
|
+
const resetNativeCache = pagedActive ? false : !lookup.hit;
|
|
1349
|
+
// Non-streaming cancellation (H2): `streamSignal` threads
|
|
1350
|
+
// through `ChatSession.startFromHistory` into the normal public
|
|
1351
|
+
// method; the wrapper maps it to the internal native operation — a mid-turn
|
|
1352
|
+
// disconnect flips the controller, the native turn unwinds
|
|
1353
|
+
// at the next safepoint, and the dispatch rejects with
|
|
1354
|
+
// "chat session cancelled" (routed through the catch below:
|
|
1355
|
+
// warm slot dropped, nothing persisted). The
|
|
1356
|
+
// disconnect-aware skip inside `handleNonStreaming` /
|
|
1357
|
+
// `endJson` remains the last line of defense for a
|
|
1358
|
+
// disconnect racing the final flush.
|
|
1359
|
+
const outcome = await runSessionNonStreaming(session, messages, config, resetNativeCache, streamSignal);
|
|
1360
|
+
const result = outcome.result;
|
|
1361
|
+
// Re-classify the `X-Session-Cache` header.
|
|
1362
|
+
//
|
|
1363
|
+
// Non-paged: a warm-slot hit that did NOT actually produce
|
|
1364
|
+
// native prefix reuse (`cachedTokens === 0` — e.g.
|
|
1365
|
+
// tokenizer change, system prompt drift squeaking past
|
|
1366
|
+
// the byte-equal compare via some upstream rewrite) gets
|
|
1367
|
+
// demoted from `prefix_hit` back to `fresh`.
|
|
1368
|
+
//
|
|
1369
|
+
// Paged: `lookup.hit` is always `false` so we entered
|
|
1370
|
+
// with `sessionCacheStatus = 'fresh'`. Promote to
|
|
1371
|
+
// `prefix_hit` when the native engine reports
|
|
1372
|
+
// `cachedTokens > 0` — that's the authoritative signal
|
|
1373
|
+
// that `BlockAllocator`'s content-addressed prefix lookup
|
|
1374
|
+
// recovered shared SYS blocks on this turn. `res.end` has
|
|
1375
|
+
// not fired yet (`handleNonStreaming` is what flushes via
|
|
1376
|
+
// `endJson`), so the overwrite still lands on the wire.
|
|
1377
|
+
if (lookup.hit && result.cachedTokens === 0) {
|
|
1378
|
+
sessionCacheStatus = 'fresh';
|
|
1379
|
+
res.setHeader('X-Session-Cache', sessionCacheStatus);
|
|
1380
|
+
}
|
|
1381
|
+
else if (pagedActive && result.cachedTokens > 0) {
|
|
1382
|
+
sessionCacheStatus = 'prefix_hit';
|
|
1383
|
+
res.setHeader('X-Session-Cache', sessionCacheStatus);
|
|
1384
|
+
}
|
|
1385
|
+
// Companion `X-Cached-Tokens` header: emitted only when
|
|
1386
|
+
// reuse genuinely happened, so operators can spot a stale
|
|
1387
|
+
// `prefix_hit` claim from telemetry alone.
|
|
1388
|
+
if (result.cachedTokens > 0) {
|
|
1389
|
+
res.setHeader('X-Cached-Tokens', String(result.cachedTokens));
|
|
1390
|
+
}
|
|
1391
|
+
await handleNonStreaming(res, result, body, visibility, stopSequences, serverTiming);
|
|
1392
|
+
// Non-paged success: adopt the warm slot only when the
|
|
1393
|
+
// dispatch actually committed. Mirrors the streaming-side
|
|
1394
|
+
// dual-gate at `streamResult.ok && outcome.wasCommitted()`
|
|
1395
|
+
// above and the sibling `/v1/responses` adopt gate, so the
|
|
1396
|
+
// local invariant — "never adopt an uncommitted session"
|
|
1397
|
+
// — is enforced by the same check on both wire formats and
|
|
1398
|
+
// both endpoints. Today every native failure throws (and
|
|
1399
|
+
// routes through the inner catch below), so the gate is
|
|
1400
|
+
// dead code on the current Rust paths; it defends the
|
|
1401
|
+
// invariant LOCALLY so a future native change that
|
|
1402
|
+
// resolves `chat_session_start_sync` with
|
|
1403
|
+
// `Ok(finish_reason="error")` cannot silently poison the
|
|
1404
|
+
// warm slot. Drop on the uncommitted branch matches the
|
|
1405
|
+
// streaming-side `else { drop(...) }` so the sentinel does
|
|
1406
|
+
// not accumulate stale entries from earlier turns.
|
|
1407
|
+
//
|
|
1408
|
+
// Paged success: never adopt — block-level reuse is
|
|
1409
|
+
// already in the native cache, and adopting would
|
|
1410
|
+
// re-introduce the cross-endpoint warm-slot eviction
|
|
1411
|
+
// that paged is supposed to eliminate.
|
|
1412
|
+
if (!pagedActive) {
|
|
1413
|
+
if (outcome.committed && !hasSuppressedToolCalls(result, body)) {
|
|
1414
|
+
sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null, config.cacheSalt ?? null);
|
|
1415
|
+
sessionRetained = true;
|
|
1416
|
+
}
|
|
1417
|
+
else {
|
|
1418
|
+
sessionReg.drop(MESSAGES_WARM_SLOT_ID);
|
|
1419
|
+
}
|
|
1230
1420
|
}
|
|
1231
1421
|
}
|
|
1232
1422
|
}
|
|
1233
|
-
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
// Paged: `lookup.hit` is always `false` so we entered
|
|
1250
|
-
// with `sessionCacheStatus = 'fresh'`. Promote to
|
|
1251
|
-
// `prefix_hit` when the native engine reports
|
|
1252
|
-
// `cachedTokens > 0` — that's the authoritative signal
|
|
1253
|
-
// that `BlockAllocator`'s content-addressed prefix lookup
|
|
1254
|
-
// recovered shared SYS blocks on this turn. `res.end` has
|
|
1255
|
-
// not fired yet (`handleNonStreaming` is what flushes via
|
|
1256
|
-
// `endJson`), so the overwrite still lands on the wire.
|
|
1257
|
-
if (lookup.hit && result.cachedTokens === 0) {
|
|
1258
|
-
sessionCacheStatus = 'fresh';
|
|
1259
|
-
res.setHeader('X-Session-Cache', sessionCacheStatus);
|
|
1260
|
-
}
|
|
1261
|
-
else if (pagedActive && result.cachedTokens > 0) {
|
|
1262
|
-
sessionCacheStatus = 'prefix_hit';
|
|
1263
|
-
res.setHeader('X-Session-Cache', sessionCacheStatus);
|
|
1264
|
-
}
|
|
1265
|
-
// Companion `X-Cached-Tokens` header: emitted only when
|
|
1266
|
-
// reuse genuinely happened, so operators can spot a stale
|
|
1267
|
-
// `prefix_hit` claim from telemetry alone.
|
|
1268
|
-
if (result.cachedTokens > 0) {
|
|
1269
|
-
res.setHeader('X-Cached-Tokens', String(result.cachedTokens));
|
|
1270
|
-
}
|
|
1271
|
-
await handleNonStreaming(res, result, body, visibility, stopSequences, serverTiming);
|
|
1272
|
-
// Non-paged success: adopt the warm slot only when the
|
|
1273
|
-
// dispatch actually committed. Mirrors the streaming-side
|
|
1274
|
-
// dual-gate at `streamResult.ok && outcome.wasCommitted()`
|
|
1275
|
-
// above and the sibling `/v1/responses` adopt gate, so the
|
|
1276
|
-
// local invariant — "never adopt an uncommitted session"
|
|
1277
|
-
// — is enforced by the same check on both wire formats and
|
|
1278
|
-
// both endpoints. Today every native failure throws (and
|
|
1279
|
-
// routes through the inner catch below), so the gate is
|
|
1280
|
-
// dead code on the current Rust paths; it defends the
|
|
1281
|
-
// invariant LOCALLY so a future native change that
|
|
1282
|
-
// resolves `chat_session_start_sync` with
|
|
1283
|
-
// `Ok(finish_reason="error")` cannot silently poison the
|
|
1284
|
-
// warm slot. Drop on the uncommitted branch matches the
|
|
1285
|
-
// streaming-side `else { drop(...) }` so the sentinel does
|
|
1286
|
-
// not accumulate stale entries from earlier turns.
|
|
1287
|
-
//
|
|
1288
|
-
// Paged success: never adopt — block-level reuse is
|
|
1289
|
-
// already in the native cache, and adopting would
|
|
1290
|
-
// re-introduce the cross-endpoint warm-slot eviction
|
|
1291
|
-
// that paged is supposed to eliminate.
|
|
1292
|
-
if (!pagedActive) {
|
|
1293
|
-
if (outcome.committed && !hasSuppressedToolCalls(result, body)) {
|
|
1294
|
-
sessionReg.adopt(MESSAGES_WARM_SLOT_ID, session, requestedSystem, null);
|
|
1423
|
+
catch (err) {
|
|
1424
|
+
// A failed turn on the non-paged path must not leave a
|
|
1425
|
+
// poisoned warm slot for the next request to lease — drop
|
|
1426
|
+
// the sentinel before emitting the error response.
|
|
1427
|
+
// Streaming half-failures are already covered by the
|
|
1428
|
+
// `wasCommitted()` gate above; this catch handles
|
|
1429
|
+
// non-streaming throws and any pre-handler failures from
|
|
1430
|
+
// the streaming path. The paged path never adopts, so the
|
|
1431
|
+
// drop is a no-op there but kept unconditional for
|
|
1432
|
+
// simplicity (the registry treats `drop` of an absent key
|
|
1433
|
+
// as a no-op).
|
|
1434
|
+
sessionReg.drop(MESSAGES_WARM_SLOT_ID);
|
|
1435
|
+
const message = err instanceof Error ? err.message : 'Unknown error during inference';
|
|
1436
|
+
if (visibility.responseMode === null) {
|
|
1437
|
+
if (isContextCapacityError(err)) {
|
|
1438
|
+
sendAnthropicBadRequest(res, message);
|
|
1295
1439
|
}
|
|
1296
1440
|
else {
|
|
1297
|
-
|
|
1441
|
+
sendAnthropicInternalError(res, message);
|
|
1298
1442
|
}
|
|
1299
1443
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
// non-streaming throws and any pre-handler failures from
|
|
1309
|
-
// the streaming path. The paged path never adopts, so the
|
|
1310
|
-
// drop is a no-op there but kept unconditional for
|
|
1311
|
-
// simplicity (the registry treats `drop` of an absent key
|
|
1312
|
-
// as a no-op).
|
|
1313
|
-
sessionReg.drop(MESSAGES_WARM_SLOT_ID);
|
|
1314
|
-
const message = err instanceof Error ? err.message : 'Unknown error during inference';
|
|
1315
|
-
if (visibility.responseMode === null) {
|
|
1316
|
-
if (isContextCapacityError(err)) {
|
|
1317
|
-
sendAnthropicBadRequest(res, message);
|
|
1444
|
+
else if (visibility.responseMode === 'json') {
|
|
1445
|
+
// Already committed to JSON — destroy the socket rather than corrupt the body.
|
|
1446
|
+
try {
|
|
1447
|
+
res.destroy(err instanceof Error ? err : new Error(message));
|
|
1448
|
+
}
|
|
1449
|
+
catch {
|
|
1450
|
+
// Socket may already be gone.
|
|
1451
|
+
}
|
|
1318
1452
|
}
|
|
1319
1453
|
else {
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1454
|
+
// SSE: best-effort streaming `error`, but only if no terminal landed
|
|
1455
|
+
// (a double terminal would confuse the client state machine).
|
|
1456
|
+
if (!visibility.terminalEmitted) {
|
|
1457
|
+
writeFallbackErrorSSE(res, 'error', {
|
|
1458
|
+
error: { type: 'api_error', message },
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
try {
|
|
1462
|
+
endSSE(res);
|
|
1463
|
+
}
|
|
1464
|
+
catch {
|
|
1465
|
+
// Already closed.
|
|
1466
|
+
}
|
|
1330
1467
|
}
|
|
1331
1468
|
}
|
|
1332
|
-
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
// Already closed.
|
|
1469
|
+
finally {
|
|
1470
|
+
// Every session that was not retained in the warm registry owns
|
|
1471
|
+
// request-local native state and must be released before leaving
|
|
1472
|
+
// the admission lane. Cleanup failure must not replace a terminal
|
|
1473
|
+
// response already delivered to the client.
|
|
1474
|
+
if (!sessionRetained) {
|
|
1475
|
+
try {
|
|
1476
|
+
await sessionReg.disposeSession(session);
|
|
1477
|
+
}
|
|
1478
|
+
catch (error) {
|
|
1479
|
+
console.error('[messages] failed to release an unretained chat-session cache owner:', error);
|
|
1480
|
+
}
|
|
1345
1481
|
}
|
|
1482
|
+
await sessionReg.flushPendingDisposals();
|
|
1346
1483
|
}
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1484
|
+
});
|
|
1485
|
+
};
|
|
1349
1486
|
await runInference();
|
|
1350
1487
|
}
|
|
1351
1488
|
catch (err) {
|
|
@@ -1360,7 +1497,7 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
|
|
|
1360
1497
|
// still routes through the handler's existing error paths.
|
|
1361
1498
|
if (err instanceof QueueFullError) {
|
|
1362
1499
|
if (!res.headersSent) {
|
|
1363
|
-
sendAnthropicRateLimit(res,
|
|
1500
|
+
sendAnthropicRateLimit(res, `${err.message}. Retry after 1s.`);
|
|
1364
1501
|
}
|
|
1365
1502
|
}
|
|
1366
1503
|
else {
|
|
@@ -1369,6 +1506,12 @@ export async function handleCreateMessage(res, body, registry, httpReq, idleSwee
|
|
|
1369
1506
|
}
|
|
1370
1507
|
}
|
|
1371
1508
|
finally {
|
|
1509
|
+
// Balance the pre-dispatch admission on EVERY exit that never handed
|
|
1510
|
+
// the permit to `withExclusive`: binding-changed 400s, disconnects,
|
|
1511
|
+
// and any validation early-return inside the outer `try`. Idempotent
|
|
1512
|
+
// and a no-op after handoff, so the unconditional call is safe.
|
|
1513
|
+
preDispatchAdmission?.release();
|
|
1514
|
+
modelLoadAdmission?.release();
|
|
1372
1515
|
// Drop disconnect listeners so they don't pin the request past handler
|
|
1373
1516
|
// return. Only detach if we actually attached (gated by the flag).
|
|
1374
1517
|
if (abortListenersAttached) {
|