@mlx-node/server 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/endpoints/messages.d.ts +13 -0
  2. package/dist/endpoints/messages.d.ts.map +1 -0
  3. package/dist/endpoints/messages.js +511 -0
  4. package/dist/endpoints/models.d.ts +5 -0
  5. package/dist/endpoints/models.d.ts.map +1 -0
  6. package/dist/endpoints/models.js +10 -0
  7. package/dist/endpoints/responses.d.ts +79 -0
  8. package/dist/endpoints/responses.d.ts.map +1 -0
  9. package/dist/endpoints/responses.js +2816 -0
  10. package/dist/errors.d.ts +43 -0
  11. package/dist/errors.d.ts.map +1 -0
  12. package/dist/errors.js +84 -0
  13. package/dist/handler.d.ts +18 -0
  14. package/dist/handler.d.ts.map +1 -0
  15. package/dist/handler.js +35 -0
  16. package/dist/index.d.ts +23 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +16 -0
  19. package/dist/mappers/anthropic-request.d.ts +9 -0
  20. package/dist/mappers/anthropic-request.d.ts.map +1 -0
  21. package/dist/mappers/anthropic-request.js +241 -0
  22. package/dist/mappers/anthropic-response.d.ts +14 -0
  23. package/dist/mappers/anthropic-response.d.ts.map +1 -0
  24. package/dist/mappers/anthropic-response.js +112 -0
  25. package/dist/mappers/request.d.ts +18 -0
  26. package/dist/mappers/request.d.ts.map +1 -0
  27. package/dist/mappers/request.js +206 -0
  28. package/dist/mappers/response.d.ts +13 -0
  29. package/dist/mappers/response.d.ts.map +1 -0
  30. package/dist/mappers/response.js +116 -0
  31. package/dist/pending-writes.d.ts +337 -0
  32. package/dist/pending-writes.d.ts.map +1 -0
  33. package/dist/pending-writes.js +468 -0
  34. package/dist/registry.d.ts +363 -0
  35. package/dist/registry.d.ts.map +1 -0
  36. package/dist/registry.js +497 -0
  37. package/dist/router.d.ts +6 -0
  38. package/dist/router.d.ts.map +1 -0
  39. package/dist/router.js +78 -0
  40. package/dist/server.d.ts +80 -0
  41. package/dist/server.d.ts.map +1 -0
  42. package/dist/server.js +158 -0
  43. package/dist/session-registry.d.ts +297 -0
  44. package/dist/session-registry.d.ts.map +1 -0
  45. package/dist/session-registry.js +403 -0
  46. package/dist/streaming.d.ts +7 -0
  47. package/dist/streaming.d.ts.map +1 -0
  48. package/dist/streaming.js +16 -0
  49. package/dist/tool-call-buffer.d.ts +26 -0
  50. package/dist/tool-call-buffer.d.ts.map +1 -0
  51. package/dist/tool-call-buffer.js +51 -0
  52. package/dist/transport-visibility.d.ts +56 -0
  53. package/dist/transport-visibility.d.ts.map +1 -0
  54. package/dist/transport-visibility.js +161 -0
  55. package/dist/types-anthropic.d.ts +144 -0
  56. package/dist/types-anthropic.d.ts.map +1 -0
  57. package/dist/types-anthropic.js +2 -0
  58. package/dist/types.d.ts +220 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +2 -0
  61. package/package.json +36 -0
