@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.
Files changed (45) hide show
  1. package/dist/host/discover.d.ts +3 -6
  2. package/dist/host/discover.d.ts.map +1 -1
  3. package/dist/host/discover.js +9 -42
  4. package/dist/host/index.d.ts +2 -2
  5. package/dist/host/index.d.ts.map +1 -1
  6. package/dist/host/index.js +8 -1
  7. package/package.json +9 -4
  8. package/src/auth.ts +111 -0
  9. package/src/chat-session-warm-reuse.ts +96 -0
  10. package/src/endpoints/messages-count-tokens.ts +164 -0
  11. package/src/endpoints/messages.ts +1802 -0
  12. package/src/endpoints/models.ts +20 -0
  13. package/src/endpoints/responses.ts +3928 -0
  14. package/src/errors.ts +120 -0
  15. package/src/handler.ts +195 -0
  16. package/src/health.ts +213 -0
  17. package/src/host/discover.ts +25 -0
  18. package/src/host/env-policy.ts +81 -0
  19. package/src/host/index.ts +496 -0
  20. package/src/host/logger.ts +419 -0
  21. package/src/host/net.ts +100 -0
  22. package/src/host/paths.ts +77 -0
  23. package/src/host/swap.ts +200 -0
  24. package/src/host/temp-root.ts +110 -0
  25. package/src/idle-sweeper.ts +555 -0
  26. package/src/index.ts +114 -0
  27. package/src/load-model.ts +92 -0
  28. package/src/mappers/anthropic-request.ts +485 -0
  29. package/src/mappers/anthropic-response.ts +306 -0
  30. package/src/mappers/request.ts +456 -0
  31. package/src/mappers/response.ts +163 -0
  32. package/src/model-work-coordinator.ts +416 -0
  33. package/src/pending-writes.ts +481 -0
  34. package/src/registry.ts +691 -0
  35. package/src/router.ts +220 -0
  36. package/src/server.ts +579 -0
  37. package/src/session-registry.ts +1371 -0
  38. package/src/stop-sequence-buffer.ts +161 -0
  39. package/src/streaming.ts +205 -0
  40. package/src/text-recovery.ts +41 -0
  41. package/src/timing.ts +236 -0
  42. package/src/tool-call-buffer.ts +78 -0
  43. package/src/transport-visibility.ts +185 -0
  44. package/src/types-anthropic.ts +409 -0
  45. 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
+ }