@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,161 @@
1
+ /**
2
+ * Buffers streaming text to detect configured stop sequences. Text that
3
+ * cannot be part of a partial stop sequence is released immediately; a
4
+ * trailing suffix that could be the start of a stop sequence is held back
5
+ * until a later push resolves it or the stream is flushed. Once a full stop
6
+ * sequence is seen, everything after it is suppressed.
7
+ */
8
+ export class StopSequenceBuffer {
9
+ private readonly stopSequences: string[];
10
+ private readonly maxLength: number;
11
+ private pending_ = '';
12
+ private _matched: string | null = null;
13
+
14
+ constructor(stopSequences: string[]) {
15
+ // Drop empty AND whitespace-only entries: a whitespace-only stop would
16
+ // truncate normal output at the first space/newline, and the real
17
+ // Anthropic API rejects such stops outright. Mirrors the same trim filter
18
+ // in the request mapper so a whitespace-only configuration is a no-op.
19
+ this.stopSequences = stopSequences.filter((s) => s.trim().length > 0);
20
+ this.maxLength = this.stopSequences.reduce((max, s) => Math.max(max, s.length), 0);
21
+ }
22
+
23
+ /**
24
+ * Earliest index wins; on a tie at the same index the longest wins. Returns
25
+ * `{ idx, seq }` for the winning stop, or `{ idx: -1, seq: null }` when none
26
+ * is present in `pending`.
27
+ */
28
+ private findMatch(): { idx: number; seq: string | null } {
29
+ let matchIdx = -1;
30
+ let matchSeq: string | null = null;
31
+ for (const seq of this.stopSequences) {
32
+ const idx = this.pending_.indexOf(seq);
33
+ if (idx < 0) {
34
+ continue;
35
+ }
36
+ if (matchIdx < 0 || idx < matchIdx || (idx === matchIdx && seq.length > (matchSeq?.length ?? 0))) {
37
+ matchIdx = idx;
38
+ matchSeq = seq;
39
+ }
40
+ }
41
+ return { idx: matchIdx, seq: matchSeq };
42
+ }
43
+
44
+ /** The stop sequence that has matched so far, or `null` if none has. */
45
+ get matched(): string | null {
46
+ return this._matched;
47
+ }
48
+
49
+ /**
50
+ * The text currently held back (received but neither emitted nor matched).
51
+ * The streaming done-path reads this so it can scan the terminal/recovered
52
+ * text on the SAME buffer with the held partial still in place, and so it
53
+ * can reconstruct the full received-but-unemitted prefix for overlap math.
54
+ */
55
+ get pending(): string {
56
+ return this.pending_;
57
+ }
58
+
59
+ /**
60
+ * The earliest start index `j` in `[0, limit]` such that `pending.slice(j)`
61
+ * is a non-empty STRICT prefix of some configured stop — i.e. a partial that
62
+ * a later push could still grow into that stop. Returns -1 when no pending
63
+ * suffix at or before `limit` is viable. Scanning from the front yields the
64
+ * earliest start index, which is the one whose completed stop would win the
65
+ * earliest-index tiebreak. A suffix longer than `maxLength - 1` can never be
66
+ * a strict prefix of any stop, so the search starts no earlier than that.
67
+ */
68
+ private earliestViablePrefixIndex(limit: number): number {
69
+ if (this.pending_.length === 0) {
70
+ return -1;
71
+ }
72
+ const lowerBound = Math.max(0, this.pending_.length - (this.maxLength - 1));
73
+ const upper = Math.min(limit, this.pending_.length - 1);
74
+ for (let j = lowerBound; j <= upper; j++) {
75
+ const suffix = this.pending_.slice(j);
76
+ if (this.stopSequences.some((seq) => suffix.length < seq.length && seq.startsWith(suffix))) {
77
+ return j;
78
+ }
79
+ }
80
+ return -1;
81
+ }
82
+
83
+ /**
84
+ * Feed text in. Returns `safeText` (emit as delta) and `matched` (the stop
85
+ * sequence that has been matched, or `null`). After a match every push
86
+ * returns empty `safeText` and keeps reporting the matched sequence.
87
+ */
88
+ push(text: string): { safeText: string; matched: string | null } {
89
+ if (this._matched !== null) {
90
+ return { safeText: '', matched: this._matched };
91
+ }
92
+
93
+ // Transparent pass-through when there is nothing to detect.
94
+ if (this.stopSequences.length === 0) {
95
+ return { safeText: text, matched: null };
96
+ }
97
+
98
+ this.pending_ += text;
99
+
100
+ const { idx: matchIdx, seq: matchSeq } = this.findMatch();
101
+
102
+ // Earliest start index of a still-growable stop prefix. When a full match
103
+ // exists we only look at or before it (`limit = matchIdx`): a viable prefix
104
+ // beginning AFTER the match would complete at a later index and lose the
105
+ // earliest-index tiebreak, and the bytes from the match onward are
106
+ // suppressed anyway. A viable prefix at or before the match could still
107
+ // complete into a stop that WINS (earlier index, or longer at the same
108
+ // index), so the match must be held. With no full match we consider the
109
+ // whole pending text.
110
+ const limit = matchIdx >= 0 ? matchIdx : this.pending_.length - 1;
111
+ const holdIdx = this.earliestViablePrefixIndex(limit);
112
+
113
+ if (matchIdx >= 0 && matchSeq !== null) {
114
+ if (holdIdx >= 0) {
115
+ // A longer/earlier stop could still complete from `holdIdx`; emit only
116
+ // the bytes before it and keep the rest pending for a later push or
117
+ // `flush()` to resolve.
118
+ const safeText = this.pending_.slice(0, holdIdx);
119
+ this.pending_ = this.pending_.slice(holdIdx);
120
+ return { safeText, matched: null };
121
+ }
122
+ const safeText = this.pending_.slice(0, matchIdx);
123
+ this._matched = matchSeq;
124
+ this.pending_ = '';
125
+ return { safeText, matched: matchSeq };
126
+ }
127
+
128
+ // No full match: release everything before the earliest viable prefix and
129
+ // hold that suffix back, since a later push could complete it.
130
+ const safeLen = holdIdx >= 0 ? holdIdx : this.pending_.length;
131
+ const safeText = this.pending_.slice(0, safeLen);
132
+ this.pending_ = this.pending_.slice(safeLen);
133
+ return { safeText, matched: null };
134
+ }
135
+
136
+ /**
137
+ * Release any held-back text at stream end. If a stop sequence already
138
+ * matched, nothing more is emitted; otherwise the residue could not
139
+ * complete any sequence and is released.
140
+ */
141
+ flush(): { safeText: string; matched: string | null } {
142
+ if (this._matched !== null) {
143
+ return { safeText: '', matched: this._matched };
144
+ }
145
+ // The stream has ended, so any match `push()` held back for a possible
146
+ // longer same-index stop can no longer be extended — resolve it now.
147
+ // Re-scan `pending` for the earliest match (longest on tie) and commit it
148
+ // if present; otherwise the residue could not complete any sequence and is
149
+ // released verbatim.
150
+ const { idx: matchIdx, seq: matchSeq } = this.findMatch();
151
+ if (matchIdx >= 0 && matchSeq !== null) {
152
+ const safeText = this.pending_.slice(0, matchIdx);
153
+ this._matched = matchSeq;
154
+ this.pending_ = '';
155
+ return { safeText, matched: matchSeq };
156
+ }
157
+ const safeText = this.pending_;
158
+ this.pending_ = '';
159
+ return { safeText, matched: null };
160
+ }
161
+ }
@@ -0,0 +1,205 @@
1
+ /** SSE writer utilities. */
2
+
3
+ import type { IncomingMessage, ServerResponse } from 'node:http';
4
+
5
+ /** Default time a connected SSE peer may remain continuously backpressured. */
6
+ export const DEFAULT_SSE_DRAIN_TIMEOUT_MS = 30_000;
7
+
8
+ /**
9
+ * Responses that have committed to SSE (`beginSSE`) but have not yet been
10
+ * ended (`endSSE`) or had their connection torn down.
11
+ *
12
+ * Purely an accounting aid for {@link activeSSEStreamCount}, which graceful
13
+ * shutdown reads to report how many streams a forced close cut short. Nothing
14
+ * in the request path branches on membership, so a miscount can only skew a
15
+ * diagnostic number — never the behaviour of a live stream.
16
+ *
17
+ * Module-scoped because `@mlx-node/server` is loaded once per process and
18
+ * `beginSSE`/`endSSE` are free functions called from both endpoints.
19
+ */
20
+ const activeSSEResponses = new Set<ServerResponse>();
21
+
22
+ /** Disconnect state whose listeners stay armed for one complete SSE handler. */
23
+ export interface SSEClientAbortTracker {
24
+ readonly aborted: boolean;
25
+ markAborted(): void;
26
+ dispose(): void;
27
+ }
28
+
29
+ /**
30
+ * Track request/response/socket disconnects until the caller's outermost
31
+ * `finally`. Keeping this lifetime outside the decode loop matters: a final
32
+ * native item can expand into backpressured residual protocol frames after the
33
+ * iterator has already closed, and a disconnect during that drain must still
34
+ * prevent a success terminal and session adoption.
35
+ */
36
+ export function trackSSEClientAbort(res: ServerResponse, httpReq: IncomingMessage | undefined): SSEClientAbortTracker {
37
+ let aborted = false;
38
+ let disposed = false;
39
+ const onClose = (): void => {
40
+ aborted = true;
41
+ };
42
+ const onError = (_err: unknown): void => {
43
+ aborted = true;
44
+ };
45
+ const socket = res.socket;
46
+
47
+ if (httpReq != null) {
48
+ httpReq.once('close', onClose);
49
+ httpReq.on('error', onError);
50
+ }
51
+ res.once('close', onClose);
52
+ res.on('error', onError);
53
+ if (socket != null) {
54
+ socket.once('close', onClose);
55
+ socket.on('error', onError);
56
+ }
57
+
58
+ return {
59
+ get aborted(): boolean {
60
+ return aborted;
61
+ },
62
+ markAborted(): void {
63
+ aborted = true;
64
+ },
65
+ dispose(): void {
66
+ if (disposed) return;
67
+ disposed = true;
68
+ if (httpReq != null) {
69
+ httpReq.removeListener('close', onClose);
70
+ httpReq.removeListener('error', onError);
71
+ }
72
+ res.removeListener('close', onClose);
73
+ res.removeListener('error', onError);
74
+ if (socket != null) {
75
+ socket.removeListener('close', onClose);
76
+ socket.removeListener('error', onError);
77
+ }
78
+ },
79
+ };
80
+ }
81
+
82
+ interface SSEDrainWaitOptions {
83
+ /** Override used by focused tests; production reads the env/default. */
84
+ timeoutMs?: number;
85
+ /** Mark the owning handler's sticky abort state before the transport closes. */
86
+ onTimeout?: () => void;
87
+ }
88
+
89
+ function resolveSSEDrainTimeoutMs(): number {
90
+ const raw = process.env.MLX_SSE_DRAIN_TIMEOUT_MS;
91
+ if (raw == null || raw.trim() === '') return DEFAULT_SSE_DRAIN_TIMEOUT_MS;
92
+ const parsed = Number(raw);
93
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_SSE_DRAIN_TIMEOUT_MS;
94
+ }
95
+
96
+ export function beginSSE(res: ServerResponse): void {
97
+ activeSSEResponses.add(res);
98
+ // Belt-and-braces cleanup: a stream torn down by a client disconnect (or by
99
+ // `server.closeAllConnections()`) may unwind through an error path that
100
+ // never reaches `endSSE`. Without this the entry would leak for the life of
101
+ // the process and inflate the count forever.
102
+ res.once('close', () => {
103
+ activeSSEResponses.delete(res);
104
+ });
105
+ res.writeHead(200, {
106
+ 'Content-Type': 'text/event-stream',
107
+ 'Cache-Control': 'no-cache',
108
+ Connection: 'keep-alive',
109
+ });
110
+ }
111
+
112
+ /** Write one SSE event. Injects `type: eventType` into the payload (data's own `type` wins) for OpenAI SDK compatibility. */
113
+ export function writeSSEEvent(res: ServerResponse, eventType: string, data: object): boolean {
114
+ const payload = { type: eventType, ...data };
115
+ return res.write(`event: ${eventType}\ndata: ${JSON.stringify(payload)}\n\n`);
116
+ }
117
+
118
+ /**
119
+ * Wait until a backpressured response can accept more data, or until its
120
+ * transport closes. Close and error resolve rather than reject: the endpoint's
121
+ * outer abort tracker owns the sticky state, and the next loop check exits
122
+ * before another native item is written.
123
+ *
124
+ * Call this synchronously after `writeSSEEvent` returns false. In particular,
125
+ * do not defer listener installation until the next iterator turn: `drain`
126
+ * could fire while that turn is being fetched and leave the handler parked on
127
+ * an event that already happened.
128
+ */
129
+ export function awaitDrainOrClose(res: ServerResponse, options: SSEDrainWaitOptions = {}): Promise<void> {
130
+ return new Promise<void>((resolve) => {
131
+ let settled = false;
132
+ const socket = res.socket;
133
+ const timeoutMs = options.timeoutMs ?? resolveSSEDrainTimeoutMs();
134
+ const timer = setTimeout(() => {
135
+ // Set the handler-visible state synchronously. `destroy()` emits close on
136
+ // a later turn, which is too late for the success gate immediately after
137
+ // this promise resolves.
138
+ options.onTimeout?.();
139
+ if (!res.destroyed) res.destroy();
140
+ settle();
141
+ }, timeoutMs);
142
+ timer.unref();
143
+ const settle = (): void => {
144
+ if (settled) return;
145
+ settled = true;
146
+ clearTimeout(timer);
147
+ res.removeListener('drain', onDrain);
148
+ res.removeListener('close', onClose);
149
+ res.removeListener('error', onError);
150
+ if (socket != null) socket.removeListener('close', onClose);
151
+ resolve();
152
+ };
153
+ const onDrain = (): void => {
154
+ settle();
155
+ };
156
+ const onClose = (): void => {
157
+ settle();
158
+ };
159
+ const onError = (_err: unknown): void => {
160
+ settle();
161
+ };
162
+
163
+ // A destroyed peer cannot emit a future useful drain. Do not include
164
+ // `writableEnded` here: write-after-end returns false and reports
165
+ // ERR_STREAM_WRITE_AFTER_END asynchronously through the error listener.
166
+ if (res.destroyed || (socket != null && socket.destroyed)) {
167
+ settle();
168
+ return;
169
+ }
170
+
171
+ res.once('drain', onDrain);
172
+ res.once('close', onClose);
173
+ res.once('error', onError);
174
+ if (socket != null) socket.once('close', onClose);
175
+ });
176
+ }
177
+
178
+ export function endSSE(res: ServerResponse): void {
179
+ activeSSEResponses.delete(res);
180
+ res.end();
181
+ }
182
+
183
+ /**
184
+ * Number of SSE streams currently open process-wide. Diagnostics and
185
+ * shutdown accounting only.
186
+ */
187
+ export function activeSSEStreamCount(): number {
188
+ return activeSSEResponses.size;
189
+ }
190
+
191
+ /**
192
+ * Number of active SSE streams among a caller-owned collection of responses.
193
+ *
194
+ * `createServer()` uses this to intersect the process-wide SSE registry with
195
+ * the responses accepted by one `node:http` Server. Keep the no-argument
196
+ * {@link activeSSEStreamCount} above for process-wide diagnostics and
197
+ * standalone `createHandler()` consumers.
198
+ */
199
+ export function activeSSEStreamCountForResponses(responses: WeakSet<ServerResponse>): number {
200
+ let count = 0;
201
+ for (const response of activeSSEResponses) {
202
+ if (responses.has(response)) count += 1;
203
+ }
204
+ return count;
205
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Streaming text-recovery helpers shared between the `/v1/messages` and
3
+ * `/v1/responses` endpoints.
4
+ *
5
+ * Both endpoints have a tool-call streaming recovery branch that has to
6
+ * compute the unsent suffix of `finalText` given that some prefix of the
7
+ * model's output may already have been streamed to the wire, but native-side
8
+ * string normalization makes `finalText` diverge from the streamed-prefix
9
+ * verbatim. Concrete divergences seen in practice:
10
+ *
11
+ * * The native side trims leading whitespace after `</think>` via
12
+ * `split_at_think_end`, so the streamed text can end in `"\n\n"` while
13
+ * `finalText` starts at `"<tool_call>"` (no overlap — emit `finalText`
14
+ * whole).
15
+ * * The native side `.trim()`s tool-tag-bracketed content boundaries, so
16
+ * the streamed text can have a trailing space that `finalText` lacks
17
+ * (also no overlap — emit `finalText` whole).
18
+ *
19
+ * Internal-only — not exported from `packages/server/src/index.ts`.
20
+ */
21
+
22
+ /**
23
+ * Find the largest k such that `streamed.endsWith(final.slice(0, k))`.
24
+ *
25
+ * Returns 0 when there is no overlap (caller emits `final` whole).
26
+ * Returns `final.length` when `final` is fully contained as a suffix of
27
+ * `streamed` (caller emits nothing).
28
+ *
29
+ * Used by the `/v1/messages` and `/v1/responses` streaming tool-call
30
+ * recovery branches to decide how much of `finalText` is already on the
31
+ * wire when native-side normalization (e.g. `.trim()`, post-`</think>`
32
+ * whitespace stripping) makes the streamed prefix diverge from the
33
+ * `finalText` prefix verbatim.
34
+ */
35
+ export function longestSuffixPrefixOverlap(streamed: string, final: string): number {
36
+ const max = Math.min(streamed.length, final.length);
37
+ for (let k = max; k > 0; k--) {
38
+ if (streamed.endsWith(final.slice(0, k))) return k;
39
+ }
40
+ return 0;
41
+ }
package/src/timing.ts ADDED
@@ -0,0 +1,236 @@
1
+ /** Wire-safe server timing extensions derived from native performance metrics. */
2
+
3
+ export interface PerformanceMetricsForUsage {
4
+ ttftMs?: number;
5
+ prefillTokensPerSecond?: number;
6
+ decodeTokensPerSecond?: number;
7
+ }
8
+
9
+ interface TimingUsageExtensions {
10
+ /** Server-extension: native time-to-first-token in milliseconds. */
11
+ time_to_first_token_ms?: number;
12
+ /** Server-extension: prompt-token throughput for the tokens actually prefetched this turn. */
13
+ prefill_tokens_per_second?: number;
14
+ /** Server-extension: generated-token throughput during decode. */
15
+ decode_tokens_per_second?: number;
16
+ /** Server-extension: native/server inference elapsed, excluding HTTP transport and logging overhead. */
17
+ server_inference_elapsed_ms?: number;
18
+ /** Server-extension alias for disambiguating native TTFT from request/HTTP elapsed time. */
19
+ server_time_to_first_token_ms?: number;
20
+ /** Server-extension: handler-start to first native token, including model resolve/load and queue wait. */
21
+ server_total_time_to_first_token_ms?: number;
22
+ /** Server-extension alias for native prefill throughput. */
23
+ server_prefill_tokens_per_second?: number;
24
+ /** Server-extension alias for native decode throughput. */
25
+ server_decode_tokens_per_second?: number;
26
+ /** Server-extension: prompt tokens actually prefetched this turn after cached-prefix reuse. */
27
+ prefill_input_tokens?: number;
28
+ /** Server-extension: prompt tokens skipped because a cached prefix was reused. */
29
+ cached_prefix_tokens?: number;
30
+ /**
31
+ * Server-extension: time spent resolving/loading/aliasing the requested model
32
+ * before registry lookup. Includes both the synchronous lookup AND any time
33
+ * spent driving the load. Excludes time spent blocked behind a peer
34
+ * request's in-flight load — that wait is reported separately via
35
+ * `server_load_wait_ms` so a fast follower request is not mis-attributed
36
+ * a long resolve when it merely inherited a cold-load wait.
37
+ */
38
+ server_model_resolve_ms?: number;
39
+ /**
40
+ * Server-extension: wall-clock time this request spent blocked on the
41
+ * process-wide model-load writer lock. Set when the load was already
42
+ * in flight when this request arrived (a peer drove the load and we
43
+ * waited for it to finish) AND when this request itself drove the
44
+ * load. Inspect `server_load_owner` to disambiguate. When the writer
45
+ * lock was free and the resolve was a no-op (model already loaded),
46
+ * this field is elided.
47
+ */
48
+ server_load_wait_ms?: number;
49
+ /**
50
+ * Server-extension: `true` when this request acquired the model-load
51
+ * writer lock with no contention (i.e. either the model was already
52
+ * loaded and the call was a no-op, or this request itself drove the
53
+ * load). `false` when the request was parked behind a peer's in-flight
54
+ * load. Elided when no load coordinator is wired (single-process
55
+ * tests, embedded callers).
56
+ */
57
+ server_load_owner?: boolean;
58
+ /** Server-extension: time spent waiting behind the per-model execution mutex. */
59
+ server_queue_ms?: number;
60
+ /** Server-extension: handler time before native inference begins, including resolve and queue wait. */
61
+ server_pre_inference_ms?: number;
62
+ /** Server-extension: effective process-level paged-prefill chunk size. */
63
+ server_paged_prefill_chunk_size?: number;
64
+ /** Server-extension: effective process-level paged-prefill eval/clear cadence. */
65
+ server_paged_prefill_eval_interval?: number;
66
+ /** Server-extension: effective process-level paged-decode cache-clear cadence. */
67
+ server_paged_decode_cache_clear_interval?: number;
68
+ }
69
+
70
+ function finitePositive(value: number | undefined): number | undefined {
71
+ return value != null && Number.isFinite(value) && value > 0 ? value : undefined;
72
+ }
73
+
74
+ function finiteNonNegativeInteger(value: number | undefined): number | undefined {
75
+ return value != null && Number.isFinite(value) && value >= 0 ? Math.max(0, Math.floor(value)) : undefined;
76
+ }
77
+
78
+ function finiteNonNegative(value: number | undefined): number | undefined {
79
+ return value != null && Number.isFinite(value) && value >= 0 ? value : undefined;
80
+ }
81
+
82
+ export interface ServerTimingForUsage {
83
+ server_model_resolve_ms?: number;
84
+ server_load_wait_ms?: number;
85
+ server_load_owner?: boolean;
86
+ server_queue_ms?: number;
87
+ server_pre_inference_ms?: number;
88
+ server_paged_prefill_chunk_size?: number;
89
+ server_paged_prefill_eval_interval?: number;
90
+ server_paged_decode_cache_clear_interval?: number;
91
+ }
92
+
93
+ const I32_MAX = 0x7fff_ffff;
94
+
95
+ function parseI32(value: string | undefined): number | undefined {
96
+ if (value == null) return undefined;
97
+ const trimmed = value.trim();
98
+ if (!/^[+-]?\d+$/.test(trimmed)) return undefined;
99
+ const parsed = Number.parseInt(trimmed, 10);
100
+ return Number.isSafeInteger(parsed) && parsed >= -0x8000_0000 && parsed <= I32_MAX ? parsed : undefined;
101
+ }
102
+
103
+ function parseNonNegativeI32(value: string | undefined, fallback: number): number {
104
+ const parsed = parseI32(value);
105
+ return parsed != null && parsed >= 0 ? parsed : fallback;
106
+ }
107
+
108
+ function parsePositiveI32(value: string | undefined, fallback: number): number {
109
+ const parsed = parseI32(value);
110
+ return parsed != null && parsed > 0 ? parsed : fallback;
111
+ }
112
+
113
+ export function resolveServerTuningForUsage(
114
+ env: Record<string, string | undefined> = process.env,
115
+ ): Pick<
116
+ ServerTimingForUsage,
117
+ 'server_paged_prefill_chunk_size' | 'server_paged_prefill_eval_interval' | 'server_paged_decode_cache_clear_interval'
118
+ > {
119
+ return {
120
+ server_paged_prefill_chunk_size: parseNonNegativeI32(env.MLX_PAGED_PREFILL_CHUNK_SIZE, 0),
121
+ server_paged_prefill_eval_interval: parsePositiveI32(env.MLX_PAGED_PREFILL_EVAL_INTERVAL, 8),
122
+ server_paged_decode_cache_clear_interval: parsePositiveI32(env.MLX_PAGED_DECODE_CACHE_CLEAR_INTERVAL, 1024),
123
+ };
124
+ }
125
+
126
+ function computeServerInferenceElapsedMs(
127
+ ttftMs: number | undefined,
128
+ decodeTokensPerSecond: number | undefined,
129
+ outputTokens: number | undefined,
130
+ ): number | undefined {
131
+ if (ttftMs == null) return undefined;
132
+
133
+ const generatedTokens = finiteNonNegativeInteger(outputTokens);
134
+ if (generatedTokens == null) {
135
+ return ttftMs;
136
+ }
137
+ if (generatedTokens <= 1) {
138
+ return ttftMs;
139
+ }
140
+ if (decodeTokensPerSecond == null) {
141
+ return undefined;
142
+ }
143
+ return ttftMs + ((generatedTokens - 1) / decodeTokensPerSecond) * 1000;
144
+ }
145
+
146
+ function buildTimingUsageExtensions(
147
+ performance: PerformanceMetricsForUsage | undefined,
148
+ promptTokens: number | undefined,
149
+ outputTokens: number | undefined,
150
+ cachedTokens: number | undefined,
151
+ serverTiming?: ServerTimingForUsage,
152
+ ): TimingUsageExtensions {
153
+ const ttftMs = finitePositive(performance?.ttftMs);
154
+ const prefillTokensPerSecond = finitePositive(performance?.prefillTokensPerSecond);
155
+ const decodeTokensPerSecond = finitePositive(performance?.decodeTokensPerSecond);
156
+ const serverInferenceElapsedMs = computeServerInferenceElapsedMs(ttftMs, decodeTokensPerSecond, outputTokens);
157
+ const preInferenceMs = finiteNonNegative(serverTiming?.server_pre_inference_ms);
158
+
159
+ const extensions: TimingUsageExtensions = {};
160
+ if (ttftMs != null) {
161
+ extensions.time_to_first_token_ms = ttftMs;
162
+ extensions.server_time_to_first_token_ms = ttftMs;
163
+ if (preInferenceMs != null) {
164
+ extensions.server_total_time_to_first_token_ms = preInferenceMs + ttftMs;
165
+ }
166
+ }
167
+ if (prefillTokensPerSecond != null) {
168
+ extensions.prefill_tokens_per_second = prefillTokensPerSecond;
169
+ extensions.server_prefill_tokens_per_second = prefillTokensPerSecond;
170
+ }
171
+ if (decodeTokensPerSecond != null) {
172
+ extensions.decode_tokens_per_second = decodeTokensPerSecond;
173
+ extensions.server_decode_tokens_per_second = decodeTokensPerSecond;
174
+ }
175
+ if (serverInferenceElapsedMs != null && Number.isFinite(serverInferenceElapsedMs) && serverInferenceElapsedMs > 0) {
176
+ extensions.server_inference_elapsed_ms = serverInferenceElapsedMs;
177
+ }
178
+
179
+ if (performance != null) {
180
+ const prompt = finiteNonNegativeInteger(promptTokens);
181
+ const cached = finiteNonNegativeInteger(cachedTokens);
182
+ if (prompt != null) {
183
+ const cachedPrefix = cached == null ? 0 : Math.min(cached, prompt);
184
+ extensions.prefill_input_tokens = prompt - cachedPrefix;
185
+ if (cachedPrefix > 0) {
186
+ extensions.cached_prefix_tokens = cachedPrefix;
187
+ }
188
+ }
189
+ }
190
+
191
+ const modelResolveMs = finiteNonNegative(serverTiming?.server_model_resolve_ms);
192
+ if (modelResolveMs != null) {
193
+ extensions.server_model_resolve_ms = modelResolveMs;
194
+ }
195
+ const loadWaitMs = finiteNonNegative(serverTiming?.server_load_wait_ms);
196
+ if (loadWaitMs != null) {
197
+ extensions.server_load_wait_ms = loadWaitMs;
198
+ }
199
+ if (typeof serverTiming?.server_load_owner === 'boolean') {
200
+ extensions.server_load_owner = serverTiming.server_load_owner;
201
+ }
202
+ const queueMs = finiteNonNegative(serverTiming?.server_queue_ms);
203
+ if (queueMs != null) {
204
+ extensions.server_queue_ms = queueMs;
205
+ }
206
+ if (preInferenceMs != null) {
207
+ extensions.server_pre_inference_ms = preInferenceMs;
208
+ }
209
+ const pagedPrefillChunkSize = finiteNonNegativeInteger(serverTiming?.server_paged_prefill_chunk_size);
210
+ if (pagedPrefillChunkSize != null) {
211
+ extensions.server_paged_prefill_chunk_size = pagedPrefillChunkSize;
212
+ }
213
+ const pagedPrefillEvalInterval = finiteNonNegativeInteger(serverTiming?.server_paged_prefill_eval_interval);
214
+ if (pagedPrefillEvalInterval != null) {
215
+ extensions.server_paged_prefill_eval_interval = pagedPrefillEvalInterval;
216
+ }
217
+ const pagedDecodeCacheClearInterval = finiteNonNegativeInteger(
218
+ serverTiming?.server_paged_decode_cache_clear_interval,
219
+ );
220
+ if (pagedDecodeCacheClearInterval != null) {
221
+ extensions.server_paged_decode_cache_clear_interval = pagedDecodeCacheClearInterval;
222
+ }
223
+
224
+ return extensions;
225
+ }
226
+
227
+ export function mergeTimingUsageExtensions<T extends TimingUsageExtensions>(
228
+ usage: T,
229
+ performance: PerformanceMetricsForUsage | undefined,
230
+ promptTokens: number | undefined,
231
+ outputTokens: number | undefined,
232
+ cachedTokens: number | undefined,
233
+ serverTiming?: ServerTimingForUsage,
234
+ ): void {
235
+ Object.assign(usage, buildTimingUsageExtensions(performance, promptTokens, outputTokens, cachedTokens, serverTiming));
236
+ }