@mlx-node/server 0.0.0 → 0.0.8

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 (74) hide show
  1. package/dist/chat-session-warm-reuse.d.ts +51 -0
  2. package/dist/chat-session-warm-reuse.d.ts.map +1 -0
  3. package/dist/chat-session-warm-reuse.js +68 -0
  4. package/dist/endpoints/messages-count-tokens.d.ts +8 -0
  5. package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
  6. package/dist/endpoints/messages-count-tokens.js +121 -0
  7. package/dist/endpoints/messages.d.ts +57 -5
  8. package/dist/endpoints/messages.d.ts.map +1 -1
  9. package/dist/endpoints/messages.js +1043 -147
  10. package/dist/endpoints/models.d.ts +2 -1
  11. package/dist/endpoints/models.d.ts.map +1 -1
  12. package/dist/endpoints/models.js +2 -2
  13. package/dist/endpoints/responses.d.ts +20 -7
  14. package/dist/endpoints/responses.d.ts.map +1 -1
  15. package/dist/endpoints/responses.js +572 -82
  16. package/dist/errors.d.ts +1 -0
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +3 -0
  19. package/dist/handler.d.ts +42 -0
  20. package/dist/handler.d.ts.map +1 -1
  21. package/dist/handler.js +6 -1
  22. package/dist/idle-sweeper.d.ts +245 -0
  23. package/dist/idle-sweeper.d.ts.map +1 -0
  24. package/dist/idle-sweeper.js +408 -0
  25. package/dist/index.d.ts +8 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/dist/mappers/anthropic-request.d.ts +24 -2
  29. package/dist/mappers/anthropic-request.d.ts.map +1 -1
  30. package/dist/mappers/anthropic-request.js +222 -24
  31. package/dist/mappers/anthropic-response.d.ts +29 -4
  32. package/dist/mappers/anthropic-response.d.ts.map +1 -1
  33. package/dist/mappers/anthropic-response.js +143 -21
  34. package/dist/mappers/request.d.ts +48 -0
  35. package/dist/mappers/request.d.ts.map +1 -1
  36. package/dist/mappers/request.js +211 -35
  37. package/dist/mappers/response.d.ts.map +1 -1
  38. package/dist/mappers/response.js +13 -1
  39. package/dist/model-work-coordinator.d.ts +70 -0
  40. package/dist/model-work-coordinator.d.ts.map +1 -0
  41. package/dist/model-work-coordinator.js +120 -0
  42. package/dist/pending-writes.d.ts.map +1 -1
  43. package/dist/presets.d.ts +82 -0
  44. package/dist/presets.d.ts.map +1 -0
  45. package/dist/presets.js +98 -0
  46. package/dist/registry.d.ts +31 -1
  47. package/dist/registry.d.ts.map +1 -1
  48. package/dist/registry.js +33 -5
  49. package/dist/router.d.ts +4 -1
  50. package/dist/router.d.ts.map +1 -1
  51. package/dist/router.js +34 -4
  52. package/dist/server.d.ts +76 -0
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +48 -1
  55. package/dist/session-registry.d.ts +272 -18
  56. package/dist/session-registry.d.ts.map +1 -1
  57. package/dist/session-registry.js +509 -37
  58. package/dist/stop-sequence-buffer.d.ts +58 -0
  59. package/dist/stop-sequence-buffer.d.ts.map +1 -0
  60. package/dist/stop-sequence-buffer.js +148 -0
  61. package/dist/text-recovery.d.ts +35 -0
  62. package/dist/text-recovery.d.ts.map +1 -0
  63. package/dist/text-recovery.js +41 -0
  64. package/dist/timing.d.ts +80 -0
  65. package/dist/timing.d.ts.map +1 -0
  66. package/dist/timing.js +121 -0
  67. package/dist/tool-call-buffer.d.ts +5 -5
  68. package/dist/tool-call-buffer.d.ts.map +1 -1
  69. package/dist/tool-call-buffer.js +28 -8
  70. package/dist/types-anthropic.d.ts +161 -1
  71. package/dist/types-anthropic.d.ts.map +1 -1
  72. package/dist/types.d.ts +172 -2
  73. package/dist/types.d.ts.map +1 -1
  74. package/package.json +5 -5
@@ -5,11 +5,59 @@ export interface MappedRequest {
5
5
  messages: ChatMessage[];
6
6
  config: ChatConfig;
7
7
  }