@@ -0,0 +1,206 @@
1
+ /** OpenAI Responses API request → internal `ChatMessage[]` + `ChatConfig`. */
2
+ function resolveContent(content) {
3
+ if (typeof content === 'string')
4
+ return content;
5
+ const parts = [];
6
+ for (const p of content) {
7
+ if (p.type === 'input_text') {
8
+ parts.push(p.text);
9
+ }
10
+ else {
11
+ throw new Error(`Unsupported content part type: "${p.type}"`);
12
+ }
13
+ }
14
+ return parts.join('');
15
+ }
16
+ /** NAPI `ToolDefinition` requires `parameters.properties` to be a JSON string. */
17
+ function mapTool(tool) {
18
+ if (tool.type !== 'function') {
19
+ throw new Error(`Unsupported tool type: "${tool.type}"`);
20
+ }
21
+ const params = tool.parameters;
22
+ return {
23
+ type: 'function',
24
+ function: {
25
+ name: tool.name,
26
+ description: tool.description,
27
+ parameters: params
28
+ ? {
29
+ type: 'object',
30
+ properties: params['properties'] ? JSON.stringify(params['properties']) : undefined,
31
+ required: Array.isArray(params['required']) ? params['required'] : undefined,
32
+ }
33
+ : undefined,
34
+ },
35
+ };
36
+ }
37
+ export function mapRequest(req, priorMessages) {
38
+ const messages = [];
39
+ if (req.instructions) {
40
+ messages.push({ role: 'system', content: req.instructions });
41
+ }
42
+ if (priorMessages) {
43
+ messages.push(...priorMessages);
44
+ }
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.
52
+ if (typeof req.input === 'string') {
53
+ messages.push({ role: 'user', content: req.input });
54
+ }
55
+ else {
56
+ let prevItemType = null;
57
+ for (const item of req.input) {
58
+ if (item == null || typeof item !== 'object') {
59
+ throw new Error('Each input item must be a non-null object');
60
+ }
61
+ const itemType = item.type ?? 'message';
62
+ if (itemType === 'message') {
63
+ const msg = item;
64
+ // OpenAI "developer" maps to our "system".
65
+ const role = msg.role === 'developer' ? 'system' : msg.role;
66
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') {
67
+ throw new Error(`Unsupported message role: "${msg.role}"`);
68
+ }
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 = [];
84
+ }
85
+ last.toolCalls.push({ name: fc.name, arguments: fc.arguments, id: fc.call_id });
86
+ }
87
+ else {
88
+ messages.push({
89
+ role: 'assistant',
90
+ content: '',
91
+ toolCalls: [{ name: fc.name, arguments: fc.arguments, id: fc.call_id }],
92
+ });
93
+ }
94
+ }
95
+ else if (itemType === 'function_call_output') {
96
+ const fco = item;
97
+ messages.push({
98
+ role: 'tool',
99
+ content: fco.output,
100
+ toolCallId: fco.call_id,
101
+ });
102
+ }
103
+ else {
104
+ throw new Error(`Unsupported input item type: "${itemType}"`);
105
+ }
106
+ prevItemType = itemType;
107
+ }
108
+ }
109
+ const config = {
110
+ reportPerformance: true,
111
+ };
112
+ if (req.max_output_tokens != null) {
113
+ config.maxNewTokens = req.max_output_tokens;
114
+ }
115
+ if (req.temperature != null) {
116
+ config.temperature = req.temperature;
117
+ }
118
+ if (req.top_p != null) {
119
+ config.topP = req.top_p;
120
+ }
121
+ if (req.reasoning?.effort) {
122
+ config.reasoningEffort = req.reasoning.effort;
123
+ }
124
+ if (req.tools && req.tools.length > 0) {
125
+ if (req.tool_choice === 'none') {
126
+ // Caller disabled tool use.
127
+ }
128
+ else if (typeof req.tool_choice === 'object' && req.tool_choice?.type === 'function') {
129
+ const targetName = req.tool_choice.name;
130
+ const matched = req.tools.filter((t) => t.name === targetName);
131
+ if (matched.length > 0) {
132
+ config.tools = matched.map(mapTool);
133
+ }
134
+ }
135
+ else {
136
+ config.tools = req.tools.map(mapTool);
137
+ }
138
+ }
139
+ if (priorMessages && priorMessages.length > 0) {
140
+ config.reuseCache = true;
141
+ }
142
+ return { messages, config };
143
+ }
144
+ /**
145
+ * Reconstruct `ChatMessage[]` from a stored response chain. Each record
146
+ * stores `inputJson` (messages sent) and `outputJson` (output items); we
147
+ * interleave them.
148
+ */
149
+ export function reconstructMessagesFromChain(chain) {
150
+ const messages = [];
151
+ for (const record of chain) {
152
+ const inputMessages = JSON.parse(record.inputJson);
153
+ messages.push(...inputMessages);
154
+ const outputItems = JSON.parse(record.outputJson);
155
+ let assistantText = '';
156
+ let thinkingText = '';
157
+ // Track presence vs. content separately: an empty-text `message` item
158
+ // still represents a real successful turn (the hot-path `ChatSession`
159
+ // always appends an assistant message per turn), so cold replay must
160
+ // preserve it or `primeHistory` will reshape the conversation.
161
+ let hadMessageItem = false;
162
+ let hadReasoningItem = false;
163
+ const toolCalls = [];
164
+ for (const item of outputItems) {
165
+ if (item.type === 'message') {
166
+ hadMessageItem = true;
167
+ if (item.content) {
168
+ assistantText += item.content.map((c) => c.text).join('');
169
+ }
170
+ }
171
+ else if (item.type === 'reasoning') {
172
+ hadReasoningItem = true;
173
+ if (item.summary) {
174
+ thinkingText += item.summary.map((s) => s.text).join('');
175
+ }
176
+ }
177
+ else if (item.type === 'function_call') {
178
+ toolCalls.push({
179
+ name: item.name,
180
+ arguments: item.arguments,
181
+ id: item.call_id,
182
+ });
183
+ }
184
+ }
185
+ // Preserve the assistant turn whenever the record carried any assistant-facing
186
+ // item — message (even empty), reasoning, or function_call — because the hot-path
187
+ // `ChatSession` always appends one assistant message per completed turn.
188
+ // Keying on accumulated content would silently drop blank successful turns and
189
+ // reshape the replayed conversation. Records with no assistant items (input-only)
190
+ // are still skipped so we don't fabricate turns the live session never generated.
191
+ if (hadMessageItem || hadReasoningItem || toolCalls.length > 0) {
192
+ const assistantMsg = {
193
+ role: 'assistant',
194
+ content: assistantText,
195
+ };
196
+ if (thinkingText) {
197
+ assistantMsg.reasoningContent = thinkingText;
198
+ }
199
+ if (toolCalls.length > 0) {
200
+ assistantMsg.toolCalls = toolCalls;
201
+ }
202
+ messages.push(assistantMsg);
203
+ }
204
+ }
205
+ return messages;
206
+ }
@@ -0,0 +1,13 @@
1
+ /** ChatResult / ChatStreamEvent → OpenAI Responses API output. */
2
+ import type { ChatResult } from '@mlx-node/core';
3
+ import type { OutputItem, ResponseObject, ResponsesAPIRequest, ResponseUsage } from '../types.js';
4
+ export declare function genId(prefix: string): string;
5
+ export declare function mapFinishReasonToStatus(finishReason: string): 'completed' | 'incomplete';
6
+ export declare function buildOutputItems(result: ChatResult): OutputItem[];
7
+ export declare function buildUsage(result: ChatResult): ResponseUsage;
8
+ /** Concatenate all `output_text` parts from message items. */
9
+ export declare function computeOutputText(items: OutputItem[]): string;
10
+ export declare function buildResponseObject(result: ChatResult, req: ResponsesAPIRequest, responseId: string, previousResponseId?: string): ResponseObject;
11
+ /** Build an in-progress ResponseObject for `response.created` / `response.in_progress`, before any output exists. */
12
+ export declare function buildPartialResponse(req: ResponsesAPIRequest, responseId: string, previousResponseId?: string): ResponseObject;
13
+ //# sourceMappingURL=response.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,116 @@
1
+ /** ChatResult / ChatStreamEvent → OpenAI Responses API output. */
2
+ import { randomUUID } from 'node:crypto';
3
+ export function genId(prefix) {
4
+ return `${prefix}${randomUUID().replaceAll('-', '')}`;
5
+ }
6
+ export function mapFinishReasonToStatus(finishReason) {
7
+ switch (finishReason) {
8
+ case 'length':
9
+ return 'incomplete';
10
+ default:
11
+ return 'completed';
12
+ }
13
+ }
14
+ export function buildOutputItems(result) {
15
+ const items = [];
16
+ if (result.thinking) {
17
+ const reasoningItem = {
18
+ id: genId('rs_'),
19
+ type: 'reasoning',
20
+ summary: [{ type: 'summary_text', text: result.thinking }],
21
+ };
22
+ items.push(reasoningItem);
23
+ }
24
+ const okToolCalls = result.toolCalls.filter((t) => t.status === 'ok');
25
+ // Always emit a message item (possibly with empty text) unless there are tool calls and no text.
26
+ if (result.text || okToolCalls.length === 0) {
27
+ const messageItem = {
28
+ id: genId('msg_'),
29
+ type: 'message',
30
+ role: 'assistant',
31
+ status: mapFinishReasonToStatus(result.finishReason),
32
+ content: [{ type: 'output_text', text: result.text, annotations: [] }],
33
+ };
34
+ items.push(messageItem);
35
+ }
36
+ for (const tc of okToolCalls) {
37
+ const callId = tc.id ?? genId('call_');
38
+ const fcItem = {
39
+ id: genId('fc_'),
40
+ type: 'function_call',
41
+ call_id: callId,
42
+ name: tc.name,
43
+ arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments),
44
+ status: 'completed',
45
+ };
46
+ items.push(fcItem);
47
+ }
48
+ return items;
49
+ }
50
+ export function buildUsage(result) {
51
+ return {
52
+ input_tokens: result.promptTokens,
53
+ output_tokens: result.numTokens,
54
+ output_tokens_details: { reasoning_tokens: result.reasoningTokens },
55
+ total_tokens: result.promptTokens + result.numTokens,
56
+ };
57
+ }
58
+ /** Concatenate all `output_text` parts from message items. */
59
+ export function computeOutputText(items) {
60
+ const parts = [];
61
+ for (const item of items) {
62
+ if (item.type === 'message') {
63
+ for (const c of item.content) {
64
+ parts.push(c.text);
65
+ }
66
+ }
67
+ }
68
+ return parts.join('');
69
+ }
70
+ export function buildResponseObject(result, req, responseId, previousResponseId) {
71
+ const output = buildOutputItems(result);
72
+ const status = mapFinishReasonToStatus(result.finishReason);
73
+ return {
74
+ id: responseId,
75
+ object: 'response',
76
+ created_at: Math.floor(Date.now() / 1000),
77
+ status,
78
+ model: req.model,
79
+ output,
80
+ output_text: computeOutputText(output),
81
+ error: null,
82
+ incomplete_details: status === 'incomplete' ? { reason: 'max_output_tokens' } : null,
83
+ usage: buildUsage(result),
84
+ instructions: req.instructions ?? null,
85
+ temperature: req.temperature ?? null,
86
+ top_p: req.top_p ?? null,
87
+ max_output_tokens: req.max_output_tokens ?? null,
88
+ tools: req.tools ?? [],
89
+ tool_choice: req.tool_choice ?? null,
90
+ reasoning: req.reasoning ?? null,
91
+ previous_response_id: previousResponseId ?? null,
92
+ };
93
+ }
94
+ /** Build an in-progress ResponseObject for `response.created` / `response.in_progress`, before any output exists. */
95
+ export function buildPartialResponse(req, responseId, previousResponseId) {
96
+ return {
97
+ id: responseId,
98
+ object: 'response',
99
+ created_at: Math.floor(Date.now() / 1000),
100
+ status: 'in_progress',
101
+ model: req.model,
102
+ output: [],
103
+ output_text: '',
104
+ error: null,
105
+ incomplete_details: null,
106
+ usage: { input_tokens: 0, output_tokens: 0, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 0 },
107
+ instructions: req.instructions ?? null,
108
+ temperature: req.temperature ?? null,
109
+ top_p: req.top_p ?? null,
110
+ max_output_tokens: req.max_output_tokens ?? null,
111
+ tools: req.tools ?? [],
112
+ tool_choice: req.tool_choice ?? null,
113
+ reasoning: req.reasoning ?? null,
114
+ previous_response_id: previousResponseId ?? null,
115
+ };
116
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * PendingResponseWrites — per-store in-memory index of response
3
+ * records whose `ResponseStore.store(...)` promise has been initiated
4
+ * but has not yet resolved.
5
+ *
6
+ * ## Why this exists
7
+ *
8
+ * The responses endpoint starts `store.store(record)` synchronously
9
+ * inside the per-model `withExclusive` block (so the tracker observes
10
+ * the in-flight write before the mutex releases) but does NOT await
11
+ * it on the critical path. Without this tracker, a client that fires
12
+ * a follow-up request carrying `previous_response_id: A` immediately
13
+ * after seeing `response.completed` could race the off-lock
14
+ * `store.store()` for A and be rejected with a spurious
15
+ * `404 Previous response not found` because `getChain()` had not yet
16
+ * seen the row.
17
+ *
18
+ * The chain-lookup path consults `awaitPending(id)` BEFORE treating
19
+ * `getChain(id).length === 0` as a 404. If a write is still in flight,
20
+ * it awaits and retries `getChain`; the retry is guaranteed to see
21
+ * the row because the promise resolves only after the store's own
22
+ * serialization queue has accepted the insert.
23
+ *
24
+ * ## Semantics
25
+ *
26
+ * `track(id, promise)` registers `promise` under `id` and removes the
27
+ * entry when the promise settles (fulfill OR reject — a failed write
28
+ * leaves the tracker empty and the subsequent `getChain()` returns
29
+ * empty, which is the correct 404 shape).
30
+ *
31
+ * `awaitPending(id)` returns the tracked in-flight promise, or
32
+ * undefined. It is the SAME promise that was registered; our removal
33
+ * handler is attached via `.finally(...)` so the caller's rejection
34
+ * behaviour is unaffected. Callers typically swallow rejections with
35
+ * `await …catch(() => {})` before retrying `getChain()` because the
36
+ * rejection is already surfaced through the registering awaiter.
37
+ *
38
+ * ## Hard-timeout marker state
39
+ *
40
+ * When the responses-endpoint breaker decides a pending write is
41
+ * wedged it calls `markHardTimedOut(id, ttlMs, absoluteExpiresAt)`,
42
+ * which removes the id from `pending` (so the closure chain is
43
+ * reclaimable) and adds it to a lightweight `hardTimedOut` map. The
44
+ * continuation path then classifies a missing chain as retryable 503
45
+ * `storage_timeout` instead of permanent 404 while the marker is live.
46
+ *
47
+ * Marker lifetime is bounded by:
48
+ *
49
+ * min(write settlement, last continuation + TTL, absoluteExpiresAt)
50
+ *
51
+ * Five cleanup/refresh paths keep memory bounded and classification
52
+ * honest:
53
+ *
54
+ * 1. Fast path — `track()`'s `.finally(...)` deletes the marker as
55
+ * soon as the wedged write settles.
56
+ * 2. Read-refresh path — `isHardTimedOut(id)` slides `expiresAt`
57
+ * forward by `ttlMs` on every live hit (clamped at
58
+ * `absoluteExpiresAt`). Actively retried chains stay recoverable
59
+ * as long as the underlying write might still land.
60
+ * 3. Read-expire path — a full TTL elapse without refresh lazily
61
+ * deletes the entry and classifies the id as permanent 404.
62
+ * 4. Read-absolute-cap path — once `Date.now() >= absoluteExpiresAt`,
63
+ * the marker is deleted unconditionally. `ResponseStore.getChain()`
64
+ * hides the row past its own row TTL, so the retryable-503
65
+ * classification would be factually wrong.
66
+ * 5. Write-sweep path — `markHardTimedOut()` drains expired entries
67
+ * before inserting, bounded to `MAX_SWEEP_PER_INSERT` visits per
68
+ * call to keep the transition O(1) amortized even when the map
69
+ * is large. `isHardTimedOut()` moves refreshed entries to the
70
+ * Map tail so the bounded sweep cannot be starved by a stable
71
+ * head cohort of hot entries (natural LRU behaviour).
72
+ *
73
+ * The caller-side `absoluteExpiresAt` is the MINIMUM `expiresAt`
74
+ * across the whole resolved chain, not just the child record.
75
+ * `ResponseStore.getChain()` walks ancestors and aborts on the first
76
+ * expired link (see `crates/mlx-db/src/response_store/reader.rs:44-59`),
77
+ * so a child whose parent expires sooner is unrecoverable at the
78
+ * parent's expiry — not the child's. This module just receives the
79
+ * min-clamped value.
80
+ *
81
+ * ## Pending-entry earliest-expiry metadata
82
+ *
83
+ * The pre-breaker `awaitPending` timeout/probe path in `responses.ts`
84
+ * would otherwise classify any unresolved pending write as retryable
85
+ * `storage_timeout`, even when the resolved chain's earliest ancestor
86
+ * has already expired. A continuation whose parent is already past
87
+ * its row TTL cannot ever succeed via `getChain()`, so looping the
88
+ * client on 503 until the hard breaker fires is wasted.
89
+ *
90
+ * `track(id, promise, earliestExpiresAtMs?)` records the earliest
91
+ * recoverable expiry alongside the tracked promise in a per-id side
92
+ * map (`earliestExpiresByPending`). `getEarliestExpiresAtMs(id)`
93
+ * exposes it so the endpoint can short-circuit to 404 once
94
+ * `Date.now() >= earliestExpiresAtMs`. The side map is keyed
95
+ * identically to `pending` and cleared on the same `.finally(...)`
96
+ * hook — no extra lifecycle surface.
97
+ *
98
+ * ## Scope
99
+ *
100
+ * One tracker per `ResponseStore` instance is attached via a
101
+ * `WeakMap`, so callers never need to thread the tracker through the
102
+ * handler plumbing explicitly. This keeps the same (store, tracker)
103
+ * pair alive for the lifetime of the store and avoids leaks across
104
+ * test suites that recreate the store per describe block.
105
+ */
106
+ /**
107
+ * Per-store tracker for in-flight `store.store(...)` writes.
108
+ *
109
+ * Thread-safety: Node.js is single-threaded within one event loop
110
+ * tick, so the internal `Map` is safe against concurrent mutation by
111
+ * design. Every mutation (`track`, `awaitPending`, `.finally(...)`
112
+ * cleanup) runs synchronously within a tick.
113
+ */
114
+ export declare class PendingResponseWrites {
115
+ private readonly pending;
116
+ /**
117
+ * Per-pending-entry scalar recording the EARLIEST recoverable
118
+ * wall-clock expiry across (record + resolved ancestor chain) at
119
+ * `track()` time. Keyed identically to `pending` and cleared on
120
+ * the same `.finally(...)` settlement hook.
121
+ *
122
+ * The pre-breaker `awaitPending` timeout/probe path consults this
123
+ * via `getEarliestExpiresAtMs(id)`: once `Date.now()` has passed
124
+ * the earliest ancestor expiry, `getChain()` can never succeed,
125
+ * so the continuation short-circuits to 404 rather than looping
126
+ * the client on retryable 503.
127
+ *
128
+ * Optional: callers that pass `undefined` do not populate the
129
+ * side map; `getEarliestExpiresAtMs(id)` then returns `undefined`
130
+ * and the caller falls back to emitting retryable 503.
131
+ */
132
+ private readonly earliestExpiresByPending;
133
+ /**
134
+ * Ids that crossed the hard-timeout breaker in `responses.ts`
135
+ * while their `store.store(...)` promise was still unresolved.
136
+ *
137
+ * Each entry records `{ expiresAt, ttlMs, absoluteExpiresAt }` in
138
+ * epoch-ms. `expiresAt` is the TTL-based sliding window;
139
+ * `absoluteExpiresAt` is the record row's own wall-clock expiry
140
+ * (`record.expiresAt * 1000`). See the module header for the full
141
+ * cleanup/refresh path inventory.
142
+ *
143
+ * Invariant: a marker is only meaningful for the SPECIFIC write
144
+ * that was live when `markHardTimedOut` was called. If a later
145
+ * `track(id, newPromise)` reuses the same id after a marker was
146
+ * set, the original promise's `.finally(...)` will still clear
147
+ * the marker on its settlement (clearing the wrong state for the
148
+ * new promise). In practice the responses endpoint scopes
149
+ * response ids to a single persist each, so this collision cannot
150
+ * arise.
151
+ */
152
+ private readonly hardTimedOut;
153
+ /**
154
+ * Per-call visit budget for the opportunistic sweep invoked from
155
+ * `markHardTimedOut()`. Without a cap, refresh-on-read could keep
156
+ * the map arbitrarily large and every transition would pay O(N) on
157
+ * the main event loop (amortized O(N^2) across N wedged writes).
158
+ *
159
+ * Cap of 64 makes each transition O(1) with a small constant.
160
+ * JavaScript `Map` iterates in insertion order, so the sweep
161
+ * naturally drains the oldest markers first — which is where
162
+ * same-TTL expiries cluster. A backlog of K expired markers drains
163
+ * fully across ceil(K / 64) subsequent inserts, adequate because
164
+ * the read-path deletions are the authoritative cleanup signals
165
+ * for ids that actually receive continuation traffic.
166
+ *
167
+ * NOTE: the budget is a VISIT limit, not a delete limit. We stop
168
+ * after visiting MAX_SWEEP_PER_INSERT entries regardless of how
169
+ * many were expired, so cost stays bounded even when none of the
170
+ * first 64 entries are expired.
171
+ */
172
+ private static readonly MAX_SWEEP_PER_INSERT;
173
+ /**
174
+ * Register an in-flight write under `id`. The caller must pass the
175
+ * raw `Promise<void>` returned by `store.store(record)` BEFORE
176
+ * awaiting it — otherwise the race window we are trying to close
177
+ * reopens.
178
+ *
179
+ * The tracker attaches its own `.finally(...)` handler to remove
180
+ * the entry when the promise settles. The caller's own handling of
181
+ * the promise (await / catch / log) is unaffected because
182
+ * `.finally` returns a new promise chain that does not steal the
183
+ * rejection.
184
+ *
185
+ * `earliestExpiresAtMs` is the EARLIEST wall-clock expiry (epoch-ms)
186
+ * across the record being persisted AND every resolved ancestor in
187
+ * its chain. When provided, it is stored in
188
+ * `earliestExpiresByPending` so the pre-breaker `awaitPending`
189
+ * timeout/probe path can short-circuit to 404 once
190
+ * `Date.now() >= earliestExpiresAtMs` rather than emit retryable
191
+ * 503 for a chain that cannot be recovered via `getChain()`.
192
+ * `Number.isFinite(...)` guards for rows lacking explicit
193
+ * `expiresAt`.
194
+ */
195
+ track(id: string, writePromise: Promise<void>, earliestExpiresAtMs?: number): void;
196
+ /**
197
+ * Return the in-flight write promise for `id`, or `undefined` if
198
+ * none is tracked. Callers typically await with rejection
199
+ * suppressed (the tracker promise's rejection is already handled
200
+ * by the separate awaiter that initiated the write) and then
201
+ * retry `store.getChain(id)`.
202
+ */
203
+ awaitPending(id: string): Promise<void> | undefined;
204
+ /**
205
+ * Return the EARLIEST wall-clock expiry (epoch-ms) captured
206
+ * alongside the in-flight write for `id` at `track()` time, or
207
+ * `undefined` if no pending write is tracked and no live marker
208
+ * covers this id.
209
+ *
210
+ * Consulted by the pre-breaker `awaitPending` timeout/probe path in
211
+ * `responses.ts` to distinguish a transient storage slowdown
212
+ * (retryable 503) from an unrecoverable chain where the earliest
213
+ * ancestor has already aged out (permanent 404).
214
+ *
215
+ * Falls back to the marker's `absoluteExpiresAt` when the pending
216
+ * entry has already been drained by `markHardTimedOut()` — otherwise
217
+ * a waiter that straddled the `pending -> hardTimedOut` transition
218
+ * would see `undefined` and fall through to retryable 503 for an
219
+ * unrecoverable chain. Both sites are fed
220
+ * `min(recordExpiresAtMs, chainEarliestExpiresAtMs)` by
221
+ * `initiatePersist` in `responses.ts`, so this is lossless.
222
+ *
223
+ * The fallback is gated on the shared `isMarkerLive` predicate so
224
+ * a marker whose TTL or absolute expiry has already passed cannot
225
+ * hand back a future scalar that contradicts `isHardTimedOut()`.
226
+ * Dead markers return `0` (sentinel meaning "already expired") —
227
+ * the consumer's `Date.now() >= earliestMs` guard always trips for
228
+ * `0`, producing a permanent 404 instead of falling through to the
229
+ * retryable-503 branch. This read path stays side-effect-free;
230
+ * `sweepExpired()` is the authoritative reaper.
231
+ */
232
+ getEarliestExpiresAtMs(id: string): number | undefined;
233
+ /**
234
+ * Shared, side-effect-free liveness predicate for hard-timeout
235
+ * markers: live iff `now < expiresAt` AND `now < absoluteExpiresAt`.
236
+ *
237
+ * Extracted so `getEarliestExpiresAtMs()` (read-only) and the
238
+ * mutating `isHardTimedOut()` poll agree on liveness without either
239
+ * invoking the other — `isHardTimedOut()` has refresh + move-to-tail
240
+ * side effects only correct for the polling-side caller.
241
+ */
242
+ private static isMarkerLive;
243
+ /**
244
+ * Transition a pending entry to the hard-timed-out marker state.
245
+ *
246
+ * Called by the hard-timeout breaker in `responses.ts` when an
247
+ * in-flight `store.store(...)` has crossed the hard timeout and is
248
+ * presumed wedged. The `pending` entry is removed so `awaitPending`
249
+ * stops handing out the stale promise and the closure chain is
250
+ * reclaimable. The id is added to the marker map so the
251
+ * continuation path classifies missing chains as retryable 503
252
+ * `storage_timeout` instead of permanent 404 while the marker is
253
+ * live.
254
+ *
255
+ * `ttlMs` caps the marker lifetime independently of whether the
256
+ * underlying write settles. The caller (`responses.ts`) reads it
257
+ * from `MLX_HARD_TIMEOUT_MARKER_TTL_MS`; passing it in keeps this
258
+ * module env-free.
259
+ *
260
+ * `absoluteExpiresAt` is the response record's row expiry
261
+ * (`record.expiresAt * 1000`). The initial expiry is
262
+ * `min(Date.now() + ttlMs, absoluteExpiresAt)` so a short-lived
263
+ * record cannot have its marker outlive its row.
264
+ * `isHardTimedOut()` also consults `absoluteExpiresAt` on every
265
+ * read and hard-stops at that bound regardless of refreshes.
266
+ *
267
+ * Returns true if the id was an active pending entry and was moved
268
+ * to the marker; false if no pending entry existed at call time.
269
+ * A false return does NOT add the id to the marker — a marker
270
+ * without a backing promise has no fast cleanup signal (beyond
271
+ * TTL / absolute cap) and would produce spurious retryable-503
272
+ * signals in the meantime if the caller mis-routes ids.
273
+ */
274
+ markHardTimedOut(id: string, ttlMs: number, absoluteExpiresAt: number): boolean;
275
+ /**
276
+ * Drain expired marker entries (`expiresAt <= now` or
277
+ * `absoluteExpiresAt <= now`). Visits at most
278
+ * `MAX_SWEEP_PER_INSERT` entries per call so a caller cannot
279
+ * trigger an unbounded linear walk. `Map` insertion order makes
280
+ * this drain the oldest (most likely expired) markers first. The
281
+ * budget is a VISIT limit, not a delete limit.
282
+ */
283
+ private sweepExpired;
284
+ /**
285
+ * Whether `id` is currently flagged as hard-timed-out. Used by the
286
+ * `previous_response_id` continuation path to classify a missing
287
+ * chain as retryable 503 `storage_timeout` vs. permanent 404.
288
+ *
289
+ * Read-path cleanup + refresh semantics:
290
+ *
291
+ * - Absolute cap is authoritative: once `now >= absoluteExpiresAt`
292
+ * the marker is deleted unconditionally. `ResponseStore.getChain()`
293
+ * hides the row past its own row TTL, so retryable-503 would
294
+ * lie to the client.
295
+ * - TTL-expired: lazy delete + return false.
296
+ * - Live hit: refresh `expiresAt = min(now + ttlMs, absoluteExpiresAt)`
297
+ * so actively-retried chains stay recoverable while the write
298
+ * might still land, without ever outliving the row.
299
+ * - On every live hit, move the entry to the Map tail (O(1)
300
+ * `delete` + `set` using insertion-order semantics). Without
301
+ * the rotation a stable head cohort of hot refreshed entries
302
+ * could indefinitely block the bounded `sweepExpired()` from
303
+ * reaching expired markers behind them. LRU rotation lets the
304
+ * sweep make forward progress.
305
+ */
306
+ isHardTimedOut(id: string): boolean;
307
+ /** Number of writes currently in flight. Primarily for tests. */
308
+ get size(): number;
309
+ /**
310
+ * Number of ids currently in the hard-timed-out marker state.
311
+ * Primarily for tests. Delegates to the shared `sweepExpired()`
312
+ * helper so read-count and write-sweep stay in lockstep.
313
+ *
314
+ * Caveat: because the sweep is bounded (`MAX_SWEEP_PER_INSERT`
315
+ * visits per call), the reported size may include still-present
316
+ * expired entries that sit past the per-call visit budget.
317
+ * Callers needing exact reclaimed-count semantics should drive
318
+ * further `markHardTimedOut()` inserts (each drains another
319
+ * batch) or call `isHardTimedOut(id)` directly — the read-path
320
+ * deletion is authoritative and unbounded per-id.
321
+ */
322
+ get hardTimedOutSize(): number;
323
+ /**
324
+ * Number of ids currently holding a scalar entry in the
325
+ * pending-side earliest-expiry map. Primarily for tests —
326
+ * regressions that need to validate the pending-side map is
327
+ * drained (independent of the marker-map fallback in
328
+ * `getEarliestExpiresAtMs`) require a direct readout.
329
+ */
330
+ get earliestExpiresByPendingSize(): number;
331
+ }
332
+ /**
333
+ * Fetch (or lazily create) the tracker for a given store. Always
334
+ * returns the same tracker for the same store instance.
335
+ */
336
+ export declare function getPendingWritesFor(store: object): PendingResponseWrites;
337
+ //# sourceMappingURL=pending-writes.d.ts.map
@@ -0,0 +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"}