@mlx-node/server 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/endpoints/messages.d.ts +13 -0
- package/dist/endpoints/messages.d.ts.map +1 -0
- package/dist/endpoints/messages.js +511 -0
- package/dist/endpoints/models.d.ts +5 -0
- package/dist/endpoints/models.d.ts.map +1 -0
- package/dist/endpoints/models.js +10 -0
- package/dist/endpoints/responses.d.ts +79 -0
- package/dist/endpoints/responses.d.ts.map +1 -0
- package/dist/endpoints/responses.js +2816 -0
- package/dist/errors.d.ts +43 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +84 -0
- package/dist/handler.d.ts +18 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +35 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/mappers/anthropic-request.d.ts +9 -0
- package/dist/mappers/anthropic-request.d.ts.map +1 -0
- package/dist/mappers/anthropic-request.js +241 -0
- package/dist/mappers/anthropic-response.d.ts +14 -0
- package/dist/mappers/anthropic-response.d.ts.map +1 -0
- package/dist/mappers/anthropic-response.js +112 -0
- package/dist/mappers/request.d.ts +18 -0
- package/dist/mappers/request.d.ts.map +1 -0
- package/dist/mappers/request.js +206 -0
- package/dist/mappers/response.d.ts +13 -0
- package/dist/mappers/response.d.ts.map +1 -0
- package/dist/mappers/response.js +116 -0
- package/dist/pending-writes.d.ts +337 -0
- package/dist/pending-writes.d.ts.map +1 -0
- package/dist/pending-writes.js +468 -0
- package/dist/registry.d.ts +363 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +497 -0
- package/dist/router.d.ts +6 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +78 -0
- package/dist/server.d.ts +80 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +158 -0
- package/dist/session-registry.d.ts +297 -0
- package/dist/session-registry.d.ts.map +1 -0
- package/dist/session-registry.js +403 -0
- package/dist/streaming.d.ts +7 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +16 -0
- package/dist/tool-call-buffer.d.ts +26 -0
- package/dist/tool-call-buffer.d.ts.map +1 -0
- package/dist/tool-call-buffer.js +51 -0
- package/dist/transport-visibility.d.ts +56 -0
- package/dist/transport-visibility.d.ts.map +1 -0
- package/dist/transport-visibility.js +161 -0
- package/dist/types-anthropic.d.ts +144 -0
- package/dist/types-anthropic.d.ts.map +1 -0
- package/dist/types-anthropic.js +2 -0
- package/dist/types.d.ts +220 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +36 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionRegistry -- per-model cache holding AT MOST one live
|
|
3
|
+
* `ChatSession` whose native KV state is currently valid.
|
|
4
|
+
*
|
|
5
|
+
* Design notes:
|
|
6
|
+
*
|
|
7
|
+
* - **One registry per model.** Composed alongside each registered
|
|
8
|
+
* `ServableModel` in `ModelRegistry`. Sessions are keyed purely
|
|
9
|
+
* by response id — no secondary keying on model name because the
|
|
10
|
+
* registry is already scoped per model.
|
|
11
|
+
*
|
|
12
|
+
* - **Single-warm-session invariant.** `ChatSession<M>` is a thin
|
|
13
|
+
* JS wrapper — it does NOT own any native KV cache. The cache
|
|
14
|
+
* lives on the underlying `SessionCapableModel` (one shared
|
|
15
|
+
* `cached_token_history` / `caches` vector per model instance).
|
|
16
|
+
* Any call that runs a turn overwrites that shared native state,
|
|
17
|
+
* silently invalidating every other `ChatSession` wrapper
|
|
18
|
+
* pointing at the same model. Caching multiple wrappers per
|
|
19
|
+
* model is therefore an illusion: at most ONE matches real
|
|
20
|
+
* native state (whichever ran most recently). To prevent
|
|
21
|
+
* cross-session corruption this registry holds at most ONE
|
|
22
|
+
* entry — both `getOrCreate` and `adopt` clear the map before
|
|
23
|
+
* returning or inserting.
|
|
24
|
+
*
|
|
25
|
+
* - **Lease semantics on hit.** Clear-on-hit also gives single-
|
|
26
|
+
* flight lease semantics: two overlapping requests referencing
|
|
27
|
+
* the same `previous_response_id` cannot share the same live
|
|
28
|
+
* `ChatSession`. The first wins the cleared entry; the second
|
|
29
|
+
* finds the map empty and cold-replays from `ResponseStore` on
|
|
30
|
+
* a fresh session. Without this, the second would hit
|
|
31
|
+
* `ChatSession`'s single-flight "concurrent send() not allowed"
|
|
32
|
+
* guard.
|
|
33
|
+
*
|
|
34
|
+
* - **Instructions / prefix-state change also misses.** Each entry
|
|
35
|
+
* records the `instructions` string used to adopt it.
|
|
36
|
+
* `getOrCreate` compares the caller's `requestedInstructions`
|
|
37
|
+
* against the cached value; mismatch forces cold replay so the
|
|
38
|
+
* new prefix state is re-primed instead of silently reusing a
|
|
39
|
+
* stale warmed prompt. The OpenAI `instructions` field and the
|
|
40
|
+
* Anthropic `system` field both flow through the same parameter
|
|
41
|
+
* — the registry does not care which is which.
|
|
42
|
+
*
|
|
43
|
+
* - **Cache miss fallback.** On a miss (eviction, interleaved turn
|
|
44
|
+
* on a different chain, restart, lease-on-hit) the endpoint
|
|
45
|
+
* layer reconstructs the conversation from the `ResponseStore`
|
|
46
|
+
* history, primes a fresh `ChatSession` via `primeHistory()`,
|
|
47
|
+
* and resumes through `startFromHistory()` /
|
|
48
|
+
* `startFromHistoryStream()`. That pair dispatches one
|
|
49
|
+
* `chatSessionStart*` call that rebuilds the full KV cache and
|
|
50
|
+
* atomically appends the new user turn, so cold replay is
|
|
51
|
+
* indistinguishable from a hot hit.
|
|
52
|
+
*
|
|
53
|
+
* - **TTL.** Default 1800 seconds mirrors `RESPONSE_TTL_SECONDS`
|
|
54
|
+
* in `packages/server/src/endpoints/responses.ts` so the cached
|
|
55
|
+
* entry ages out alongside its stored response metadata. With
|
|
56
|
+
* at most one entry there is no LRU bookkeeping — just a single
|
|
57
|
+
* expiry check on lookup.
|
|
58
|
+
*
|
|
59
|
+
* - **Thread safety.** Node.js is single-threaded within one
|
|
60
|
+
* event-loop tick, so the internal `Map` is safe against
|
|
61
|
+
* concurrent mutation by design. `sweep()` can be scheduled
|
|
62
|
+
* via `setInterval` without colliding with in-flight calls.
|
|
63
|
+
*
|
|
64
|
+
* - **Per-model execution mutex.** A dispatch that spans multiple
|
|
65
|
+
* awaits (map -> prefill -> decode -> persist -> adopt) is NOT
|
|
66
|
+
* atomic from the registry's POV. Two requests against the
|
|
67
|
+
* same model would both receive a `ChatSession` pointing at
|
|
68
|
+
* the same native model; even though the lease-on-hit clear
|
|
69
|
+
* prevents sharing one `ChatSession` object, the native KV
|
|
70
|
+
* cache is a single mutable resource and two parallel
|
|
71
|
+
* `primeHistory()` / `send*()` calls would race. Whichever
|
|
72
|
+
* finished last would win `adopt()`, poisoning the hot path
|
|
73
|
+
* for every subsequent chained turn.
|
|
74
|
+
*
|
|
75
|
+
* `withExclusive(fn)` serializes every per-model dispatch via
|
|
76
|
+
* a FIFO `execLock` chain. `/v1/responses` and `/v1/messages`
|
|
77
|
+
* wrap the full `getOrCreate -> run -> adopt/drop` span in one
|
|
78
|
+
* `withExclusive` so at most one request holds the model at a
|
|
79
|
+
* time. A weaker epoch-token scheme would let the losing
|
|
80
|
+
* `adopt()` no-op but the native KV would already be wrong.
|
|
81
|
+
*/
|
|
82
|
+
import { ChatSession } from '@mlx-node/lm';
|
|
83
|
+
/**
|
|
84
|
+
* Thrown synchronously by {@link SessionRegistry.withExclusive} when
|
|
85
|
+
* the per-model queue cap (`maxQueueDepth`) is exceeded. The error is
|
|
86
|
+
* raised BEFORE awaiting the previous lock holder so endpoint handlers
|
|
87
|
+
* can reliably catch it without racing the chain.
|
|
88
|
+
*/
|
|
89
|
+
export class QueueFullError extends Error {
|
|
90
|
+
queuedCount;
|
|
91
|
+
limit;
|
|
92
|
+
constructor(queuedCount, limit) {
|
|
93
|
+
super(`Model queue full: ${queuedCount} waiting (limit ${limit})`);
|
|
94
|
+
this.name = 'QueueFullError';
|
|
95
|
+
this.queuedCount = queuedCount;
|
|
96
|
+
this.limit = limit;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Current time in unix seconds. Kept as a helper so tests can patch `Date.now` via fake timers. */
|
|
100
|
+
function nowSec() {
|
|
101
|
+
return Math.floor(Date.now() / 1000);
|
|
102
|
+
}
|
|
103
|
+
export class SessionRegistry {
|
|
104
|
+
model;
|
|
105
|
+
ttlSec;
|
|
106
|
+
maxQueueDepth;
|
|
107
|
+
/**
|
|
108
|
+
* Number of callers that are currently WAITING for the per-model
|
|
109
|
+
* execution mutex — i.e. have entered `withExclusive` but have not
|
|
110
|
+
* yet started running their closure. The caller that is actively
|
|
111
|
+
* running inside `fn()` is NOT counted here, so a cap of
|
|
112
|
+
* `maxQueueDepth = N` means "1 running + up to N waiting".
|
|
113
|
+
*
|
|
114
|
+
* Mutated strictly inside `withExclusive`: the admitting caller is
|
|
115
|
+
* counted as a waiter ONLY when the execution chain is already
|
|
116
|
+
* non-idle (i.e. some earlier caller still holds the mutex). The
|
|
117
|
+
* first caller into an idle chain is admitted directly as the
|
|
118
|
+
* runner slot and never contributes to `queuedCount`. Waiters
|
|
119
|
+
* decrement exactly once as they transition from waiting to
|
|
120
|
+
* running (after `await prev`). The counter is intentionally
|
|
121
|
+
* NEVER touched on cap-reject paths (the caller never queued) so
|
|
122
|
+
* the cap check is stable across concurrent entries, and runner-
|
|
123
|
+
* slot admissions leave it alone so a synchronous burst
|
|
124
|
+
* (e.g. `Promise.all([fn, fn])`) does not spuriously bill the
|
|
125
|
+
* runner-slot caller against the waiter cap.
|
|
126
|
+
*/
|
|
127
|
+
queuedCount = 0;
|
|
128
|
+
/**
|
|
129
|
+
* Holds AT MOST ONE entry under the single-warm invariant (see the
|
|
130
|
+
* module-level rustdoc). `getOrCreate` and `adopt` both clear the
|
|
131
|
+
* map as part of their contract so a later lookup cannot hand out
|
|
132
|
+
* a wrapper whose assumed native state has been overwritten by a
|
|
133
|
+
* turn on another cached entry.
|
|
134
|
+
*/
|
|
135
|
+
entries = new Map();
|
|
136
|
+
/**
|
|
137
|
+
* Shared sentinel representing "the execution chain is idle" — a
|
|
138
|
+
* pre-resolved promise. `execLock` starts at this value and is
|
|
139
|
+
* reset to it whenever the last holder releases without a
|
|
140
|
+
* successor chained behind it. `withExclusive` uses reference
|
|
141
|
+
* equality against this sentinel (`execLock === initialLock`) to
|
|
142
|
+
* tell "I am the runner slot on an idle chain" apart from "I am a
|
|
143
|
+
* waiter behind someone else", which is how the burst
|
|
144
|
+
* (`Promise.all([fn, fn])`) admission bug is avoided.
|
|
145
|
+
*/
|
|
146
|
+
initialLock = Promise.resolve();
|
|
147
|
+
/**
|
|
148
|
+
* Tail of the per-model execution FIFO. Every `withExclusive` call
|
|
149
|
+
* captures this value as its predecessor, then overwrites it with
|
|
150
|
+
* its own pending promise so the next waiter chains after it. The
|
|
151
|
+
* chain is resolved only when the current holder's `fn` has
|
|
152
|
+
* settled (success or failure), guaranteeing that at most one
|
|
153
|
+
* dispatch runs through this registry's native model at a time.
|
|
154
|
+
* Initialized to `initialLock` so the first caller proceeds
|
|
155
|
+
* without waiting AND is recognised as the runner slot (no waiter
|
|
156
|
+
* increment). When a holder releases as the current chain tail it
|
|
157
|
+
* restores `execLock` to `initialLock` so the next burst starts
|
|
158
|
+
* cleanly from the idle state.
|
|
159
|
+
*/
|
|
160
|
+
execLock = this.initialLock;
|
|
161
|
+
constructor(opts) {
|
|
162
|
+
this.model = opts.model;
|
|
163
|
+
this.ttlSec = opts.ttlSec ?? 1800;
|
|
164
|
+
this.maxQueueDepth = opts.maxQueueDepth;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Number of requests currently WAITING to acquire the per-model
|
|
168
|
+
* execution mutex. Does NOT include the one actively running inside
|
|
169
|
+
* `fn`. Primarily for tests and diagnostics.
|
|
170
|
+
*/
|
|
171
|
+
get queueDepth() {
|
|
172
|
+
return this.queuedCount;
|
|
173
|
+
}
|
|
174
|
+
/** Number of sessions currently cached. Primarily for tests and diagnostics. Always 0 or 1. */
|
|
175
|
+
get size() {
|
|
176
|
+
return this.entries.size;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Look up or allocate a session for the given previous response id.
|
|
180
|
+
* Always returns a `SessionLookupResult` and always leaves the cache
|
|
181
|
+
* empty after return (single-warm invariant).
|
|
182
|
+
*
|
|
183
|
+
* On a null id, missing key, expired entry, or prefix-state
|
|
184
|
+
* mismatch: clear and return `{ session: new ChatSession(model), hit: false }`.
|
|
185
|
+
* The caller primes / cold-replays from the `ResponseStore` and
|
|
186
|
+
* re-adopts after the turn commits.
|
|
187
|
+
*
|
|
188
|
+
* On a hit: the entry is removed and its live session is returned
|
|
189
|
+
* alongside `hit: true`. Overlapping requests against the same
|
|
190
|
+
* `previous_response_id` cannot share the same live `ChatSession` —
|
|
191
|
+
* the first wins, the second misses and cold-replays.
|
|
192
|
+
*
|
|
193
|
+
* `requestedInstructions` is the caller's prefix/system state
|
|
194
|
+
* (OpenAI `instructions`, Anthropic `system`, or `null`); byte-for-
|
|
195
|
+
* byte mismatch against the cached entry forces cold replay so
|
|
196
|
+
* the new prefix is re-primed.
|
|
197
|
+
*
|
|
198
|
+
* The `hit` flag drives the `X-Session-Cache` observability header
|
|
199
|
+
* emitted by both `/v1/responses` and `/v1/messages`: when the caller
|
|
200
|
+
* supplied a `previous_response_id`, `hit === true` yields `hit` and
|
|
201
|
+
* `hit === false` yields `cold_replay` (the endpoint then rebuilds
|
|
202
|
+
* from the `ResponseStore` on a fresh session). Requests with no
|
|
203
|
+
* `previous_response_id` (or the stateless `/v1/messages` endpoint,
|
|
204
|
+
* which always passes `null`) yield `fresh` regardless of this flag.
|
|
205
|
+
*/
|
|
206
|
+
getOrCreate(previousResponseId, requestedInstructions) {
|
|
207
|
+
// Every call is about to overwrite native KV state, so drop any
|
|
208
|
+
// other cached entry now — a later `getOrCreate` must not hand
|
|
209
|
+
// out a wrapper whose assumed state has been stomped. Under the
|
|
210
|
+
// single-warm invariant the map holds at most one entry, so the
|
|
211
|
+
// common case is either "the entry we want" or "nothing".
|
|
212
|
+
if (previousResponseId === null) {
|
|
213
|
+
this.entries.clear();
|
|
214
|
+
return { session: new ChatSession(this.model), hit: false };
|
|
215
|
+
}
|
|
216
|
+
const entry = this.entries.get(previousResponseId);
|
|
217
|
+
if (entry === undefined) {
|
|
218
|
+
this.entries.clear();
|
|
219
|
+
return { session: new ChatSession(this.model), hit: false };
|
|
220
|
+
}
|
|
221
|
+
if (entry.expiresAt < nowSec()) {
|
|
222
|
+
this.entries.clear();
|
|
223
|
+
return { session: new ChatSession(this.model), hit: false };
|
|
224
|
+
}
|
|
225
|
+
// Prefix-state mismatch forces cold replay so the new
|
|
226
|
+
// instructions are re-primed; without this guard, output would
|
|
227
|
+
// silently depend on cache state instead of request contents.
|
|
228
|
+
if (entry.instructions !== requestedInstructions) {
|
|
229
|
+
this.entries.clear();
|
|
230
|
+
return { session: new ChatSession(this.model), hit: false };
|
|
231
|
+
}
|
|
232
|
+
// Hit: clear and hand the session out as a single-use lease so
|
|
233
|
+
// a concurrent second request against the same id cold-replays
|
|
234
|
+
// instead of sharing this live ChatSession.
|
|
235
|
+
this.entries.clear();
|
|
236
|
+
return { session: entry.session, hit: true };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Insert a session under a newly allocated response id. Clears the
|
|
240
|
+
* map before inserting to keep the single-warm invariant explicit
|
|
241
|
+
* regardless of caller ordering.
|
|
242
|
+
*
|
|
243
|
+
* `instructions` is the prefix/system state used for this turn;
|
|
244
|
+
* stored on the entry and compared on the next `getOrCreate` to
|
|
245
|
+
* detect prefix changes that must force a cold replay.
|
|
246
|
+
*/
|
|
247
|
+
adopt(responseId, session, instructions) {
|
|
248
|
+
this.entries.clear();
|
|
249
|
+
this.entries.set(responseId, {
|
|
250
|
+
session,
|
|
251
|
+
instructions,
|
|
252
|
+
expiresAt: nowSec() + this.ttlSec,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Remove a session by response id. No-op if the key is not present.
|
|
257
|
+
*/
|
|
258
|
+
drop(responseId) {
|
|
259
|
+
this.entries.delete(responseId);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Walk the map and drop the entry if its TTL has expired.
|
|
263
|
+
* Intended for periodic cleanup via `setInterval`. Under the
|
|
264
|
+
* single-warm invariant the map holds at most one entry.
|
|
265
|
+
*/
|
|
266
|
+
sweep() {
|
|
267
|
+
const cutoff = nowSec();
|
|
268
|
+
for (const [key, entry] of this.entries) {
|
|
269
|
+
if (entry.expiresAt < cutoff) {
|
|
270
|
+
this.entries.delete(key);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Empty the registry. Useful at shutdown and in tests. */
|
|
275
|
+
clear() {
|
|
276
|
+
this.entries.clear();
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Serialize `fn` against every other dispatch through this
|
|
280
|
+
* registry's model. The caller must hold the lock across the
|
|
281
|
+
* entire per-model dispatch span — `getOrCreate` ->
|
|
282
|
+
* `primeHistory`/`send*` -> `adopt`/`drop`. Without it, two
|
|
283
|
+
* concurrent `primeHistory()` / `send*()` calls would race on
|
|
284
|
+
* the single mutable native KV cache and whichever finished last
|
|
285
|
+
* would corrupt the other's chain.
|
|
286
|
+
*
|
|
287
|
+
* FIFO chaining via a rolling `execLock` promise: each caller
|
|
288
|
+
* captures the current tail, publishes a fresh pending promise as
|
|
289
|
+
* the new tail, awaits the old tail, then runs `fn`. The
|
|
290
|
+
* `finally` releases regardless of whether `fn` threw.
|
|
291
|
+
*
|
|
292
|
+
* **Admission control.** When `maxQueueDepth` is configured and the
|
|
293
|
+
* current number of waiters (`queuedCount`, excluding the active
|
|
294
|
+
* holder) is already at or above the cap, the call throws
|
|
295
|
+
* {@link QueueFullError} synchronously — SYNCHRONOUSLY from the
|
|
296
|
+
* caller's perspective, not merely before `await prev`. The wrapper
|
|
297
|
+
* is deliberately NOT declared `async` so the admission gate
|
|
298
|
+
* throws on the caller's stack frame, letting endpoint handlers
|
|
299
|
+
* wrap the call site in a plain try/catch without racing promise
|
|
300
|
+
* microtasks. On acceptance the async body takes over via the
|
|
301
|
+
* returned `Promise<T>`.
|
|
302
|
+
*
|
|
303
|
+
* The cap is "waiters-only" — a cap of N permits one running
|
|
304
|
+
* dispatch plus N queued ones, rejecting the (N+1)th waiter. The
|
|
305
|
+
* default (undefined) preserves the original unbounded behaviour.
|
|
306
|
+
*
|
|
307
|
+
* **Runner-slot admission.** Whether a given caller counts as the
|
|
308
|
+
* runner slot or as a waiter is decided up front by comparing
|
|
309
|
+
* `execLock` against the idle sentinel `initialLock`. If they are
|
|
310
|
+
* identical, nobody is currently in-flight and this caller wins
|
|
311
|
+
* the runner slot: it is not counted against the waiter cap and
|
|
312
|
+
* never touches `queuedCount`. Otherwise it is a waiter and the
|
|
313
|
+
* normal cap check / increment / decrement cycle applies. This is
|
|
314
|
+
* what keeps a synchronous burst such as `Promise.all([fn, fn])`
|
|
315
|
+
* admissible under `maxQueueDepth = 1` — Call 1 is the runner,
|
|
316
|
+
* Call 2 is the one allowed waiter, Call 3 would throw.
|
|
317
|
+
*/
|
|
318
|
+
withExclusive(fn) {
|
|
319
|
+
// Distinguish runner-slot from waiter admission. If the chain is
|
|
320
|
+
// idle (`execLock === initialLock`) the current caller is about
|
|
321
|
+
// to become the active holder on its very first `await prev`
|
|
322
|
+
// microtask — it must NOT be billed against the waiter cap and
|
|
323
|
+
// must NOT touch `queuedCount`. Only chained callers (someone
|
|
324
|
+
// else still holds or is ahead in the FIFO) count as waiters.
|
|
325
|
+
const asWaiter = this.execLock !== this.initialLock;
|
|
326
|
+
// Admission check — raised synchronously so endpoint handlers
|
|
327
|
+
// can reliably catch `QueueFullError` without racing any
|
|
328
|
+
// `await`. Only waiters can trip the cap; the runner slot is
|
|
329
|
+
// always admitted. The counter is NOT mutated on the reject
|
|
330
|
+
// path; the request never queued.
|
|
331
|
+
if (asWaiter && this.maxQueueDepth !== undefined && this.queuedCount >= this.maxQueueDepth) {
|
|
332
|
+
throw new QueueFullError(this.queuedCount, this.maxQueueDepth);
|
|
333
|
+
}
|
|
334
|
+
const prev = this.execLock;
|
|
335
|
+
let release;
|
|
336
|
+
const myLock = new Promise((resolve) => {
|
|
337
|
+
release = resolve;
|
|
338
|
+
});
|
|
339
|
+
this.execLock = myLock;
|
|
340
|
+
if (asWaiter) {
|
|
341
|
+
this.queuedCount += 1;
|
|
342
|
+
}
|
|
343
|
+
return this._runExclusive(prev, myLock, release, fn, asWaiter);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Async tail of {@link withExclusive}. Kept separate so the public
|
|
347
|
+
* wrapper stays a plain (non-async) function whose admission-gate
|
|
348
|
+
* throw lands on the caller's stack synchronously. This helper
|
|
349
|
+
* owns the post-acceptance bookkeeping: awaiting the predecessor
|
|
350
|
+
* lock, transitioning from waiter to holder (`queuedCount`
|
|
351
|
+
* decrement for waiters only), running `fn`, releasing the FIFO
|
|
352
|
+
* tail, and resetting the chain to `initialLock` when this caller
|
|
353
|
+
* is still the tail (so a future burst admits its first entry as
|
|
354
|
+
* a runner-slot rather than as a waiter).
|
|
355
|
+
*/
|
|
356
|
+
async _runExclusive(prev, myLock, release, fn, asWaiter) {
|
|
357
|
+
// Track whether the waiting-counter has already been balanced so
|
|
358
|
+
// an error raised by `await prev` (should never happen today but
|
|
359
|
+
// is cheap to defend against) cannot double-decrement via the
|
|
360
|
+
// outer `finally` below. Runner-slot admissions never touch the
|
|
361
|
+
// counter, so the flag starts already-balanced for them.
|
|
362
|
+
let waitingDecremented = !asWaiter;
|
|
363
|
+
try {
|
|
364
|
+
try {
|
|
365
|
+
await prev;
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
// Transition from "waiting" to "running" — the counter must
|
|
369
|
+
// drop exactly here regardless of whether `prev` fulfilled
|
|
370
|
+
// or rejected, because from this point forward the caller is
|
|
371
|
+
// the active holder and no longer part of the queue depth.
|
|
372
|
+
if (!waitingDecremented) {
|
|
373
|
+
this.queuedCount -= 1;
|
|
374
|
+
if (this.queuedCount < 0)
|
|
375
|
+
this.queuedCount = 0;
|
|
376
|
+
waitingDecremented = true;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return await fn();
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
// Belt-and-suspenders: if `await prev` managed to throw before
|
|
383
|
+
// reaching the inner `finally` (extremely unlikely given the
|
|
384
|
+
// chain is always resolved with `undefined`), still balance the
|
|
385
|
+
// queued counter so a future cap check doesn't drift upward.
|
|
386
|
+
if (!waitingDecremented) {
|
|
387
|
+
this.queuedCount -= 1;
|
|
388
|
+
if (this.queuedCount < 0)
|
|
389
|
+
this.queuedCount = 0;
|
|
390
|
+
waitingDecremented = true;
|
|
391
|
+
}
|
|
392
|
+
release();
|
|
393
|
+
// Reset the chain to the idle sentinel ONLY when this caller
|
|
394
|
+
// is still the tail — if someone else has already extended the
|
|
395
|
+
// FIFO behind us, leave their tail in place. Reference-equality
|
|
396
|
+
// gate here is what lets the next burst see `execLock ===
|
|
397
|
+
// initialLock` and admit its first caller as a runner slot.
|
|
398
|
+
if (this.execLock === myLock) {
|
|
399
|
+
this.execLock = this.initialLock;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** SSE writer utilities. */
|
|
2
|
+
import type { ServerResponse } from 'node:http';
|
|
3
|
+
export declare function beginSSE(res: ServerResponse): void;
|
|
4
|
+
/** Write one SSE event. Injects `type: eventType` into the payload (data's own `type` wins) for OpenAI SDK compatibility. */
|
|
5
|
+
export declare function writeSSEEvent(res: ServerResponse, eventType: string, data: object): void;
|
|
6
|
+
export declare function endSSE(res: ServerResponse): void;
|
|
7
|
+
//# sourceMappingURL=streaming.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"streaming.d.ts","sourceRoot":"","sources":["../src/streaming.ts"],"names":[],"mappings":"AAAA,4BAA4B;AAE5B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD,wBAAgB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI,CAMlD;AAED,6HAA6H;AAC7H,wBAAgB,aAAa,CAAC,GAAG,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAGxF;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI,CAEhD"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** SSE writer utilities. */
|
|
2
|
+
export function beginSSE(res) {
|
|
3
|
+
res.writeHead(200, {
|
|
4
|
+
'Content-Type': 'text/event-stream',
|
|
5
|
+
'Cache-Control': 'no-cache',
|
|
6
|
+
Connection: 'keep-alive',
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
/** Write one SSE event. Injects `type: eventType` into the payload (data's own `type` wins) for OpenAI SDK compatibility. */
|
|
10
|
+
export function writeSSEEvent(res, eventType, data) {
|
|
11
|
+
const payload = { type: eventType, ...data };
|
|
12
|
+
res.write(`event: ${eventType}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
13
|
+
}
|
|
14
|
+
export function endSSE(res) {
|
|
15
|
+
res.end();
|
|
16
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buffers streaming text to detect and suppress `<tool_call>` tags. Text
|
|
3
|
+
* that cannot be part of a partial tag is released immediately; once a
|
|
4
|
+
* full tag is seen, everything after it is suppressed until the stream
|
|
5
|
+
* ends.
|
|
6
|
+
*/
|
|
7
|
+
export declare class ToolCallTagBuffer {
|
|
8
|
+
private static readonly TAG;
|
|
9
|
+
private pendingText;
|
|
10
|
+
private _suppressed;
|
|
11
|
+
get suppressed(): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Feed text in. Returns `safeText` (emit as delta), `tagFound` (a full
|
|
14
|
+
* `<tool_call>` was just seen), and `cleanPrefix` (text before the tag
|
|
15
|
+
* when `tagFound` — may contain whitespace; use `.trim()` only for
|
|
16
|
+
* emptiness checks, never for emission).
|
|
17
|
+
*/
|
|
18
|
+
push(text: string): {
|
|
19
|
+
safeText: string;
|
|
20
|
+
tagFound: boolean;
|
|
21
|
+
cleanPrefix: string;
|
|
22
|
+
};
|
|
23
|
+
/** Release any held-back text at stream end. */
|
|
24
|
+
flush(): string;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=tool-call-buffer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-call-buffer.d.ts","sourceRoot":"","sources":["../src/tool-call-buffer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAiB;IAC5C,OAAO,CAAC,WAAW,CAAM;IACzB,OAAO,CAAC,WAAW,CAAS;IAE5B,IAAI,UAAU,IAAI,OAAO,CAExB;IAED;;;;;OAKG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE;IA8BhF,gDAAgD;IAChD,KAAK,IAAI,MAAM;CAKhB"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buffers streaming text to detect and suppress `<tool_call>` tags. Text
|
|
3
|
+
* that cannot be part of a partial tag is released immediately; once a
|
|
4
|
+
* full tag is seen, everything after it is suppressed until the stream
|
|
5
|
+
* ends.
|
|
6
|
+
*/
|
|
7
|
+
export class ToolCallTagBuffer {
|
|
8
|
+
static TAG = '<tool_call>';
|
|
9
|
+
pendingText = '';
|
|
10
|
+
_suppressed = false;
|
|
11
|
+
get suppressed() {
|
|
12
|
+
return this._suppressed;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Feed text in. Returns `safeText` (emit as delta), `tagFound` (a full
|
|
16
|
+
* `<tool_call>` was just seen), and `cleanPrefix` (text before the tag
|
|
17
|
+
* when `tagFound` — may contain whitespace; use `.trim()` only for
|
|
18
|
+
* emptiness checks, never for emission).
|
|
19
|
+
*/
|
|
20
|
+
push(text) {
|
|
21
|
+
if (this._suppressed) {
|
|
22
|
+
return { safeText: '', tagFound: false, cleanPrefix: '' };
|
|
23
|
+
}
|
|
24
|
+
this.pendingText += text;
|
|
25
|
+
const tagIdx = this.pendingText.indexOf(ToolCallTagBuffer.TAG);
|
|
26
|
+
if (tagIdx >= 0) {
|
|
27
|
+
const cleanPrefix = this.pendingText.slice(0, tagIdx);
|
|
28
|
+
this._suppressed = true;
|
|
29
|
+
this.pendingText = '';
|
|
30
|
+
return { safeText: '', tagFound: true, cleanPrefix };
|
|
31
|
+
}
|
|
32
|
+
// Hold back any suffix that could be the start of the tag.
|
|
33
|
+
let safeLen = this.pendingText.length;
|
|
34
|
+
for (let i = 1; i <= Math.min(this.pendingText.length, ToolCallTagBuffer.TAG.length - 1); i++) {
|
|
35
|
+
const suffix = this.pendingText.slice(-i);
|
|
36
|
+
if (ToolCallTagBuffer.TAG.startsWith(suffix)) {
|
|
37
|
+
safeLen = this.pendingText.length - i;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const safeText = this.pendingText.slice(0, safeLen);
|
|
42
|
+
this.pendingText = this.pendingText.slice(safeLen);
|
|
43
|
+
return { safeText, tagFound: false, cleanPrefix: '' };
|
|
44
|
+
}
|
|
45
|
+
/** Release any held-back text at stream end. */
|
|
46
|
+
flush() {
|
|
47
|
+
const text = this.pendingText;
|
|
48
|
+
this.pendingText = '';
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport visibility tracking shared between the Responses and Messages
|
|
3
|
+
* endpoints. Gates "safe to suppress" on whether the client actually observed
|
|
4
|
+
* a terminal artefact for this turn.
|
|
5
|
+
*
|
|
6
|
+
* The helpers reject — and leave visibility flags unflipped — on ANY of:
|
|
7
|
+
* - the write callback reporting err != null;
|
|
8
|
+
* - a `'error'` event on `res`;
|
|
9
|
+
* - a `'close'` event on `res` or its socket;
|
|
10
|
+
* - a pre-write check finding the response/socket already destroyed.
|
|
11
|
+
*
|
|
12
|
+
* Flipping the flags from `res.end()` / `writeSSEEvent`'s synchronous return
|
|
13
|
+
* is not sufficient: on a dead socket `_writeRaw` can return without ever
|
|
14
|
+
* firing the callback or `'error'`, which would pin the per-model mutex on a
|
|
15
|
+
* client that cannot see anything we write.
|
|
16
|
+
*
|
|
17
|
+
* Non-terminal SSE writes remain synchronous — only the terminal event is
|
|
18
|
+
* flushed through the async helper. Streaming handlers independently attach a
|
|
19
|
+
* `res.once('close', …)` listener to flip `clientAborted` so the decode loop
|
|
20
|
+
* breaks at the next iteration boundary.
|
|
21
|
+
*/
|
|
22
|
+
import type { ServerResponse } from 'node:http';
|
|
23
|
+
/**
|
|
24
|
+
* Wire format committed by the handler. `null` = pre-headers (outer catch
|
|
25
|
+
* can still emit a clean 500 JSON). `'json'` = `writeHead(200, 'application/json')`
|
|
26
|
+
* already fired — the outer catch MUST NOT emit SSE frames. `'sse'` = `beginSSE()`
|
|
27
|
+
* fired — the outer catch may emit a best-effort streaming `error` event.
|
|
28
|
+
*/
|
|
29
|
+
export type ResponseMode = 'json' | 'sse' | null;
|
|
30
|
+
export interface TransportVisibility {
|
|
31
|
+
responseMode: ResponseMode;
|
|
32
|
+
/** Set only after `res.end(body)`'s callback fires with err == null — proves kernel acceptance, not buffer queue. */
|
|
33
|
+
responseBodyWritten: boolean;
|
|
34
|
+
/** Set only after the terminal SSE event's write callback reports no error. */
|
|
35
|
+
terminalEmitted: boolean;
|
|
36
|
+
}
|
|
37
|
+
export declare function createVisibility(): TransportVisibility;
|
|
38
|
+
/**
|
|
39
|
+
* Write an HTTP 200 JSON response and await kernel ack via `res.end(body, cb)`.
|
|
40
|
+
* `responseMode` is committed AFTER `writeHead` returns so a synchronous throw
|
|
41
|
+
* from `writeHead` leaves `responseMode === null` for the outer catch.
|
|
42
|
+
* `responseBodyWritten` is flipped only on the callback's success path.
|
|
43
|
+
*/
|
|
44
|
+
export declare function endJson(res: ServerResponse, body: string, visibility: TransportVisibility): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Emit the terminal SSE event for a streaming response and await kernel ack via
|
|
47
|
+
* `res.write(chunk, cb)`. `terminalEmitted` is flipped only on the success path.
|
|
48
|
+
* Used for `response.completed` / `response.failed`, `message_stop`, and the
|
|
49
|
+
* streaming `error` event. Non-terminal writes stay synchronous.
|
|
50
|
+
*/
|
|
51
|
+
export declare function flushTerminalSSE(res: ServerResponse, eventType: string, data: object, visibility: TransportVisibility): Promise<void>;
|
|
52
|
+
/** Commit to SSE mode — call immediately after `beginSSE(res)` so the outer catch routes SSE-shaped failures correctly. */
|
|
53
|
+
export declare function markSSEMode(visibility: TransportVisibility): void;
|
|
54
|
+
/** Best-effort synchronous SSE `error` event for the outer catch when the handler threw before flushing a terminal. */
|
|
55
|
+
export declare function writeFallbackErrorSSE(res: ServerResponse, eventType: string, data: object): void;
|
|
56
|
+
//# sourceMappingURL=transport-visibility.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport-visibility.d.ts","sourceRoot":"","sources":["../src/transport-visibility.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAIhD;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,KAAK,GAAG,IAAI,CAAC;AAEjD,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,YAAY,CAAC;IAC3B,qHAAqH;IACrH,mBAAmB,EAAE,OAAO,CAAC;IAC7B,+EAA+E;IAC/E,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED,wBAAgB,gBAAgB,IAAI,mBAAmB,CAMtD;AAUD;;;;;GAKG;AACH,wBAAsB,OAAO,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CA8C/G;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,cAAc,EACnB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,mBAAmB,GAC9B,OAAO,CAAC,IAAI,CAAC,CA8Cf;AAED,2HAA2H;AAC3H,wBAAgB,WAAW,CAAC,UAAU,EAAE,mBAAmB,GAAG,IAAI,CAEjE;AAED,uHAAuH;AACvH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAMhG"}
|