8
+ /**
9
+ * Shared MTP-extension parser for the `extra_body` carrier on both
10
+ * `/v1/responses` (OpenAI) and `/v1/messages` (Anthropic) request
11
+ * shapes. Mutates the passed `config` in place:
12
+ *
13
+ * * `generation_mode: "mtp"` → `enableMtp = true`
14
+ * * `generation_mode: "ar"` → `enableMtp = false`
15
+ * * any other / absent value → `enableMtp` untouched, so the
16
+ * downstream `ChatSession.mergeConfig` auto-default (true when
17
+ * the model ships an MTP head) applies.
18
+ *
19
+ * * `mtp_depth: <positive int ≤ 64>` → `mtpDepth = value`
20
+ * * non-integer, out-of-range, or absent → `mtpDepth` untouched.
21
+ * The real clamps are per-family and owned by native
22
+ * `resolve_params`: qwen3.5 native MTP clamps to [1, 5], gemma4
23
+ * DSpark caps at the draft block size (7 on v1), and the gemma4
24
+ * assistant draft clamps to [1, 8]. The server therefore only
25
+ * rejects garbage — non-integers, non-positives, and values
26
+ * > 64 (a generous sanity ceiling far above any family's real
27
+ * clamp) — which saves a round-trip into the model thread.
28
+ *
29
+ * Kept as a pure helper rather than inlined into each mapper so the
30
+ * two endpoints can't drift in semantics.
31
+ */
32
+ export declare function applyExtraBodyMtpOverrides(config: ChatConfig, extraBody: {
33
+ generation_mode?: string | null;
34
+ mtp_depth?: number | null;
35
+ } | undefined): void;
8
36
  export declare function mapRequest(req: ResponsesAPIRequest, priorMessages?: ChatMessage[]): MappedRequest;
37
+ /**
38
+ * Serialise a `ChatMessage[]` snapshot for `StoredResponseRecord.inputJson`,
39
+ * preserving any `Uint8Array` image payloads as base64-encoded sentinels
40
+ * so a later `reconstructMessagesFromChain` can revive them into real
41
+ * `Uint8Array`s for the NAPI chat-session boundary.
42
+ *
43
+ * The replacer runs AFTER `toJSON`, so a `Buffer` (which defines
44
+ * `Buffer.prototype.toJSON` returning `{type:"Buffer",data:[...]}`)
45
+ * would otherwise slip past the `instanceof Uint8Array` check. We
46
+ * match both shapes defensively — the production `resolveMessageContent`
47
+ * path now wraps with `new Uint8Array(...)` at decode time, but any
48
+ * future caller that sneaks a `Buffer` through still round-trips
49
+ * instead of silently corrupting image state.
50
+ */
51
+ export declare function stringifyStoredInputMessages(messages: ChatMessage[]): string;
9
52
  /**
10
53
  * Reconstruct `ChatMessage[]` from a stored response chain. Each record
11
54
  * stores `inputJson` (messages sent) and `outputJson` (output items); we
12
55
  * interleave them.
56
+ *
57
+ * Image payloads encoded as `{__u8__: "<base64>"}` by
58
+ * `stringifyStoredInputMessages` are rehydrated back into `Buffer`
59
+ * (a `Uint8Array` subclass) so replayed user turns carry the same
60
+ * runtime shape the native chat-session APIs expect.
13
61
  */
