@mlx-node/server 0.0.12 → 0.0.15
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/host/discover.d.ts +3 -6
- package/dist/host/discover.d.ts.map +1 -1
- package/dist/host/discover.js +9 -42
- package/dist/host/index.d.ts +2 -2
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +8 -1
- package/package.json +9 -4
- package/src/auth.ts +111 -0
- package/src/chat-session-warm-reuse.ts +96 -0
- package/src/endpoints/messages-count-tokens.ts +164 -0
- package/src/endpoints/messages.ts +1802 -0
- package/src/endpoints/models.ts +20 -0
- package/src/endpoints/responses.ts +3928 -0
- package/src/errors.ts +120 -0
- package/src/handler.ts +195 -0
- package/src/health.ts +213 -0
- package/src/host/discover.ts +25 -0
- package/src/host/env-policy.ts +81 -0
- package/src/host/index.ts +496 -0
- package/src/host/logger.ts +419 -0
- package/src/host/net.ts +100 -0
- package/src/host/paths.ts +77 -0
- package/src/host/swap.ts +200 -0
- package/src/host/temp-root.ts +110 -0
- package/src/idle-sweeper.ts +555 -0
- package/src/index.ts +114 -0
- package/src/load-model.ts +92 -0
- package/src/mappers/anthropic-request.ts +485 -0
- package/src/mappers/anthropic-response.ts +306 -0
- package/src/mappers/request.ts +456 -0
- package/src/mappers/response.ts +163 -0
- package/src/model-work-coordinator.ts +416 -0
- package/src/pending-writes.ts +481 -0
- package/src/registry.ts +691 -0
- package/src/router.ts +220 -0
- package/src/server.ts +579 -0
- package/src/session-registry.ts +1371 -0
- package/src/stop-sequence-buffer.ts +161 -0
- package/src/streaming.ts +205 -0
- package/src/text-recovery.ts +41 -0
- package/src/timing.ts +236 -0
- package/src/tool-call-buffer.ts +78 -0
- package/src/transport-visibility.ts +185 -0
- package/src/types-anthropic.ts +409 -0
- package/src/types.ts +470 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/** OpenAI Responses API request → internal `ChatMessage[]` + `ChatConfig`. */
|
|
2
|
+
|
|
3
|
+
import type { ChatConfig, ChatMessage, ToolDefinition } from '@mlx-node/core';
|
|
4
|
+
|
|
5
|
+
import type { ContentPart, ResponsesAPIRequest, ResponsesToolDefinition } from '../types.js';
|
|
6
|
+
|
|
7
|
+
export const MAX_CACHE_SALT_BYTES = 256;
|
|
8
|
+
|
|
9
|
+
export function validateCacheSalt(cacheSalt: unknown): asserts cacheSalt is string {
|
|
10
|
+
if (typeof cacheSalt !== 'string') {
|
|
11
|
+
throw new Error('cache_salt must be a string');
|
|
12
|
+
}
|
|
13
|
+
if (cacheSalt.length === 0) {
|
|
14
|
+
throw new Error('cache_salt must not be empty');
|
|
15
|
+
}
|
|
16
|
+
if (Buffer.byteLength(cacheSalt, 'utf8') > MAX_CACHE_SALT_BYTES) {
|
|
17
|
+
throw new Error(`cache_salt must be at most ${MAX_CACHE_SALT_BYTES} UTF-8 bytes`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a message's `content` array into text + optional image bytes.
|
|
23
|
+
*
|
|
24
|
+
* Accepts both input-side (`input_text`, `input_image`) and replay-side
|
|
25
|
+
* (`output_text`, `refusal`, `summary_text`) content parts. Clients that echo
|
|
26
|
+
* prior assistant turns in `input[]` instead of using `previous_response_id`
|
|
27
|
+
* (pi-ai, Codex) send `output_text` on assistant messages — rejecting those
|
|
28
|
+
* would break cold-start replay. `input_image` with a base64 `data:` URL is
|
|
29
|
+
* decoded to bytes; `http(s)://` URLs are not fetched (the mapper stays sync).
|
|
30
|
+
*/
|
|
31
|
+
function resolveMessageContent(
|
|
32
|
+
content: string | ContentPart[],
|
|
33
|
+
role: 'user' | 'assistant' | 'system',
|
|
34
|
+
): { text: string; images?: Uint8Array[] } {
|
|
35
|
+
if (typeof content === 'string') return { text: content };
|
|
36
|
+
|
|
37
|
+
const parts: string[] = [];
|
|
38
|
+
const images: Uint8Array[] = [];
|
|
39
|
+
// The internal `ChatMessage` shape is `{ content: string, images: Uint8Array[] }`
|
|
40
|
+
// and the downstream Jinja serializer always emits `[{type:"text",...},
|
|
41
|
+
// {type:"image"}*N]` — it cannot represent a text part that appears AFTER
|
|
42
|
+
// an image part in the caller's content array. Detect and reject that
|
|
43
|
+
// shape rather than silently reordering it and changing user intent.
|
|
44
|
+
// Mirrors the existing rejection in `anthropic-request.ts` for the
|
|
45
|
+
// tool_result + trailing-mixed case.
|
|
46
|
+
let seenImage = false;
|
|
47
|
+
|
|
48
|
+
for (const p of content) {
|
|
49
|
+
if (p.type === 'input_text' || p.type === 'output_text' || p.type === 'summary_text') {
|
|
50
|
+
if (seenImage) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'Unsupported: text content part after an image part in the same message is not representable ' +
|
|
53
|
+
'in the internal message model. The flat ChatMessage shape and the Jinja serializer both place ' +
|
|
54
|
+
'all text before all images in a user turn, so any mapping would silently reorder your content. ' +
|
|
55
|
+
'Place all text parts before any image parts, or split across separate user turns.',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
parts.push(p.text);
|
|
59
|
+
} else if (p.type === 'refusal') {
|
|
60
|
+
if (seenImage) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'Unsupported: refusal content part after an image part in the same message is not representable ' +
|
|
63
|
+
'in the internal message model; the flat ChatMessage shape would silently reorder it.',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
parts.push(p.refusal);
|
|
67
|
+
} else if (p.type === 'input_image') {
|
|
68
|
+
if (role !== 'user') {
|
|
69
|
+
throw new Error(`input_image content parts are only allowed on user messages (got role="${role}")`);
|
|
70
|
+
}
|
|
71
|
+
if (p.file_id) {
|
|
72
|
+
throw new Error('input_image.file_id is not supported — inline the image as a data URL');
|
|
73
|
+
}
|
|
74
|
+
if (!p.image_url) {
|
|
75
|
+
throw new Error('input_image is missing image_url');
|
|
76
|
+
}
|
|
77
|
+
const match = /^data:[^;,]+;base64,(.+)$/s.exec(p.image_url);
|
|
78
|
+
if (!match) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
'input_image.image_url must be a base64 data URL (data:<mime>;base64,<payload>); ' +
|
|
81
|
+
'remote http(s) URLs are not fetched by this server',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
// Wrap as a plain `Uint8Array` rather than storing the raw
|
|
85
|
+
// `Buffer`. `Buffer` is a `Uint8Array` subclass, but it defines
|
|
86
|
+
// its own `toJSON()` that `JSON.stringify` calls BEFORE any
|
|
87
|
+
// replacer runs — so a Buffer-backed value would serialise as
|
|
88
|
+
// `{type:"Buffer",data:[...]}` and skip the `__u8__` sentinel in
|
|
89
|
+
// `stringifyStoredInputMessages`, corrupting image round-trip
|
|
90
|
+
// through `previous_response_id` chains. A plain `Uint8Array`
|
|
91
|
+
// has no `toJSON`, so the replacer fires as intended.
|
|
92
|
+
images.push(new Uint8Array(Buffer.from(match[1], 'base64')));
|
|
93
|
+
seenImage = true;
|
|
94
|
+
} else {
|
|
95
|
+
throw new Error(`Unsupported content part type: "${(p as { type: string }).type}"`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const out: { text: string; images?: Uint8Array[] } = { text: parts.join('') };
|
|
100
|
+
if (images.length > 0) out.images = images;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** NAPI `ToolDefinition` requires `parameters.properties` to be a JSON string. */
|
|
105
|
+
function mapTool(tool: ResponsesToolDefinition): ToolDefinition {
|
|
106
|
+
if (tool.type !== 'function') {
|
|
107
|
+
throw new Error(`Unsupported tool type: "${tool.type as string}"`);
|
|
108
|
+
}
|
|
109
|
+
const params = tool.parameters;
|
|
110
|
+
return {
|
|
111
|
+
type: 'function',
|
|
112
|
+
function: {
|
|
113
|
+
name: tool.name,
|
|
114
|
+
description: tool.description,
|
|
115
|
+
parameters: params
|
|
116
|
+
? {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: params['properties'] ? JSON.stringify(params['properties']) : undefined,
|
|
119
|
+
required: Array.isArray(params['required']) ? (params['required'] as string[]) : undefined,
|
|
120
|
+
}
|
|
121
|
+
: undefined,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface MappedRequest {
|
|
127
|
+
messages: ChatMessage[];
|
|
128
|
+
config: ChatConfig;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Shared MTP-extension parser for the `extra_body` carrier on both
|
|
133
|
+
* `/v1/responses` (OpenAI) and `/v1/messages` (Anthropic) request
|
|
134
|
+
* shapes. Mutates the passed `config` in place:
|
|
135
|
+
*
|
|
136
|
+
* * `generation_mode: "mtp"` → `enableMtp = true`
|
|
137
|
+
* * `generation_mode: "ar"` → `enableMtp = false`
|
|
138
|
+
* * any other / absent value → `enableMtp` untouched, so the
|
|
139
|
+
* downstream `ChatSession.mergeConfig` auto-default applies.
|
|
140
|
+
* That default is two-part, not just "has a head": the model
|
|
141
|
+
* must report `hasMtpWeights()` AND opt in through
|
|
142
|
+
* `ChatSession#mtpAutoDefaultAllowed`, which prefers the native
|
|
143
|
+
* `mtpAutoEnabled()` getter when present and otherwise falls
|
|
144
|
+
* back to the `MTP_AUTO_DEFAULT_SUPPRESSED_MODELS` class-name
|
|
145
|
+
* list (both in `packages/lm/src/chat-session.ts`). NemotronH
|
|
146
|
+
* is currently suppressed — its head is a measured throughput
|
|
147
|
+
* loss — so `generation_mode: "mtp"` (or an explicit
|
|
148
|
+
* `enableMtp: true`) is the only way to speculate on it here.
|
|
149
|
+
*
|
|
150
|
+
* * `mtp_depth: <positive int ≤ 64>` → `mtpDepth = value`
|
|
151
|
+
* * non-integer, out-of-range, or absent → `mtpDepth` untouched.
|
|
152
|
+
* The real clamps are per-family and owned by native
|
|
153
|
+
* `resolve_params`: qwen3.5 native MTP clamps to [1, 5], gemma4
|
|
154
|
+
* DSpark caps at the draft block size (7 on v1), and the gemma4
|
|
155
|
+
* assistant draft clamps to [1, 8]. The server therefore only
|
|
156
|
+
* rejects garbage — non-integers, non-positives, and values
|
|
157
|
+
* > 64 (a generous sanity ceiling far above any family's real
|
|
158
|
+
* clamp) — which saves a round-trip into the model thread.
|
|
159
|
+
*
|
|
160
|
+
* Kept as a pure helper rather than inlined into each mapper so the
|
|
161
|
+
* two endpoints can't drift in semantics.
|
|
162
|
+
*/
|
|
163
|
+
export function applyExtraBodyMtpOverrides(
|
|
164
|
+
config: ChatConfig,
|
|
165
|
+
extraBody: { generation_mode?: string | null; mtp_depth?: number | null } | undefined,
|
|
166
|
+
): void {
|
|
167
|
+
if (!extraBody) return;
|
|
168
|
+
const mode = extraBody.generation_mode;
|
|
169
|
+
if (mode === 'mtp') {
|
|
170
|
+
config.enableMtp = true;
|
|
171
|
+
} else if (mode === 'ar') {
|
|
172
|
+
config.enableMtp = false;
|
|
173
|
+
}
|
|
174
|
+
// Any other value (null, undefined, unknown string) → leave alone.
|
|
175
|
+
|
|
176
|
+
const depth = extraBody.mtp_depth;
|
|
177
|
+
if (depth != null && Number.isInteger(depth) && depth > 0 && depth <= 64) {
|
|
178
|
+
config.mtpDepth = depth;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function mapRequest(req: ResponsesAPIRequest, priorMessages?: ChatMessage[]): MappedRequest {
|
|
183
|
+
const messages: ChatMessage[] = [];
|
|
184
|
+
|
|
185
|
+
if (req.instructions) {
|
|
186
|
+
messages.push({ role: 'system', content: req.instructions });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (priorMessages) {
|
|
190
|
+
messages.push(...priorMessages);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// An assistant turn may serialise into any interleaving of `reasoning`,
|
|
194
|
+
// `message` (assistant), and `function_call` items. We coalesce that run
|
|
195
|
+
// into ONE assistant `ChatMessage` carrying `content` + `reasoningContent`
|
|
196
|
+
// + `toolCalls`, matching the hot-path `ChatSession` shape exactly. Any
|
|
197
|
+
// non-assistant item (user / system / function_call_output) flushes the
|
|
198
|
+
// current turn. An assistant `message` item that appears AFTER a
|
|
199
|
+
// `function_call` opens a fresh turn — preserving the pre-existing
|
|
200
|
+
// convention documented in the fan-out tests.
|
|
201
|
+
if (typeof req.input === 'string') {
|
|
202
|
+
messages.push({ role: 'user', content: req.input });
|
|
203
|
+
} else {
|
|
204
|
+
let currentAssistant: ChatMessage | null = null;
|
|
205
|
+
let assistantHasToolCalls = false;
|
|
206
|
+
|
|
207
|
+
const flushAssistant = () => {
|
|
208
|
+
if (currentAssistant) {
|
|
209
|
+
messages.push(currentAssistant);
|
|
210
|
+
currentAssistant = null;
|
|
211
|
+
assistantHasToolCalls = false;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const ensureAssistant = (): ChatMessage => {
|
|
215
|
+
if (!currentAssistant) {
|
|
216
|
+
currentAssistant = { role: 'assistant', content: '' };
|
|
217
|
+
}
|
|
218
|
+
return currentAssistant;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
for (const item of req.input) {
|
|
222
|
+
if (item == null || typeof item !== 'object') {
|
|
223
|
+
throw new Error('Each input item must be a non-null object');
|
|
224
|
+
}
|
|
225
|
+
const itemType = (item as { type?: string }).type ?? 'message';
|
|
226
|
+
|
|
227
|
+
if (itemType === 'message') {
|
|
228
|
+
const msg = item as { role: string; content: string | ContentPart[] };
|
|
229
|
+
// OpenAI "developer" maps to our "system".
|
|
230
|
+
const role = msg.role === 'developer' ? 'system' : msg.role;
|
|
231
|
+
if (role !== 'user' && role !== 'assistant' && role !== 'system') {
|
|
232
|
+
throw new Error(`Unsupported message role: "${msg.role}"`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (role === 'assistant') {
|
|
236
|
+
// `message` after a `function_call` opens a new turn.
|
|
237
|
+
if (assistantHasToolCalls) {
|
|
238
|
+
flushAssistant();
|
|
239
|
+
}
|
|
240
|
+
const a = ensureAssistant();
|
|
241
|
+
const { text } = resolveMessageContent(msg.content, 'assistant');
|
|
242
|
+
a.content = (a.content ?? '') + text;
|
|
243
|
+
} else {
|
|
244
|
+
flushAssistant();
|
|
245
|
+
const { text, images } = resolveMessageContent(msg.content, role);
|
|
246
|
+
const m: ChatMessage = { role, content: text };
|
|
247
|
+
if (images) m.images = images;
|
|
248
|
+
messages.push(m);
|
|
249
|
+
}
|
|
250
|
+
} else if (itemType === 'reasoning') {
|
|
251
|
+
const r = item as { summary?: { text?: string }[] };
|
|
252
|
+
const summary = (r.summary ?? []).map((s) => s.text ?? '').join('');
|
|
253
|
+
const a = ensureAssistant();
|
|
254
|
+
a.reasoningContent = (a.reasoningContent ?? '') + summary;
|
|
255
|
+
} else if (itemType === 'function_call') {
|
|
256
|
+
const fc = item as { name: string; arguments: string; call_id: string };
|
|
257
|
+
const a = ensureAssistant();
|
|
258
|
+
a.toolCalls ??= [];
|
|
259
|
+
a.toolCalls.push({ name: fc.name, arguments: fc.arguments, id: fc.call_id });
|
|
260
|
+
assistantHasToolCalls = true;
|
|
261
|
+
} else if (itemType === 'function_call_output') {
|
|
262
|
+
const fco = item as { call_id: string; output: string };
|
|
263
|
+
flushAssistant();
|
|
264
|
+
messages.push({
|
|
265
|
+
role: 'tool',
|
|
266
|
+
content: fco.output,
|
|
267
|
+
toolCallId: fco.call_id,
|
|
268
|
+
});
|
|
269
|
+
} else {
|
|
270
|
+
throw new Error(`Unsupported input item type: "${itemType as string}"`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
flushAssistant();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const config: ChatConfig = {
|
|
278
|
+
reportPerformance: true,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (req.cache_salt != null) {
|
|
282
|
+
validateCacheSalt(req.cache_salt);
|
|
283
|
+
config.cacheSalt = req.cache_salt;
|
|
284
|
+
}
|
|
285
|
+
if (req.max_output_tokens != null) {
|
|
286
|
+
config.maxNewTokens = req.max_output_tokens;
|
|
287
|
+
}
|
|
288
|
+
if (req.temperature != null) {
|
|
289
|
+
config.temperature = req.temperature;
|
|
290
|
+
}
|
|
291
|
+
if (req.top_p != null) {
|
|
292
|
+
config.topP = req.top_p;
|
|
293
|
+
}
|
|
294
|
+
if (req.reasoning?.effort) {
|
|
295
|
+
config.reasoningEffort = req.reasoning.effort;
|
|
296
|
+
}
|
|
297
|
+
if (req.tools && req.tools.length > 0) {
|
|
298
|
+
if (req.tool_choice === 'none') {
|
|
299
|
+
// Caller disabled tool use.
|
|
300
|
+
} else if (typeof req.tool_choice === 'object' && req.tool_choice?.type === 'function') {
|
|
301
|
+
const targetName = req.tool_choice.name;
|
|
302
|
+
const matched = req.tools.filter((t) => t.name === targetName);
|
|
303
|
+
if (matched.length > 0) {
|
|
304
|
+
config.tools = matched.map(mapTool);
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
config.tools = req.tools.map(mapTool);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (priorMessages && priorMessages.length > 0) {
|
|
311
|
+
config.reuseCache = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
applyExtraBodyMtpOverrides(config, req.extra_body);
|
|
315
|
+
|
|
316
|
+
return { messages, config };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Sentinel key used to tag base64-encoded `Uint8Array` payloads in
|
|
321
|
+
* persisted `inputJson`. Plain `JSON.stringify` turns a `Uint8Array`
|
|
322
|
+
* into a numeric-keyed object (e.g. `{"0":1,"1":2,...}`), which
|
|
323
|
+
* (a) bloats the row ~8× vs base64 and (b) does not round-trip — the
|
|
324
|
+
* parsed object fails the NAPI `Uint8Array` type check on cold replay,
|
|
325
|
+
* breaking `previous_response_id` continuations that carry images.
|
|
326
|
+
*/
|
|
327
|
+
const UINT8_SENTINEL = '__u8__';
|
|
328
|
+
|
|
329
|
+
interface EncodedUint8Array {
|
|
330
|
+
[UINT8_SENTINEL]: string;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function isEncodedUint8Array(value: unknown): value is EncodedUint8Array {
|
|
334
|
+
return (
|
|
335
|
+
value !== null &&
|
|
336
|
+
typeof value === 'object' &&
|
|
337
|
+
typeof (value as Record<string, unknown>)[UINT8_SENTINEL] === 'string'
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Serialise a `ChatMessage[]` snapshot for `StoredResponseRecord.inputJson`,
|
|
343
|
+
* preserving any `Uint8Array` image payloads as base64-encoded sentinels
|
|
344
|
+
* so a later `reconstructMessagesFromChain` can revive them into real
|
|
345
|
+
* `Uint8Array`s for the NAPI chat-session boundary.
|
|
346
|
+
*
|
|
347
|
+
* The replacer runs AFTER `toJSON`, so a `Buffer` (which defines
|
|
348
|
+
* `Buffer.prototype.toJSON` returning `{type:"Buffer",data:[...]}`)
|
|
349
|
+
* would otherwise slip past the `instanceof Uint8Array` check. We
|
|
350
|
+
* match both shapes defensively — the production `resolveMessageContent`
|
|
351
|
+
* path now wraps with `new Uint8Array(...)` at decode time, but any
|
|
352
|
+
* future caller that sneaks a `Buffer` through still round-trips
|
|
353
|
+
* instead of silently corrupting image state.
|
|
354
|
+
*/
|
|
355
|
+
export function stringifyStoredInputMessages(messages: ChatMessage[]): string {
|
|
356
|
+
return JSON.stringify(messages, (_key, value: unknown) => {
|
|
357
|
+
if (value instanceof Uint8Array) {
|
|
358
|
+
return { [UINT8_SENTINEL]: Buffer.from(value).toString('base64') };
|
|
359
|
+
}
|
|
360
|
+
if (
|
|
361
|
+
value !== null &&
|
|
362
|
+
typeof value === 'object' &&
|
|
363
|
+
(value as { type?: unknown }).type === 'Buffer' &&
|
|
364
|
+
Array.isArray((value as { data?: unknown }).data)
|
|
365
|
+
) {
|
|
366
|
+
const data = (value as { data: number[] }).data;
|
|
367
|
+
return { [UINT8_SENTINEL]: Buffer.from(data).toString('base64') };
|
|
368
|
+
}
|
|
369
|
+
return value;
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Reconstruct `ChatMessage[]` from a stored response chain. Each record
|
|
375
|
+
* stores `inputJson` (messages sent) and `outputJson` (output items); we
|
|
376
|
+
* interleave them.
|
|
377
|
+
*
|
|
378
|
+
* Image payloads encoded as `{__u8__: "<base64>"}` by
|
|
379
|
+
* `stringifyStoredInputMessages` are rehydrated back into `Buffer`
|
|
380
|
+
* (a `Uint8Array` subclass) so replayed user turns carry the same
|
|
381
|
+
* runtime shape the native chat-session APIs expect.
|
|
382
|
+
*/
|
|
383
|
+
export function reconstructMessagesFromChain(chain: { inputJson: string; outputJson: string }[]): ChatMessage[] {
|
|
384
|
+
const messages: ChatMessage[] = [];
|
|
385
|
+
|
|
386
|
+
for (const record of chain) {
|
|
387
|
+
const inputMessages = JSON.parse(record.inputJson, (_key, value: unknown) => {
|
|
388
|
+
if (isEncodedUint8Array(value)) {
|
|
389
|
+
return Buffer.from(value[UINT8_SENTINEL], 'base64');
|
|
390
|
+
}
|
|
391
|
+
return value;
|
|
392
|
+
}) as ChatMessage[];
|
|
393
|
+
messages.push(...inputMessages);
|
|
394
|
+
|
|
395
|
+
const outputItems = JSON.parse(record.outputJson) as Array<{
|
|
396
|
+
type: string;
|
|
397
|
+
content?: Array<{ text: string }>;
|
|
398
|
+
name?: string;
|
|
399
|
+
arguments?: string;
|
|
400
|
+
call_id?: string;
|
|
401
|
+
summary?: Array<{ text: string }>;
|
|
402
|
+
}>;
|
|
403
|
+
|
|
404
|
+
let assistantText = '';
|
|
405
|
+
let thinkingText = '';
|
|
406
|
+
// Track presence vs. content separately: an empty-text `message` item
|
|
407
|
+
// still represents a real successful turn (the hot-path `ChatSession`
|
|
408
|
+
// always appends an assistant message per turn), so cold replay must
|
|
409
|
+
// preserve it or `primeHistory` will reshape the conversation.
|
|
410
|
+
let hadMessageItem = false;
|
|
411
|
+
let hadReasoningItem = false;
|
|
412
|
+
const toolCalls: { name: string; arguments: string; id?: string }[] = [];
|
|
413
|
+
|
|
414
|
+
for (const item of outputItems) {
|
|
415
|
+
if (item.type === 'message') {
|
|
416
|
+
hadMessageItem = true;
|
|
417
|
+
if (item.content) {
|
|
418
|
+
assistantText += item.content.map((c) => c.text).join('');
|
|
419
|
+
}
|
|
420
|
+
} else if (item.type === 'reasoning') {
|
|
421
|
+
hadReasoningItem = true;
|
|
422
|
+
if (item.summary) {
|
|
423
|
+
thinkingText += item.summary.map((s) => s.text).join('');
|
|
424
|
+
}
|
|
425
|
+
} else if (item.type === 'function_call') {
|
|
426
|
+
toolCalls.push({
|
|
427
|
+
name: item.name!,
|
|
428
|
+
arguments: item.arguments!,
|
|
429
|
+
id: item.call_id,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Preserve the assistant turn whenever the record carried any assistant-facing
|
|
435
|
+
// item — message (even empty), reasoning, or function_call — because the hot-path
|
|
436
|
+
// `ChatSession` always appends one assistant message per completed turn.
|
|
437
|
+
// Keying on accumulated content would silently drop blank successful turns and
|
|
438
|
+
// reshape the replayed conversation. Records with no assistant items (input-only)
|
|
439
|
+
// are still skipped so we don't fabricate turns the live session never generated.
|
|
440
|
+
if (hadMessageItem || hadReasoningItem || toolCalls.length > 0) {
|
|
441
|
+
const assistantMsg: ChatMessage = {
|
|
442
|
+
role: 'assistant',
|
|
443
|
+
content: assistantText,
|
|
444
|
+
};
|
|
445
|
+
if (thinkingText) {
|
|
446
|
+
assistantMsg.reasoningContent = thinkingText;
|
|
447
|
+
}
|
|
448
|
+
if (toolCalls.length > 0) {
|
|
449
|
+
assistantMsg.toolCalls = toolCalls;
|
|
450
|
+
}
|
|
451
|
+
messages.push(assistantMsg);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return messages;
|
|
456
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** ChatResult / ChatStreamEvent → OpenAI Responses API output. */
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import type { ChatResult } from '@mlx-node/core';
|
|
6
|
+
|
|
7
|
+
import { mergeTimingUsageExtensions } from '../timing.js';
|
|
8
|
+
import type {
|
|
9
|
+
FunctionCallOutputItem,
|
|
10
|
+
MessageOutputItem,
|
|
11
|
+
OutputItem,
|
|
12
|
+
ReasoningOutputItem,
|
|
13
|
+
ResponseObject,
|
|
14
|
+
ResponsesAPIRequest,
|
|
15
|
+
ResponseUsage,
|
|
16
|
+
} from '../types.js';
|
|
17
|
+
|
|
18
|
+
export function genId(prefix: string): string {
|
|
19
|
+
return `${prefix}${randomUUID().replaceAll('-', '')}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function mapFinishReasonToStatus(finishReason: string): 'completed' | 'incomplete' {
|
|
23
|
+
switch (finishReason) {
|
|
24
|
+
case 'length':
|
|
25
|
+
return 'incomplete';
|
|
26
|
+
default:
|
|
27
|
+
return 'completed';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildOutputItems(result: ChatResult): OutputItem[] {
|
|
32
|
+
const items: OutputItem[] = [];
|
|
33
|
+
|
|
34
|
+
if (result.thinking) {
|
|
35
|
+
const reasoningItem: ReasoningOutputItem = {
|
|
36
|
+
id: genId('rs_'),
|
|
37
|
+
type: 'reasoning',
|
|
38
|
+
summary: [{ type: 'summary_text', text: result.thinking }],
|
|
39
|
+
};
|
|
40
|
+
items.push(reasoningItem);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const okToolCalls = result.toolCalls.filter((t) => t.status === 'ok');
|
|
44
|
+
|
|
45
|
+
// Always emit a message item (possibly with empty text) unless there are tool calls and no text.
|
|
46
|
+
if (result.text || okToolCalls.length === 0) {
|
|
47
|
+
const messageItem: MessageOutputItem = {
|
|
48
|
+
id: genId('msg_'),
|
|
49
|
+
type: 'message',
|
|
50
|
+
role: 'assistant',
|
|
51
|
+
status: mapFinishReasonToStatus(result.finishReason),
|
|
52
|
+
content: [{ type: 'output_text', text: result.text, annotations: [] as never[] }],
|
|
53
|
+
};
|
|
54
|
+
items.push(messageItem);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const tc of okToolCalls) {
|
|
58
|
+
const callId = tc.id ?? genId('call_');
|
|
59
|
+
const fcItem: FunctionCallOutputItem = {
|
|
60
|
+
id: genId('fc_'),
|
|
61
|
+
type: 'function_call',
|
|
62
|
+
call_id: callId,
|
|
63
|
+
name: tc.name,
|
|
64
|
+
arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments),
|
|
65
|
+
status: 'completed',
|
|
66
|
+
};
|
|
67
|
+
items.push(fcItem);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return items;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildUsage(result: ChatResult): ResponseUsage {
|
|
74
|
+
const usage: ResponseUsage = {
|
|
75
|
+
input_tokens: result.promptTokens,
|
|
76
|
+
output_tokens: result.numTokens,
|
|
77
|
+
output_tokens_details: { reasoning_tokens: result.reasoningTokens },
|
|
78
|
+
total_tokens: result.promptTokens + result.numTokens,
|
|
79
|
+
};
|
|
80
|
+
// Surface reused KV-cache prefix tokens via the upstream
|
|
81
|
+
// `input_tokens_details.cached_tokens` field when the native
|
|
82
|
+
// dispatch reports a non-zero reuse count. Omitted when zero so
|
|
83
|
+
// consumers can cheaply `usage.input_tokens_details?.cached_tokens`
|
|
84
|
+
// without also distinguishing a meaningful zero from "feature not
|
|
85
|
+
// active on this turn".
|
|
86
|
+
if (result.cachedTokens > 0) {
|
|
87
|
+
usage.input_tokens_details = { cached_tokens: result.cachedTokens };
|
|
88
|
+
}
|
|
89
|
+
mergeTimingUsageExtensions(usage, result.performance, result.promptTokens, result.numTokens, result.cachedTokens);
|
|
90
|
+
return usage;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Concatenate all `output_text` parts from message items. */
|
|
94
|
+
export function computeOutputText(items: OutputItem[]): string {
|
|
95
|
+
const parts: string[] = [];
|
|
96
|
+
for (const item of items) {
|
|
97
|
+
if (item.type === 'message') {
|
|
98
|
+
for (const c of item.content) {
|
|
99
|
+
parts.push(c.text);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return parts.join('');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildResponseObject(
|
|
107
|
+
result: ChatResult,
|
|
108
|
+
req: ResponsesAPIRequest,
|
|
109
|
+
responseId: string,
|
|
110
|
+
previousResponseId?: string,
|
|
111
|
+
): ResponseObject {
|
|
112
|
+
const output = buildOutputItems(result);
|
|
113
|
+
const status = mapFinishReasonToStatus(result.finishReason);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
id: responseId,
|
|
117
|
+
object: 'response',
|
|
118
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
119
|
+
status,
|
|
120
|
+
model: req.model,
|
|
121
|
+
output,
|
|
122
|
+
output_text: computeOutputText(output),
|
|
123
|
+
error: null,
|
|
124
|
+
incomplete_details: status === 'incomplete' ? { reason: 'max_output_tokens' } : null,
|
|
125
|
+
usage: buildUsage(result),
|
|
126
|
+
instructions: req.instructions ?? null,
|
|
127
|
+
temperature: req.temperature ?? null,
|
|
128
|
+
top_p: req.top_p ?? null,
|
|
129
|
+
max_output_tokens: req.max_output_tokens ?? null,
|
|
130
|
+
tools: req.tools ?? [],
|
|
131
|
+
tool_choice: req.tool_choice ?? null,
|
|
132
|
+
reasoning: req.reasoning ?? null,
|
|
133
|
+
previous_response_id: previousResponseId ?? null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Build an in-progress ResponseObject for `response.created` / `response.in_progress`, before any output exists. */
|
|
138
|
+
export function buildPartialResponse(
|
|
139
|
+
req: ResponsesAPIRequest,
|
|
140
|
+
responseId: string,
|
|
141
|
+
previousResponseId?: string,
|
|
142
|
+
): ResponseObject {
|
|
143
|
+
return {
|
|
144
|
+
id: responseId,
|
|
145
|
+
object: 'response',
|
|
146
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
147
|
+
status: 'in_progress',
|
|
148
|
+
model: req.model,
|
|
149
|
+
output: [],
|
|
150
|
+
output_text: '',
|
|
151
|
+
error: null,
|
|
152
|
+
incomplete_details: null,
|
|
153
|
+
usage: { input_tokens: 0, output_tokens: 0, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 0 },
|
|
154
|
+
instructions: req.instructions ?? null,
|
|
155
|
+
temperature: req.temperature ?? null,
|
|
156
|
+
top_p: req.top_p ?? null,
|
|
157
|
+
max_output_tokens: req.max_output_tokens ?? null,
|
|
158
|
+
tools: req.tools ?? [],
|
|
159
|
+
tool_choice: req.tool_choice ?? null,
|
|
160
|
+
reasoning: req.reasoning ?? null,
|
|
161
|
+
previous_response_id: previousResponseId ?? null,
|
|
162
|
+
};
|
|
163
|
+
}
|