@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.
Files changed (61) hide show
  1. package/dist/endpoints/messages.d.ts +13 -0
  2. package/dist/endpoints/messages.d.ts.map +1 -0
  3. package/dist/endpoints/messages.js +511 -0
  4. package/dist/endpoints/models.d.ts +5 -0
  5. package/dist/endpoints/models.d.ts.map +1 -0
  6. package/dist/endpoints/models.js +10 -0
  7. package/dist/endpoints/responses.d.ts +79 -0
  8. package/dist/endpoints/responses.d.ts.map +1 -0
  9. package/dist/endpoints/responses.js +2816 -0
  10. package/dist/errors.d.ts +43 -0
  11. package/dist/errors.d.ts.map +1 -0
  12. package/dist/errors.js +84 -0
  13. package/dist/handler.d.ts +18 -0
  14. package/dist/handler.d.ts.map +1 -0
  15. package/dist/handler.js +35 -0
  16. package/dist/index.d.ts +23 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +16 -0
  19. package/dist/mappers/anthropic-request.d.ts +9 -0
  20. package/dist/mappers/anthropic-request.d.ts.map +1 -0
  21. package/dist/mappers/anthropic-request.js +241 -0
  22. package/dist/mappers/anthropic-response.d.ts +14 -0
  23. package/dist/mappers/anthropic-response.d.ts.map +1 -0
  24. package/dist/mappers/anthropic-response.js +112 -0
  25. package/dist/mappers/request.d.ts +18 -0
  26. package/dist/mappers/request.d.ts.map +1 -0
  27. package/dist/mappers/request.js +206 -0
  28. package/dist/mappers/response.d.ts +13 -0
  29. package/dist/mappers/response.d.ts.map +1 -0
  30. package/dist/mappers/response.js +116 -0
  31. package/dist/pending-writes.d.ts +337 -0
  32. package/dist/pending-writes.d.ts.map +1 -0
  33. package/dist/pending-writes.js +468 -0
  34. package/dist/registry.d.ts +363 -0
  35. package/dist/registry.d.ts.map +1 -0
  36. package/dist/registry.js +497 -0
  37. package/dist/router.d.ts +6 -0
  38. package/dist/router.d.ts.map +1 -0
  39. package/dist/router.js +78 -0
  40. package/dist/server.d.ts +80 -0
  41. package/dist/server.d.ts.map +1 -0
  42. package/dist/server.js +158 -0
  43. package/dist/session-registry.d.ts +297 -0
  44. package/dist/session-registry.d.ts.map +1 -0
  45. package/dist/session-registry.js +403 -0
  46. package/dist/streaming.d.ts +7 -0
  47. package/dist/streaming.d.ts.map +1 -0
  48. package/dist/streaming.js +16 -0
  49. package/dist/tool-call-buffer.d.ts +26 -0
  50. package/dist/tool-call-buffer.d.ts.map +1 -0
  51. package/dist/tool-call-buffer.js +51 -0
  52. package/dist/transport-visibility.d.ts +56 -0
  53. package/dist/transport-visibility.d.ts.map +1 -0
  54. package/dist/transport-visibility.js +161 -0
  55. package/dist/types-anthropic.d.ts +144 -0
  56. package/dist/types-anthropic.d.ts.map +1 -0
  57. package/dist/types-anthropic.js +2 -0
  58. package/dist/types.d.ts +220 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +2 -0
  61. 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"}