14
62
  export declare function reconstructMessagesFromChain(chain: {
15
63
  inputJson: string;
@@ -1 +1 @@
1
- {"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../../src/mappers/request.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAE9E,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAkB,MAAM,gBAAgB,CAAC;AAE9E,OAAO,KAAK,EAAe,mBAAmB,EAA2B,MAAM,aAAa,CAAC;AAqC7F,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,MAAM,EAAE,UAAU,CAAC;CACpB;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,mBAAmB,EAAE,aAAa,CAAC,EAAE,WAAW,EAAE,GAAG,aAAa,CA4GjG;AAED;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,WAAW,EAAE,CAoE9G"}
1
+ {"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../../src/mappers/request.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAE9E,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAkB,MAAM,gBAAgB,CAAC;AAE9E,OAAO,KAAK,EAAe,mBAAmB,EAA2B,MAAM,aAAa,CAAC;AA2G7F,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,MAAM,EAAE,UAAU,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,UAAU,EAClB,SAAS,EAAE;IAAE,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,SAAS,GACpF,IAAI,CAcN;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,mBAAmB,EAAE,aAAa,CAAC,EAAE,WAAW,EAAE,GAAG,aAAa,CAmIjG;AAwBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,MAAM,CAgB5E;AAED;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,WAAW,EAAE,CAyE9G"}
@@ -1,17 +1,78 @@
1
1
  /** OpenAI Responses API request → internal `ChatMessage[]` + `ChatConfig`. */
2
- function resolveContent(content) {
2
+ /**
3
+ * Resolve a message's `content` array into text + optional image bytes.
4
+ *
5
+ * Accepts both input-side (`input_text`, `input_image`) and replay-side
6
+ * (`output_text`, `refusal`, `summary_text`) content parts. Clients that echo
7
+ * prior assistant turns in `input[]` instead of using `previous_response_id`
8
+ * (pi-ai, Codex) send `output_text` on assistant messages — rejecting those
9
+ * would break cold-start replay. `input_image` with a base64 `data:` URL is
10
+ * decoded to bytes; `http(s)://` URLs are not fetched (the mapper stays sync).
11
+ */
12
+ function resolveMessageContent(content, role) {
3
13
  if (typeof content === 'string')
4
- return content;
14
+ return { text: content };
5
15
  const parts = [];
16
+ const images = [];
17
+ // The internal `ChatMessage` shape is `{ content: string, images: Uint8Array[] }`
18
+ // and the downstream Jinja serializer always emits `[{type:"text",...},
19
+ // {type:"image"}*N]` — it cannot represent a text part that appears AFTER
20
+ // an image part in the caller's content array. Detect and reject that
21
+ // shape rather than silently reordering it and changing user intent.
22
+ // Mirrors the existing rejection in `anthropic-request.ts` for the
23
+ // tool_result + trailing-mixed case.
24
+ let seenImage = false;
6
25
  for (const p of content) {
7
- if (p.type === 'input_text') {
26
+ if (p.type === 'input_text' || p.type === 'output_text' || p.type === 'summary_text') {
27
+ if (seenImage) {
28
+ throw new Error('Unsupported: text content part after an image part in the same message is not representable ' +
29
+ 'in the internal message model. The flat ChatMessage shape and the Jinja serializer both place ' +
30
+ 'all text before all images in a user turn, so any mapping would silently reorder your content. ' +
31
+ 'Place all text parts before any image parts, or split across separate user turns.');
32
+ }
8
33
  parts.push(p.text);
9
34
  }
35
+ else if (p.type === 'refusal') {
36
+ if (seenImage) {
37
+ throw new Error('Unsupported: refusal content part after an image part in the same message is not representable ' +
38
+ 'in the internal message model; the flat ChatMessage shape would silently reorder it.');
39
+ }
40
+ parts.push(p.refusal);
41
+ }
42
+ else if (p.type === 'input_image') {
43
+ if (role !== 'user') {
44
+ throw new Error(`input_image content parts are only allowed on user messages (got role="${role}")`);
45
+ }
46
+ if (p.file_id) {
47
+ throw new Error('input_image.file_id is not supported — inline the image as a data URL');
48
+ }
49
+ if (!p.image_url) {
50
+ throw new Error('input_image is missing image_url');
51
+ }
52
+ const match = /^data:[^;,]+;base64,(.+)$/s.exec(p.image_url);
53
+ if (!match) {
54
+ throw new Error('input_image.image_url must be a base64 data URL (data:<mime>;base64,<payload>); ' +
55
+ 'remote http(s) URLs are not fetched by this server');
56
+ }
57
+ // Wrap as a plain `Uint8Array` rather than storing the raw
58
+ // `Buffer`. `Buffer` is a `Uint8Array` subclass, but it defines
59
+ // its own `toJSON()` that `JSON.stringify` calls BEFORE any
60
+ // replacer runs — so a Buffer-backed value would serialise as
61
+ // `{type:"Buffer",data:[...]}` and skip the `__u8__` sentinel in
62
+ // `stringifyStoredInputMessages`, corrupting image round-trip
63
+ // through `previous_response_id` chains. A plain `Uint8Array`
64
+ // has no `toJSON`, so the replacer fires as intended.
65
+ images.push(new Uint8Array(Buffer.from(match[1], 'base64')));
66
+ seenImage = true;
67
+ }
10
68
  else {
11
69
  throw new Error(`Unsupported content part type: "${p.type}"`);
12
70
  }
13
71
  }
14
- return parts.join('');
72
+ const out = { text: parts.join('') };
73
+ if (images.length > 0)
74
+ out.images = images;
75
+ return out;
15
76
  }
16
77
  /** NAPI `ToolDefinition` requires `parameters.properties` to be a JSON string. */
17
78
  function mapTool(tool) {
@@ -34,6 +95,46 @@ function mapTool(tool) {
34
95
  },
35
96
  };
36
97
  }
98
+ /**
99
+ * Shared MTP-extension parser for the `extra_body` carrier on both
100
+ * `/v1/responses` (OpenAI) and `/v1/messages` (Anthropic) request
101
+ * shapes. Mutates the passed `config` in place:
102
+ *
103
+ * * `generation_mode: "mtp"` → `enableMtp = true`
104
+ * * `generation_mode: "ar"` → `enableMtp = false`
105
+ * * any other / absent value → `enableMtp` untouched, so the
106
+ * downstream `ChatSession.mergeConfig` auto-default (true when
107
+ * the model ships an MTP head) applies.
108
+ *
109
+ * * `mtp_depth: <positive int ≤ 64>` → `mtpDepth = value`
110
+ * * non-integer, out-of-range, or absent → `mtpDepth` untouched.
111
+ * The real clamps are per-family and owned by native
112
+ * `resolve_params`: qwen3.5 native MTP clamps to [1, 5], gemma4
113
+ * DSpark caps at the draft block size (7 on v1), and the gemma4
114
+ * assistant draft clamps to [1, 8]. The server therefore only
115
+ * rejects garbage — non-integers, non-positives, and values
116
+ * > 64 (a generous sanity ceiling far above any family's real
117
+ * clamp) — which saves a round-trip into the model thread.
118
+ *
119
+ * Kept as a pure helper rather than inlined into each mapper so the
120
+ * two endpoints can't drift in semantics.
121
+ */
122
+ export function applyExtraBodyMtpOverrides(config, extraBody) {
123
+ if (!extraBody)
124
+ return;
125
+ const mode = extraBody.generation_mode;
126
+ if (mode === 'mtp') {
127
+ config.enableMtp = true;
128
+ }
129
+ else if (mode === 'ar') {
130
+ config.enableMtp = false;
131
+ }
132
+ // Any other value (null, undefined, unknown string) → leave alone.
133
+ const depth = extraBody.mtp_depth;
134
+ if (depth != null && Number.isInteger(depth) && depth > 0 && depth <= 64) {
135
+ config.mtpDepth = depth;
136
+ }
137
+ }
37
138
  export function mapRequest(req, priorMessages) {
38
139
  const messages = [];
39
140
  if (req.instructions) {
@@ -42,18 +143,33 @@ export function mapRequest(req, priorMessages) {
42
143
  if (priorMessages) {
43
144
  messages.push(...priorMessages);
44
145
  }
45
- // Coalesce a `message + function_call+` run (or a pure `function_call+` run)
46
- // into ONE assistant `ChatMessage` carrying both `content` and `toolCalls`.
47
- // `ChatSession.sendStream()` appends exactly one assistant message per turn,
48
- // and `validateAndCanonicalizeHistoryToolOrder` requires each fan-out's
49
- // `toolCalls` to pair 1:1 with the trailing tool block — splitting would
50
- // reshape the conversation and make the walker reject the turn as orphaned.
51
- // A `message` item immediately after a `function_call` starts a new turn.
146
+ // An assistant turn may serialise into any interleaving of `reasoning`,
147
+ // `message` (assistant), and `function_call` items. We coalesce that run
148
+ // into ONE assistant `ChatMessage` carrying `content` + `reasoningContent`
149
+ // + `toolCalls`, matching the hot-path `ChatSession` shape exactly. Any
150
+ // non-assistant item (user / system / function_call_output) flushes the
151
+ // current turn. An assistant `message` item that appears AFTER a
152
+ // `function_call` opens a fresh turn preserving the pre-existing
153
+ // convention documented in the fan-out tests.
52
154
  if (typeof req.input === 'string') {
53
155
  messages.push({ role: 'user', content: req.input });
54
156
  }
55
157
  else {
56
- let prevItemType = null;
158
+ let currentAssistant = null;
159
+ let assistantHasToolCalls = false;
160
+ const flushAssistant = () => {
161
+ if (currentAssistant) {
162
+ messages.push(currentAssistant);
163
+ currentAssistant = null;
164
+ assistantHasToolCalls = false;
165
+ }
166
+ };
167
+ const ensureAssistant = () => {
168
+ if (!currentAssistant) {
169
+ currentAssistant = { role: 'assistant', content: '' };
170
+ }
171
+ return currentAssistant;
172
+ };
57
173
  for (const item of req.input) {
58
174
  if (item == null || typeof item !== 'object') {
59
175
  throw new Error('Each input item must be a non-null object');
@@ -66,34 +182,40 @@ export function mapRequest(req, priorMessages) {
66
182
  if (role !== 'user' && role !== 'assistant' && role !== 'system') {
67
183
  throw new Error(`Unsupported message role: "${msg.role}"`);
68
184
  }
69
- messages.push({
70
- role,
71
- content: resolveContent(msg.content),
72
- });
73
- }
74
- else if (itemType === 'function_call') {
75
- // Coalesce onto the preceding assistant turn — see the loop header.
76
- const fc = item;
77
- const last = messages[messages.length - 1];
78
- const canCoalesce = (prevItemType === 'function_call' || prevItemType === 'message') &&
79
- last !== undefined &&
80
- last.role === 'assistant';
81
- if (canCoalesce) {
82
- if (last.toolCalls === undefined) {
83
- last.toolCalls = [];
185
+ if (role === 'assistant') {
186
+ // `message` after a `function_call` opens a new turn.
187
+ if (assistantHasToolCalls) {
188
+ flushAssistant();
84
189
  }
85
- last.toolCalls.push({ name: fc.name, arguments: fc.arguments, id: fc.call_id });
190
+ const a = ensureAssistant();
191
+ const { text } = resolveMessageContent(msg.content, 'assistant');
192
+ a.content = (a.content ?? '') + text;
86
193
  }
87
194
  else {
88
- messages.push({
89
- role: 'assistant',
90
- content: '',
91
- toolCalls: [{ name: fc.name, arguments: fc.arguments, id: fc.call_id }],
92
- });
195
+ flushAssistant();
196
+ const { text, images } = resolveMessageContent(msg.content, role);
197
+ const m = { role, content: text };
198
+ if (images)
199
+ m.images = images;
200
+ messages.push(m);
93
201
  }
94
202
  }
203
+ else if (itemType === 'reasoning') {
204
+ const r = item;
205
+ const summary = (r.summary ?? []).map((s) => s.text ?? '').join('');
206
+ const a = ensureAssistant();
207
+ a.reasoningContent = (a.reasoningContent ?? '') + summary;
208
+ }
209
+ else if (itemType === 'function_call') {
210
+ const fc = item;
211
+ const a = ensureAssistant();
212
+ a.toolCalls ??= [];
213
+ a.toolCalls.push({ name: fc.name, arguments: fc.arguments, id: fc.call_id });
214
+ assistantHasToolCalls = true;
215
+ }
95
216
  else if (itemType === 'function_call_output') {
96
217
  const fco = item;
218
+ flushAssistant();
97
219
  messages.push({
98
220
  role: 'tool',
99
221
  content: fco.output,
@@ -103,8 +225,8 @@ export function mapRequest(req, priorMessages) {
103
225
  else {
104
226
  throw new Error(`Unsupported input item type: "${itemType}"`);
105
227
  }
106
- prevItemType = itemType;
107
228
  }
229
+ flushAssistant();
108
230
  }
109
231
  const config = {
110
232
  reportPerformance: true,
@@ -139,17 +261,71 @@ export function mapRequest(req, priorMessages) {
139
261
  if (priorMessages && priorMessages.length > 0) {
140
262
  config.reuseCache = true;
141
263
  }
264
+ applyExtraBodyMtpOverrides(config, req.extra_body);
142
265
  return { messages, config };
143
266
  }
267
+ /**
268
+ * Sentinel key used to tag base64-encoded `Uint8Array` payloads in
269
+ * persisted `inputJson`. Plain `JSON.stringify` turns a `Uint8Array`
270
+ * into a numeric-keyed object (e.g. `{"0":1,"1":2,...}`), which
271
+ * (a) bloats the row ~8× vs base64 and (b) does not round-trip — the
272
+ * parsed object fails the NAPI `Uint8Array` type check on cold replay,
273
+ * breaking `previous_response_id` continuations that carry images.
274
+ */
275
+ const UINT8_SENTINEL = '__u8__';
276
+ function isEncodedUint8Array(value) {
277
+ return (value !== null &&
278
+ typeof value === 'object' &&
279
+ typeof value[UINT8_SENTINEL] === 'string');
280
+ }
281
+ /**
282
+ * Serialise a `ChatMessage[]` snapshot for `StoredResponseRecord.inputJson`,
283
+ * preserving any `Uint8Array` image payloads as base64-encoded sentinels
284
+ * so a later `reconstructMessagesFromChain` can revive them into real
285
+ * `Uint8Array`s for the NAPI chat-session boundary.
286
+ *
287
+ * The replacer runs AFTER `toJSON`, so a `Buffer` (which defines
288
+ * `Buffer.prototype.toJSON` returning `{type:"Buffer",data:[...]}`)
289
+ * would otherwise slip past the `instanceof Uint8Array` check. We
290
+ * match both shapes defensively — the production `resolveMessageContent`
291
+ * path now wraps with `new Uint8Array(...)` at decode time, but any
292
+ * future caller that sneaks a `Buffer` through still round-trips
293
+ * instead of silently corrupting image state.
294
+ */
295
+ export function stringifyStoredInputMessages(messages) {
296
+ return JSON.stringify(messages, (_key, value) => {
297
+ if (value instanceof Uint8Array) {
298
+ return { [UINT8_SENTINEL]: Buffer.from(value).toString('base64') };
299
+ }
300
+ if (value !== null &&
301
+ typeof value === 'object' &&
302
+ value.type === 'Buffer' &&
303
+ Array.isArray(value.data)) {
304
+ const data = value.data;
305
+ return { [UINT8_SENTINEL]: Buffer.from(data).toString('base64') };
306
+ }
307
+ return value;
308
+ });
309
+ }
144
310
  /**
145
311
  * Reconstruct `ChatMessage[]` from a stored response chain. Each record
146
312
  * stores `inputJson` (messages sent) and `outputJson` (output items); we
147
313
  * interleave them.
314
+ *
315
+ * Image payloads encoded as `{__u8__: "<base64>"}` by
316
+ * `stringifyStoredInputMessages` are rehydrated back into `Buffer`
317
+ * (a `Uint8Array` subclass) so replayed user turns carry the same
318
+ * runtime shape the native chat-session APIs expect.
148
319
  */
149
320
  export function reconstructMessagesFromChain(chain) {
150
321
  const messages = [];
151
322
  for (const record of chain) {
152
- const inputMessages = JSON.parse(record.inputJson);
323
+ const inputMessages = JSON.parse(record.inputJson, (_key, value) => {
324
+ if (isEncodedUint8Array(value)) {
325
+ return Buffer.from(value[UINT8_SENTINEL], 'base64');
326
+ }
327
+ return value;
328
+ });
153
329
  messages.push(...inputMessages);
154
330
  const outputItems = JSON.parse(record.outputJson);
155
331
  let assistantText = '';
@@ -1 +1 @@
1
- {"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/mappers/response.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAIlE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,OAAO,KAAK,EAGV,UAAU,EAEV,cAAc,EACd,mBAAmB,EACnB,aAAa,EACd,MAAM,aAAa,CAAC;AAErB,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE5C;AAED,wBAAgB,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,WAAW,GAAG,YAAY,CAOxF;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,UAAU,EAAE,CAwCjE;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,GAAG,aAAa,CAO5D;AAED,8DAA8D;AAC9D,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,MAAM,CAU7D;AAED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,mBAAmB,EACxB,UAAU,EAAE,MAAM,EAClB,kBAAkB,CAAC,EAAE,MAAM,GAC1B,cAAc,CAwBhB;AAED,qHAAqH;AACrH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,mBAAmB,EACxB,UAAU,EAAE,MAAM,EAClB,kBAAkB,CAAC,EAAE,MAAM,GAC1B,cAAc,CAqBhB"}
1
+ {"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/mappers/response.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAIlE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAGjD,OAAO,KAAK,EAGV,UAAU,EAEV,cAAc,EACd,mBAAmB,EACnB,aAAa,EACd,MAAM,aAAa,CAAC;AAErB,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE5C;AAED,wBAAgB,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,WAAW,GAAG,YAAY,CAOxF;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,UAAU,EAAE,CAwCjE;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,GAAG,aAAa,CAkB5D;AAED,8DAA8D;AAC9D,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,MAAM,CAU7D;AAED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,mBAAmB,EACxB,UAAU,EAAE,MAAM,EAClB,kBAAkB,CAAC,EAAE,MAAM,GAC1B,cAAc,CAwBhB;AAED,qHAAqH;AACrH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,mBAAmB,EACxB,UAAU,EAAE,MAAM,EAClB,kBAAkB,CAAC,EAAE,MAAM,GAC1B,cAAc,CAqBhB"}
@@ -1,5 +1,6 @@
1
1
  /** ChatResult / ChatStreamEvent → OpenAI Responses API output. */
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { mergeTimingUsageExtensions } from '../timing.js';
3
4
  export function genId(prefix) {
4
5
  return `${prefix}${randomUUID().replaceAll('-', '')}`;
5
6
  }
@@ -48,12 +49,23 @@ export function buildOutputItems(result) {
48
49
  return items;
49
50
  }
50
51
  export function buildUsage(result) {
51
- return {
52
+ const usage = {
52
53
  input_tokens: result.promptTokens,
53
54
  output_tokens: result.numTokens,
54
55
  output_tokens_details: { reasoning_tokens: result.reasoningTokens },
55
56
  total_tokens: result.promptTokens + result.numTokens,
56
57
  };
58
+ // Surface reused KV-cache prefix tokens via the upstream
59
+ // `input_tokens_details.cached_tokens` field when the native
60
+ // dispatch reports a non-zero reuse count. Omitted when zero so
61
+ // consumers can cheaply `usage.input_tokens_details?.cached_tokens`
62
+ // without also distinguishing a meaningful zero from "feature not
63
+ // active on this turn".
64
+ if (result.cachedTokens > 0) {
65
+ usage.input_tokens_details = { cached_tokens: result.cachedTokens };
66
+ }
67
+ mergeTimingUsageExtensions(usage, result.performance, result.promptTokens, result.numTokens, result.cachedTokens);
68
+ return usage;
57
69
  }
58
70
  /** Concatenate all `output_text` parts from message items. */
59
71
  export function computeOutputText(items) {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Result of a `withModelLoad` call that surfaces who actually drove the load
3
+ * vs. who merely parked behind one that was already in flight. Callers use
4
+ * this to split observability between the request that triggered a cold
5
+ * weight-materialize from one that arrived a millisecond later and merely
6
+ * inherited the wait — without the split a 60-second cold-load shows up
7
+ * on every concurrent request as if each one paid for a separate load.
8
+ *
9
+ * `owner` reflects the SYNCHRONOUS state observed at lock acquisition:
10
+ * `true` if the writer lock was free when this caller arrived and the
11
+ * caller itself executed the supplied `fn`; `false` if there was already
12
+ * a writer active (or queued ahead of this caller) when it arrived.
13
+ *
14
+ * `waitMs` and `ownMs` partition the wall-clock interval between when
15
+ * the caller arrived at the coordinator and when its `fn` resolved:
16
+ * - `waitMs` is time spent blocked inside `acquireWrite()` (zero for a
17
+ * no-contention owner; ≈ peer's load duration for a follower).
18
+ * - `ownMs` is time spent inside `fn` once the writer lock was held
19
+ * (≈ load duration for an owner driving a cold load; near-zero for a
20
+ * follower whose `fn` is a no-op cache lookup).
21
+ * Both are measured from `Date.now()` and clamped at zero to absorb
22
+ * monotonic-skew. Their sum equals the total elapsed time in the call,
23
+ * so handlers can plumb them into separate observability fields
24
+ * (`server_load_wait_ms` vs. `server_model_resolve_ms`) without
25
+ * double-counting.
26
+ */
27
+ export interface ModelLoadOutcome<T> {
28
+ result: T;
29
+ owner: boolean;
30
+ waitMs: number;
31
+ ownMs: number;
32
+ }
33
+ /**
34
+ * Process-local gate for native MLX work.
35
+ *
36
+ * Individual model instances already have a per-model execution mutex, but a
37
+ * lazy `loadModel()` can still run load-time materialization / warmup Metal
38
+ * work while another model is decoding. MLX's allocator and command queues are
39
+ * process-wide, so model load/swap takes an exclusive writer slot; inference
40
+ * takes shared reader slots.
41
+ */
42
+ export declare class ModelWorkCoordinator {
43
+ private activeReaders;
44
+ private writerActive;
45
+ private waitingWriters;
46
+ private readonly readerWaiters;
47
+ private readonly writerWaiters;
48
+ withModelLoad<T>(fn: () => Promise<T> | T): Promise<T>;
49
+ /**
50
+ * Like {@link withModelLoad} but reports whether THIS caller owned the
51
+ * load (acquired the writer lock with no contention) or merely waited
52
+ * behind a load that was already in flight when it arrived.
53
+ *
54
+ * Decided at sync-time before any await: if neither a writer is active
55
+ * nor any writer is queued ahead, this caller is the owner; otherwise
56
+ * it is parked behind someone else's load and `owner` is `false`. The
57
+ * distinction is used by `/v1/messages` to split `resolve_ms` (own
58
+ * load + lookup) from `load_wait_ms` (waiting on a peer's load) so a
59
+ * 60-second cold-load does not look like 60 seconds of own work for
60
+ * every concurrent request.
61
+ */
62
+ withModelLoadInstrumented<T>(fn: () => Promise<T> | T): Promise<ModelLoadOutcome<T>>;
63
+ withInference<T>(fn: () => Promise<T> | T): Promise<T>;
64
+ private acquireRead;
65
+ private acquireWrite;
66
+ private releaseRead;
67
+ private releaseWrite;
68
+ private drain;
69
+ }
70
+ //# sourceMappingURL=model-work-coordinator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-work-coordinator.d.ts","sourceRoot":"","sources":["../src/model-work-coordinator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,WAAW,gBAAgB,CAAC,CAAC;IACjC,MAAM,EAAE,CAAC,CAAC;IACV,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;GAQG;AACH,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAyB;IACvD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAyB;IAEjD,aAAa,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAO3D;IAED;;;;;;;;;;;;OAYG;IACG,yBAAyB,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAoBzF;IAEK,aAAa,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAO3D;IAED,OAAO,CAAC,WAAW;IAanB,OAAO,CAAC,YAAY;IAgBpB,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,KAAK;CAWd"}
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Process-local gate for native MLX work.
3
+ *
4
+ * Individual model instances already have a per-model execution mutex, but a
5
+ * lazy `loadModel()` can still run load-time materialization / warmup Metal
6
+ * work while another model is decoding. MLX's allocator and command queues are
7
+ * process-wide, so model load/swap takes an exclusive writer slot; inference
8
+ * takes shared reader slots.
9
+ */
10
+ export class ModelWorkCoordinator {
11
+ activeReaders = 0;
12
+ writerActive = false;
13
+ waitingWriters = 0;
14
+ readerWaiters = [];
15
+ writerWaiters = [];
16
+ async withModelLoad(fn) {
17
+ await this.acquireWrite();
18
+ try {
19
+ return await fn();
20
+ }
21
+ finally {
22
+ this.releaseWrite();
23
+ }
24
+ }
25
+ /**
26
+ * Like {@link withModelLoad} but reports whether THIS caller owned the
27
+ * load (acquired the writer lock with no contention) or merely waited
28
+ * behind a load that was already in flight when it arrived.
29
+ *
30
+ * Decided at sync-time before any await: if neither a writer is active
31
+ * nor any writer is queued ahead, this caller is the owner; otherwise
32
+ * it is parked behind someone else's load and `owner` is `false`. The
33
+ * distinction is used by `/v1/messages` to split `resolve_ms` (own
34
+ * load + lookup) from `load_wait_ms` (waiting on a peer's load) so a
35
+ * 60-second cold-load does not look like 60 seconds of own work for
36
+ * every concurrent request.
37
+ */
38
+ async withModelLoadInstrumented(fn) {
39
+ // `owner` MUST be decided synchronously, before any await, so the
40
+ // signal reflects coordinator state at arrival rather than after
41
+ // any peer transition. The wait/own split is measured around the
42
+ // actual phase boundaries (lock acquisition, fn completion) so the
43
+ // two intervals partition cleanly instead of both reporting total
44
+ // elapsed time — see `ModelLoadOutcome` for the contract.
45
+ const owner = !this.writerActive && this.waitingWriters === 0;
46
+ const arrivedAt = Date.now();
47
+ await this.acquireWrite();
48
+ const lockAcquiredAt = Date.now();
49
+ try {
50
+ const result = await fn();
51
+ const fnDoneAt = Date.now();
52
+ const waitMs = Math.max(0, lockAcquiredAt - arrivedAt);
53
+ const ownMs = Math.max(0, fnDoneAt - lockAcquiredAt);
54
+ return { result, owner, waitMs, ownMs };
55
+ }
56
+ finally {
57
+ this.releaseWrite();
58
+ }
59
+ }
60
+ async withInference(fn) {
61
+ await this.acquireRead();
62
+ try {
63
+ return await fn();
64
+ }
65
+ finally {
66
+ this.releaseRead();
67
+ }
68
+ }
69
+ acquireRead() {
70
+ if (!this.writerActive && this.waitingWriters === 0) {
71
+ this.activeReaders += 1;
72
+ return Promise.resolve();
73
+ }
74
+ return new Promise((resolve) => {
75
+ this.readerWaiters.push(() => {
76
+ this.activeReaders += 1;
77
+ resolve();
78
+ });
79
+ });
80
+ }
81
+ acquireWrite() {
82
+ this.waitingWriters += 1;
83
+ if (!this.writerActive && this.activeReaders === 0) {
84
+ this.waitingWriters -= 1;
85
+ this.writerActive = true;
86
+ return Promise.resolve();
87
+ }
88
+ return new Promise((resolve) => {
89
+ this.writerWaiters.push(() => {
90
+ this.waitingWriters -= 1;
91
+ this.writerActive = true;
92
+ resolve();
93
+ });
94
+ });
95
+ }
96
+ releaseRead() {
97
+ this.activeReaders -= 1;
98
+ if (this.activeReaders < 0)
99
+ this.activeReaders = 0;
100
+ if (this.activeReaders === 0)
101
+ this.drain();
102
+ }
103
+ releaseWrite() {
104
+ this.writerActive = false;
105
+ this.drain();
106
+ }
107
+ drain() {
108
+ if (this.writerActive)
109
+ return;
110
+ if (this.activeReaders === 0 && this.writerWaiters.length > 0) {
111
+ this.writerWaiters.shift()?.();
112
+ return;
113
+ }
114
+ if (this.waitingWriters === 0 && this.readerWaiters.length > 0) {
115
+ const readers = this.readerWaiters.splice(0);
116
+ for (const resolve of readers)
117
+ resolve();
118
+ }
119
+ }
120
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"pending-writes.d.ts","sourceRoot":"","sources":["../src/pending-writes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwGG;AAEH;;;;;;;GAOG;AACH,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAyC;IAEjE;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAkC;IAE3E;;;;;;;;;;;;;;;;;;OAkBG;IACH,OAAO,CAAC,QAAQ,CAAC,YAAY,CACjB;IAEZ;;;;;;;;;;;;;;;;;;OAkBG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,oBAAoB,CAAM;IAElD;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,mBAAmB,CAAC,EAAE,MAAM,GAAG,IAAI;IA0ClF;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,SAAS;IAInD;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAWtD;;;;;;;;OAQG;IACH,OAAO,CAAC,MAAM,CAAC,YAAY;IAI3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,GAAG,OAAO;IAyB/E;;;;;;;OAOG;IACH,OAAO,CAAC,YAAY;IAYpB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO;IAqBnC,iEAAiE;IACjE,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED;;;;;;;;;;;;OAYG;IACH,IAAI,gBAAgB,IAAI,MAAM,CAG7B;IAED;;;;;;OAMG;IACH,IAAI,4BAA4B,IAAI,MAAM,CAEzC;CACF;AAYD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,qBAAqB,CAOxE"}
1
+ {"version":3,"file":"pending-writes.d.ts","sourceRoot":"","sources":["../src/pending-writes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwGG;AAEH;;;;;;;GAOG;AACH,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAyC;IAEjE;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAkC;IAE3E;;;;;;;;;;;;;;;;;;OAkBG;IACH,OAAO,CAAC,QAAQ,CAAC,YAAY,CACjB;IAEZ;;;;;;;;;;;;;;;;;;OAkBG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,oBAAoB,CAAM;IAElD;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,mBAAmB,CAAC,EAAE,MAAM,GAAG,IAAI,CAwCjF;IAED;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,SAAS,CAElD;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CASrD;IAED;;;;;;;;OAQG;IACH,OAAO,CAAC,MAAM,CAAC,YAAY;IAI3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,GAAG,OAAO,CAuB9E;IAED;;;;;;;OAOG;IACH,OAAO,CAAC,YAAY;IAYpB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAmBlC;IAED,iEAAiE;IACjE,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED;;;;;;;;;;;;OAYG;IACH,IAAI,gBAAgB,IAAI,MAAM,CAG7B;IAED;;;;;;OAMG;IACH,IAAI,4BAA4B,IAAI,MAAM,CAEzC;CACF;AAYD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,qBAAqB,CAOxE"}