@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,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
|
+
}
|
package/src/streaming.ts
ADDED
|
@@ -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
|
+
}
|